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>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user