Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/WwSamplingTaskController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

349 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 水质检测与实验室管理——采样任务单。
*
* <p>覆盖:
* <ul>
* <li>采样任务台账 CRUD(含特征污染物 六价铬/总铬/氰化物/挥发酚/镍/铜/锌 结构化字段)</li>
* <li>批量生成采样计划(POST /ww-sampling-tasks/generate):按活跃排污企业自动生成本周
* 来水采样任务</li>
* <li>检测结果录入与达标自动判定(POST /{id}/analyze):按 GB8978 或纳管标准逐指标比对,
* 超标自动置 超标整改中 并生成整改参考号</li>
* <li>质控数据录入(POST /{id}/qc):加标回收率/平行样 RPD/空白样</li>
* <li>超标统计(GET /ww-sampling-tasks/exceed-summary</li>
* </ul>
* </p>
*/
@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<WwSamplingTask>> 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<WwSamplingTask> 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<WwSamplingTask> 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<WwSamplingTask> 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<Void> 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<List<WwSamplingTask>> generate(@RequestBody GenerateRequest req) {
String date = req.planDate() == null ? LocalDate.now().toString() : req.planDate();
String std = req.standard() == null ? "GB8978" : req.standard();
List<WastewaterClient> clients = clientRepo.findByServiceStatus("运营中");
List<WwSamplingTask> created = new ArrayList<>();
long seq = taskRepo.count();
for (WastewaterClient c : clients) {
// 去重:当日同企业来水采样任务已存在则跳过
List<WwSamplingTask> 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<WwSamplingTask> 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<String> 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<WwSamplingTask> 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<String> topExceedItems) {
}
@GetMapping("/exceed-summary")
public ApiResp<ExceedSummary> exceedSummary() {
List<WwSamplingTask> 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<String, Long> 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<String> 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<String> 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());
}
}