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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,327 @@
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<String> 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<EnvDischargePermit>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String envType) {
List<EnvDischargePermit> 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<EnvDischargePermit> 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<EnvDischargePermit> 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<EnvDischargePermit> 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<Void> 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<List<EnvMonitorRecord>> 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<EnvMonitorRecord> 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<Void> 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<List<ExceedAlert>> exceedAlerts() {
List<EnvMonitorRecord> exceeds = monitorRepo.findByStatus("超标");
Map<Long, EnvMonitorRecord> 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<ExceedAlert> result = new ArrayList<>();
for (Map.Entry<Long, EnvMonitorRecord> 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<List<ExpiryRow>> scanExpiry() {
LocalDate today = LocalDate.now();
List<ExpiryRow> 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) {
}
}
}