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.WastewaterClient; import com.kaidi.oa.domain.WwSamplingTask; import com.kaidi.oa.repository.WastewaterClientRepository; import com.kaidi.oa.repository.WwSamplingTaskRepository; 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.util.ArrayList; import java.util.List; /** * 工业废水运营中心·§5 水质检测与实验室管理——采样任务单。 * *

覆盖: *

*

*/ @RestController @RequestMapping("/api/oa/ww-sampling-tasks") public class WwSamplingTaskController { /** GB8978 综合废水二级排放标准近似限值(mg/L,可后续可配置化)。 */ private static final double LIMIT_COD_GB8978 = 150.0; private static final double LIMIT_AMMONIA_GB8978 = 25.0; private static final double LIMIT_TN_GB8978 = 40.0; private static final double LIMIT_TP_GB8978 = 3.0; private static final double LIMIT_SS_GB8978 = 150.0; private static final double LIMIT_CR6_GB8978 = 0.5; private static final double LIMIT_TOTAL_CR_GB8978 = 1.5; private static final double LIMIT_CYANIDE_GB8978 = 0.5; private static final double LIMIT_PHENOL_GB8978 = 0.5; private static final double LIMIT_NICKEL_GB8978 = 1.0; private static final double LIMIT_COPPER_GB8978 = 0.5; private static final double LIMIT_ZINC_GB8978 = 2.0; private static final double LIMIT_PH_MIN = 6.0; private static final double LIMIT_PH_MAX = 9.0; private final WwSamplingTaskRepository taskRepo; private final WastewaterClientRepository clientRepo; public WwSamplingTaskController(WwSamplingTaskRepository taskRepo, WastewaterClientRepository clientRepo) { this.taskRepo = taskRepo; this.clientRepo = clientRepo; } // =============================================================== // 采样任务 CRUD // =============================================================== @GetMapping public ApiResp> list( @RequestParam(required = false) String status, @RequestParam(required = false) Long clientId, @RequestParam(required = false) String judgeResult) { if (clientId != null) return ApiResp.ok(taskRepo.findByClientId(clientId)); if (judgeResult != null) return ApiResp.ok(taskRepo.findByJudgeResult(judgeResult)); if (status != null) return ApiResp.ok(taskRepo.findByStatus(status)); return ApiResp.ok(taskRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(taskRepo.findById(id) .orElseThrow(() -> new NotFoundException("采样任务不存在: " + id))); } public record TaskRequest( String samplePoint, String sampleType, Long clientId, String clientName, Long unitId, String planDate, String sampler, String standard, String remark) { } @PostMapping public ApiResp create(@RequestBody TaskRequest req) { if (req.samplePoint() == null || req.samplePoint().isBlank()) { throw new ApiException(400, "采样点位不能为空"); } WwSamplingTask t = new WwSamplingTask(); long seq = taskRepo.count() + 1; t.setSampleCode("WW-" + LocalDate.now().getYear() + "-" + String.format("%03d", seq)); applyBase(t, req); t.setStatus("待采样"); t.setPlanDate(req.planDate() == null ? LocalDate.now().toString() : req.planDate()); t.setStandard(req.standard() == null ? "GB8978" : req.standard()); t.setCreatedAt(Instant.now()); t.setUpdatedAt(Instant.now()); return ApiResp.ok(taskRepo.save(t)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody TaskRequest req) { WwSamplingTask t = taskRepo.findById(id) .orElseThrow(() -> new NotFoundException("采样任务不存在: " + id)); applyBase(t, req); t.setUpdatedAt(Instant.now()); return ApiResp.ok(taskRepo.save(t)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!taskRepo.existsById(id)) { throw new NotFoundException("采样任务不存在: " + id); } taskRepo.deleteById(id); return ApiResp.ok(null); } // =============================================================== // 批量生成采样计划 // =============================================================== public record GenerateRequest(String planDate, String standard) { } /** * 按所有运营中的排污企业自动生成一批"企业来水"采样任务(每企业 1 条)。 * 如当日任务已存在则跳过(去重)。返回本次新建任务列表。 */ @PostMapping("/generate") @Transactional public ApiResp> generate(@RequestBody GenerateRequest req) { String date = req.planDate() == null ? LocalDate.now().toString() : req.planDate(); String std = req.standard() == null ? "GB8978" : req.standard(); List clients = clientRepo.findByServiceStatus("运营中"); List created = new ArrayList<>(); long seq = taskRepo.count(); for (WastewaterClient c : clients) { // 去重:当日同企业来水采样任务已存在则跳过 List existing = taskRepo.findByClientId(c.getId()).stream() .filter(t -> date.equals(t.getPlanDate()) && "企业来水".equals(t.getSampleType())) .toList(); if (!existing.isEmpty()) { continue; } seq++; WwSamplingTask t = new WwSamplingTask(); t.setSampleCode("WW-" + LocalDate.now().getYear() + "-" + String.format("%03d", seq)); t.setSamplePoint(c.getName() + " 进水口"); t.setSampleType("企业来水"); t.setClientId(c.getId()); t.setClientName(c.getName()); t.setPlanDate(date); t.setStatus("待采样"); t.setStandard(std); t.setCreatedAt(Instant.now()); t.setUpdatedAt(Instant.now()); created.add(taskRepo.save(t)); } return ApiResp.ok(created); } // =============================================================== // 检测结果录入 + 自动达标判定 // =============================================================== public record AnalyzeRequest( String actualTime, String analyst, String analyzeDate, Double codResult, Double ammoniaResult, Double tnResult, Double tpResult, Double phResult, Double ssResult, Double cr6Result, Double totalCrResult, Double cyanideResult, Double phenolResult, Double nickelResult, Double copperResult, Double zincResult) { } /** * 录入检测结果并自动判定达标情况: * 按 GB8978(或任务中指定标准)逐指标比对 → 超标项列表 → 判定结论 * (达标 / 超标)→ 超标时状态→「超标整改中」并生成整改参考号。 */ @PostMapping("/{id}/analyze") @Transactional public ApiResp analyze(@PathVariable Long id, @RequestBody AnalyzeRequest req) { WwSamplingTask t = taskRepo.findById(id) .orElseThrow(() -> new NotFoundException("采样任务不存在: " + id)); if ("已关闭".equals(t.getStatus())) { throw new ApiException(409, "已关闭的采样任务不可录入结果"); } if (req.actualTime() != null) t.setActualTime(req.actualTime()); if (req.analyst() != null) t.setAnalyst(req.analyst()); if (req.analyzeDate() != null) t.setAnalyzeDate(req.analyzeDate()); else t.setAnalyzeDate(LocalDate.now().toString()); // 录入各指标 if (req.codResult() != null) t.setCodResult(req.codResult()); if (req.ammoniaResult() != null) t.setAmmoniaResult(req.ammoniaResult()); if (req.tnResult() != null) t.setTnResult(req.tnResult()); if (req.tpResult() != null) t.setTpResult(req.tpResult()); if (req.phResult() != null) t.setPhResult(req.phResult()); if (req.ssResult() != null) t.setSsResult(req.ssResult()); if (req.cr6Result() != null) t.setCr6Result(req.cr6Result()); if (req.totalCrResult() != null) t.setTotalCrResult(req.totalCrResult()); if (req.cyanideResult() != null) t.setCyanideResult(req.cyanideResult()); if (req.phenolResult() != null) t.setPhenolResult(req.phenolResult()); if (req.nickelResult() != null) t.setNickelResult(req.nickelResult()); if (req.copperResult() != null) t.setCopperResult(req.copperResult()); if (req.zincResult() != null) t.setZincResult(req.zincResult()); // 自动达标判定 List exceeded = new ArrayList<>(); checkLimit(exceeded, "COD", t.getCodResult(), LIMIT_COD_GB8978); checkLimit(exceeded, "氨氮", t.getAmmoniaResult(), LIMIT_AMMONIA_GB8978); checkLimit(exceeded, "总氮", t.getTnResult(), LIMIT_TN_GB8978); checkLimit(exceeded, "总磷", t.getTpResult(), LIMIT_TP_GB8978); checkLimit(exceeded, "SS", t.getSsResult(), LIMIT_SS_GB8978); checkLimit(exceeded, "六价铬", t.getCr6Result(), LIMIT_CR6_GB8978); checkLimit(exceeded, "总铬", t.getTotalCrResult(), LIMIT_TOTAL_CR_GB8978); checkLimit(exceeded, "氰化物", t.getCyanideResult(), LIMIT_CYANIDE_GB8978); checkLimit(exceeded, "挥发酚", t.getPhenolResult(), LIMIT_PHENOL_GB8978); checkLimit(exceeded, "镍", t.getNickelResult(), LIMIT_NICKEL_GB8978); checkLimit(exceeded, "铜", t.getCopperResult(), LIMIT_COPPER_GB8978); checkLimit(exceeded, "锌", t.getZincResult(), LIMIT_ZINC_GB8978); // pH 区间判定 if (t.getPhResult() != null && (t.getPhResult() < LIMIT_PH_MIN || t.getPhResult() > LIMIT_PH_MAX)) { exceeded.add("pH"); } if (exceeded.isEmpty()) { t.setJudgeResult("达标"); t.setExceedItems(null); t.setStatus("已检测"); } else { t.setJudgeResult("超标"); t.setExceedItems(String.join(",", exceeded)); t.setStatus("超标整改中"); // 生成整改参考号 if (t.getRectifyRef() == null) { t.setRectifyRef("RECT-" + t.getSampleCode()); } } t.setUpdatedAt(Instant.now()); return ApiResp.ok(taskRepo.save(t)); } // =============================================================== // 质控数据录入 // =============================================================== public record QcRequest(Double spikeRecovery, Double rpd, String blankResult) { } @PostMapping("/{id}/qc") @Transactional public ApiResp recordQc(@PathVariable Long id, @RequestBody QcRequest req) { WwSamplingTask t = taskRepo.findById(id) .orElseThrow(() -> new NotFoundException("采样任务不存在: " + id)); // 质控校验:加标回收率应在 70%~130% 内 if (req.spikeRecovery() != null) { if (req.spikeRecovery() < 70.0 || req.spikeRecovery() > 130.0) { t.setRemark((t.getRemark() == null ? "" : t.getRemark() + ";") + "质控预警: 加标回收率 " + req.spikeRecovery() + "% 超出 70-130% 范围"); } t.setSpikeRecovery(req.spikeRecovery()); } // RPD 应 < 20% if (req.rpd() != null) { if (req.rpd() > 20.0) { t.setRemark((t.getRemark() == null ? "" : t.getRemark() + ";") + "质控预警: 平行样 RPD " + req.rpd() + "% > 20%"); } t.setRpd(req.rpd()); } if (req.blankResult() != null) t.setBlankResult(req.blankResult()); t.setUpdatedAt(Instant.now()); return ApiResp.ok(taskRepo.save(t)); } // =============================================================== // 超标统计汇总 // =============================================================== public record ExceedSummary(long totalTasks, long exceedTasks, double exceedRate, List topExceedItems) { } @GetMapping("/exceed-summary") public ApiResp exceedSummary() { List all = taskRepo.findAll(); long total = all.stream().filter(t -> t.getJudgeResult() != null).count(); long exceeded = all.stream().filter(t -> "超标".equals(t.getJudgeResult())).count(); // 统计超标项频次 java.util.Map itemCount = new java.util.HashMap<>(); all.stream() .filter(t -> t.getExceedItems() != null && !t.getExceedItems().isBlank()) .forEach(t -> { for (String item : t.getExceedItems().split(",")) { itemCount.merge(item.trim(), 1L, Long::sum); } }); List top = itemCount.entrySet().stream() .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) .limit(5) .map(e -> e.getKey() + "(" + e.getValue() + "次)") .toList(); double rate = total == 0 ? 0 : Math.round(exceeded * 1000.0 / total) / 10.0; return ApiResp.ok(new ExceedSummary(total, exceeded, rate, top)); } // ---- helpers ---- private void checkLimit(List exceeded, String item, Double value, double limit) { if (value != null && value > limit) { exceeded.add(item); } } private void applyBase(WwSamplingTask t, TaskRequest req) { if (req.samplePoint() != null) t.setSamplePoint(req.samplePoint()); if (req.sampleType() != null) t.setSampleType(req.sampleType()); if (req.clientId() != null) { t.setClientId(req.clientId()); if (req.clientName() != null) { t.setClientName(req.clientName()); } else { clientRepo.findById(req.clientId()).ifPresent(c -> t.setClientName(c.getName())); } } if (req.unitId() != null) t.setUnitId(req.unitId()); if (req.planDate() != null) t.setPlanDate(req.planDate()); if (req.sampler() != null) t.setSampler(req.sampler()); if (req.standard() != null) t.setStandard(req.standard()); if (req.remark() != null) t.setRemark(req.remark()); } }