package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.EnvDischargePermit; import com.kaidi.oa.domain.EnvMonitorRecord; import com.kaidi.oa.repository.EnvDischargePermitRepository; import com.kaidi.oa.repository.EnvMonitorRecordRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.LinkedHashMap; import java.util.Map; /** * 质安部·本企业排污许可管理(需求 §4 环境保护管理·排污许可管理)。补深审计 high 缺口: * 排污许可证台账 + 监测结果录入(子表 EnvMonitorRecord)+ 超标自动预警 + 有效期到期提醒。 * * 关键工作流: * - POST /{id}/monitor:录入监测值,自动与限值比对,超标落档 status=超标。 * - GET /{id}/monitors:查询该证的监测历史(时序)。 * - POST /scan-expiry:扫描即将到期(30天内)/ 已过期许可证,派生 status 并返回到期清单。 * - GET /exceed-alerts:返回最近有超标记录的许可证列表(聚合快速预警入口)。 * * 写口默认受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护。 */ @RestController @RequestMapping("/api/oa/env-discharge-permits") public class EnvDischargePermitController { private static final List ENV_TYPES = List.of("废气", "废水", "噪声", "固废"); private static final int WARN_DAYS = 90; private final EnvDischargePermitRepository permitRepo; private final EnvMonitorRecordRepository monitorRepo; public EnvDischargePermitController(EnvDischargePermitRepository permitRepo, EnvMonitorRecordRepository monitorRepo) { this.permitRepo = permitRepo; this.monitorRepo = monitorRepo; } // ---------- 许可证台账 CRUD ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) String status, @RequestParam(required = false) String envType) { List list; if (status != null && !status.isBlank()) { list = permitRepo.findByStatus(status); } else if (envType != null && !envType.isBlank()) { list = permitRepo.findByEnvType(envType); } else { list = permitRepo.findAll(); } list.forEach(this::deriveStatus); return ApiResp.ok(list); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { EnvDischargePermit p = permitRepo.findById(id) .orElseThrow(() -> new NotFoundException("排污许可证不存在: " + id)); deriveStatus(p); return ApiResp.ok(p); } public record PermitRequest( String code, String permitNo, String envType, String outlet, String pollutant, Double limitValue, String unit, String monitorRequire, String issuer, String validFrom, String validTo) { } @PostMapping @Transactional public ApiResp create(@RequestBody PermitRequest req) { if (req.pollutant() == null || req.pollutant().isBlank()) { throw new ApiException(400, "污染因子不能为空"); } if (req.permitNo() != null && !req.permitNo().isBlank() && permitRepo.existsByPermitNo(req.permitNo())) { throw new ApiException(409, "排污许可证编号已存在: " + req.permitNo()); } EnvDischargePermit p = new EnvDischargePermit(); p.setCode(req.code() == null || req.code().isBlank() ? "PWXK-" + (permitRepo.count() + 1) : req.code()); p.setPermitNo(req.permitNo()); p.setEnvType(req.envType() == null || req.envType().isBlank() ? "废水" : req.envType()); p.setOutlet(req.outlet()); p.setPollutant(req.pollutant()); p.setLimitValue(req.limitValue()); p.setUnit(req.unit()); p.setMonitorRequire(req.monitorRequire() == null || req.monitorRequire().isBlank() ? "季度" : req.monitorRequire()); p.setIssuer(req.issuer()); p.setValidFrom(req.validFrom()); p.setValidTo(req.validTo()); p.setStatus("有效"); p.setCreatedAt(Instant.now()); deriveStatus(p); return ApiResp.ok(permitRepo.save(p)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody PermitRequest req) { EnvDischargePermit p = permitRepo.findById(id) .orElseThrow(() -> new NotFoundException("排污许可证不存在: " + id)); if (req.permitNo() != null && !req.permitNo().isBlank() && !req.permitNo().equals(p.getPermitNo()) && permitRepo.existsByPermitNo(req.permitNo())) { throw new ApiException(409, "排污许可证编号已存在: " + req.permitNo()); } if (req.permitNo() != null) p.setPermitNo(req.permitNo()); if (req.envType() != null && !req.envType().isBlank()) p.setEnvType(req.envType()); if (req.outlet() != null) p.setOutlet(req.outlet()); if (req.pollutant() != null && !req.pollutant().isBlank()) p.setPollutant(req.pollutant()); if (req.limitValue() != null) p.setLimitValue(req.limitValue()); if (req.unit() != null) p.setUnit(req.unit()); if (req.monitorRequire() != null) p.setMonitorRequire(req.monitorRequire()); if (req.issuer() != null) p.setIssuer(req.issuer()); if (req.validFrom() != null) p.setValidFrom(req.validFrom()); if (req.validTo() != null) p.setValidTo(req.validTo()); deriveStatus(p); return ApiResp.ok(permitRepo.save(p)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { if (!permitRepo.existsById(id)) { throw new NotFoundException("排污许可证不存在: " + id); } // 级联删除监测记录 monitorRepo.findByPermitId(id).forEach(r -> monitorRepo.deleteById(r.getId())); permitRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 监测记录子表 ---------- @GetMapping("/{id}/monitors") public ApiResp> monitors(@PathVariable Long id) { if (!permitRepo.existsById(id)) { throw new NotFoundException("排污许可证不存在: " + id); } return ApiResp.ok(monitorRepo.findByPermitIdOrderByMonitorDateDesc(id)); } public record MonitorRequest( String monitorDate, String measureType, Double measuredValue, String monitor, String note) { } /** * 录入监测值:自动与许可证限值比对,超标落档 status=超标 并回传超标倍率。 * 接近限值(实测/限值 >= 0.8)落档 status=接近限值。 */ @PostMapping("/{id}/monitor") @Transactional public ApiResp addMonitor(@PathVariable Long id, @RequestBody MonitorRequest req) { EnvDischargePermit permit = permitRepo.findById(id) .orElseThrow(() -> new NotFoundException("排污许可证不存在: " + id)); if (req.measuredValue() == null) { throw new ApiException(400, "实测值不能为空"); } EnvMonitorRecord r = new EnvMonitorRecord(); r.setPermitId(id); r.setMonitorDate(req.monitorDate() == null || req.monitorDate().isBlank() ? LocalDate.now().toString() : req.monitorDate()); r.setMeasureType(req.measureType() == null || req.measureType().isBlank() ? "手动监测" : req.measureType()); r.setMeasuredValue(req.measuredValue()); r.setLimitValue(permit.getLimitValue()); r.setUnit(permit.getUnit()); r.setMonitor(req.monitor()); r.setNote(req.note()); // 自动比对超标 String monitorStatus; Double excessRatio = null; if (permit.getLimitValue() != null && permit.getLimitValue() > 0) { double ratio = req.measuredValue() / permit.getLimitValue(); excessRatio = ratio - 1.0; if (ratio > 1.0) { monitorStatus = "超标"; } else if (ratio >= 0.8) { monitorStatus = "接近限值"; } else { monitorStatus = "达标"; } } else { monitorStatus = "达标"; } r.setStatus(monitorStatus); r.setExcessRatio(excessRatio); r.setCreatedAt(Instant.now()); return ApiResp.ok(monitorRepo.save(r)); } @DeleteMapping("/monitors/{rid}") @Transactional public ApiResp deleteMonitor(@PathVariable Long rid) { if (!monitorRepo.existsById(rid)) { throw new NotFoundException("监测记录不存在: " + rid); } monitorRepo.deleteById(rid); return ApiResp.ok(null); } // ---------- 超标预警聚合 ---------- public record ExceedAlert( Long permitId, String code, String permitNo, String envType, String outlet, String pollutant, String unit, String monitorDate, Double measuredValue, Double limitValue, Double excessRatio, String monitorStatus) { } /** 返回最近有超标记录的许可证列表(取各证最新一条超标记录)。 */ @GetMapping("/exceed-alerts") public ApiResp> exceedAlerts() { List exceeds = monitorRepo.findByStatus("超标"); Map latest = new LinkedHashMap<>(); for (EnvMonitorRecord rec : exceeds) { latest.merge(rec.getPermitId(), rec, (a, b) -> { // Keep the one with the later monitorDate if (b.getMonitorDate() != null && a.getMonitorDate() != null && b.getMonitorDate().compareTo(a.getMonitorDate()) > 0) { return b; } return a; }); } List result = new ArrayList<>(); for (Map.Entry entry : latest.entrySet()) { permitRepo.findById(entry.getKey()).ifPresent(permit -> { EnvMonitorRecord rec = entry.getValue(); result.add(new ExceedAlert( permit.getId(), permit.getCode(), permit.getPermitNo(), permit.getEnvType(), permit.getOutlet(), permit.getPollutant(), rec.getUnit(), rec.getMonitorDate(), rec.getMeasuredValue(), rec.getLimitValue(), rec.getExcessRatio(), rec.getStatus())); }); } return ApiResp.ok(result); } // ---------- 到期扫描 ---------- public record ExpiryRow( Long id, String code, String permitNo, String envType, String outlet, String pollutant, String validTo, long daysToExpire, String level) { } /** * 扫描排污许可证有效期:即将到期(90天内)/ 已过期,自动落档 status 并返回清单。 */ @PostMapping("/scan-expiry") @Transactional public ApiResp> scanExpiry() { LocalDate today = LocalDate.now(); List rows = new ArrayList<>(); for (EnvDischargePermit p : permitRepo.findAll()) { if (p.getValidTo() == null || p.getValidTo().isBlank()) continue; try { LocalDate expiry = LocalDate.parse(p.getValidTo()); long days = ChronoUnit.DAYS.between(today, expiry); String newStatus; String level; if (days < 0) { newStatus = "已过期"; level = "danger"; } else if (days <= WARN_DAYS) { newStatus = "即将到期"; level = "warning"; } else { newStatus = "有效"; level = "success"; } if (!newStatus.equals(p.getStatus())) { p.setStatus(newStatus); permitRepo.save(p); } if (!"有效".equals(newStatus)) { rows.add(new ExpiryRow(p.getId(), p.getCode(), p.getPermitNo(), p.getEnvType(), p.getOutlet(), p.getPollutant(), p.getValidTo(), days, level)); } } catch (DateTimeParseException ignored) { } } return ApiResp.ok(rows); } // ---------- 私有 ---------- private void deriveStatus(EnvDischargePermit p) { if (p.getValidTo() == null || p.getValidTo().isBlank()) return; try { LocalDate expiry = LocalDate.parse(p.getValidTo()); long days = ChronoUnit.DAYS.between(LocalDate.now(), expiry); if (days < 0) { p.setStatus("已过期"); } else if (days <= WARN_DAYS) { p.setStatus("即将到期"); } else { p.setStatus("有效"); } } catch (DateTimeParseException ignored) { } } }