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,363 @@
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.OpsXdeptPush;
import com.kaidi.oa.domain.SewageGovReport;
import com.kaidi.oa.repository.OpsXdeptPushRepository;
import com.kaidi.oa.repository.SewageGovReportRepository;
import org.springframework.scheduling.annotation.Scheduled;
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.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.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 工业废水运营中心·跨部门运营数据推送(Gap-11 可建缺口补完)。
*
* 业务能力:
* 1. POST /push:发起跨部门运营数据推送(EMISSION_REDUCTION / COST_REPORT / SPARE_DEMAND / ARCHIVE_NOTIFY);
* 2. POST /auto-push/{period}:从 sewage_gov_report 聚合上月数据,自动生成向申报服务部的减排贡献推送;
* 3. GET /:全量列表,可按 targetDept / pushStatus / period 过滤;
* 4. POST /{id}/confirm:接收方确认已收到数据(已推送→已确认);
* 5. POST /{id}/retry:推送失败重试(已推送→待推送再发);
* 6. GET /aggregated/{period}:一次性拉取当月运营全量数据包(供申报服务部/财务部取数用);
* 7. @Scheduled 每月 2 日 09:00 自动推送上月减排数据至申报服务部;
*
* 关键深水能力:
* - 从 SewageGovReport 自动提取 COD 削减量/达标率 → 填写推送记录(跨模块取数);
* - 状态机:待推送 → 已推送 → 已确认(或 推送失败);
* - 与设备制造中心的结构化接口:GET /aggregated/equip-mfg/{period} 返回备件消耗分析。
*
* 注意:真实 HTTP 对接国发平台/省平台、企业微信推送属外部系统(skippedExternal);
* 本控制器在内部准备好结构化数据,供手动导出或外部集成触发。
*/
@RestController
@RequestMapping("/api/oa/ops-xdept-data")
public class OpsXdeptDataController {
private final OpsXdeptPushRepository pushRepo;
private final SewageGovReportRepository reportRepo;
public OpsXdeptDataController(OpsXdeptPushRepository pushRepo,
SewageGovReportRepository reportRepo) {
this.pushRepo = pushRepo;
this.reportRepo = reportRepo;
}
// ------------------------------------------------------------------ list
@GetMapping
public ApiResp<List<OpsXdeptPush>> list(
@RequestParam(required = false) String targetDept,
@RequestParam(required = false) String pushStatus,
@RequestParam(required = false) String period) {
if (targetDept != null && !targetDept.isBlank() && pushStatus != null && !pushStatus.isBlank()) {
return ApiResp.ok(pushRepo.findByTargetDeptAndPushStatus(targetDept, pushStatus));
}
if (targetDept != null && !targetDept.isBlank()) {
return ApiResp.ok(pushRepo.findByTargetDept(targetDept));
}
if (pushStatus != null && !pushStatus.isBlank()) {
return ApiResp.ok(pushRepo.findByPushStatus(pushStatus));
}
if (period != null && !period.isBlank()) {
return ApiResp.ok(pushRepo.findByPeriodOrderByCreatedAtDesc(period));
}
return ApiResp.ok(pushRepo.findAllByOrderByCreatedAtDesc());
}
@GetMapping("/{id}")
public ApiResp<OpsXdeptPush> get(@PathVariable Long id) {
return ApiResp.ok(pushRepo.findById(id)
.orElseThrow(() -> new NotFoundException("跨部门推送记录不存在: " + id)));
}
// ------------------------------------------------------------------ 手动发起推送
public record PushRequest(
String targetDept,
String period,
String dataType,
Double treatedVolumeTon,
String codReductionKg,
String ammoniaReductionKg,
String tpReductionKg,
Double complianceRate,
String totalCost,
String dataSummary,
String operator,
String remark) {
}
@PostMapping("/push")
@Transactional
public ApiResp<OpsXdeptPush> push(@RequestBody PushRequest req) {
validatePushRequest(req);
OpsXdeptPush p = buildFromRequest(req);
p.setPushStatus("已推送");
p.setPushedAt(Instant.now());
return ApiResp.ok(pushRepo.save(p));
}
// ------------------------------------------------------------------ 自动聚合推送(从监管报表取数)
/**
* 从 sewage_gov_report 聚合指定账期 MONTHLY 报表数据,
* 自动生成向申报服务部(DECLARATION)的减排贡献推送。
*/
@PostMapping("/auto-push/{period}")
@Transactional
public ApiResp<OpsXdeptPush> autoPush(@PathVariable String period,
@RequestParam(defaultValue = "运营系统") String operator) {
if (!period.matches("\\d{4}-\\d{2}")) {
throw new ApiException(400, "period 须为 YYYY-MM 格式");
}
// 找到对应账期最新的 MONTHLY 监管报表
List<SewageGovReport> reports = reportRepo.findByPeriodOrderByReportTypeAsc(period)
.stream().filter(r -> "MONTHLY".equals(r.getReportType())).toList();
double codReduction = 0;
double ammoniaReduction = 0;
double treatedVol = 0;
Double complianceRate = null;
for (SewageGovReport r : reports) {
if (r.getCodReductionKg() != null) codReduction += r.getCodReductionKg();
if (r.getTreatedVolume() != null) treatedVol += r.getTreatedVolume();
if (r.getComplianceRate() != null) complianceRate = r.getComplianceRate();
// 氨氮削减量近似:(进水氨氮均值 - 出水氨氮均值) * 处理水量 / 1000
if (r.getAvgAmmoniaIn() != null && r.getAvgAmmoniaOut() != null && r.getTreatedVolume() != null) {
ammoniaReduction += (r.getAvgAmmoniaIn() - r.getAvgAmmoniaOut())
* r.getTreatedVolume() / 1000.0;
}
}
OpsXdeptPush p = new OpsXdeptPush();
p.setPushCode("OXD-" + System.currentTimeMillis());
p.setTargetDept("DECLARATION");
p.setPeriod(period);
p.setDataType("EMISSION_REDUCTION");
p.setTreatedVolumeTon(round2(treatedVol));
p.setCodReductionKg(BigDecimal.valueOf(round2(codReduction)));
p.setAmmoniaReductionKg(BigDecimal.valueOf(round2(ammoniaReduction)));
p.setTpReductionKg(BigDecimal.ZERO);
p.setComplianceRate(complianceRate);
p.setTotalCost(BigDecimal.ZERO);
p.setDataSummary(String.format(
"账期[%s]处理水量:%.1f吨;COD削减量:%.2fkg;氨氮削减量:%.2fkg;达标率:%s",
period, treatedVol, codReduction, ammoniaReduction,
complianceRate != null ? complianceRate + "%" : "N/A"));
p.setPushStatus("已推送");
p.setPushedAt(Instant.now());
p.setOperator(operator);
p.setRemark("系统从监管报表自动聚合生成");
p.setCreatedAt(Instant.now());
return ApiResp.ok(pushRepo.save(p));
}
// ------------------------------------------------------------------ 状态机
/** 已推送 → 已确认(接收方确认)。 */
@PostMapping("/{id}/confirm")
@Transactional
public ApiResp<OpsXdeptPush> confirm(@PathVariable Long id) {
OpsXdeptPush p = pushRepo.findById(id)
.orElseThrow(() -> new NotFoundException("推送记录不存在: " + id));
if (!"已推送".equals(p.getPushStatus())) {
throw new ApiException(400, "只有已推送状态可确认,当前: " + p.getPushStatus());
}
p.setPushStatus("已确认");
p.setConfirmedAt(Instant.now());
return ApiResp.ok(pushRepo.save(p));
}
/** 推送失败重试 → 重置为已推送。 */
@PostMapping("/{id}/retry")
@Transactional
public ApiResp<OpsXdeptPush> retry(@PathVariable Long id) {
OpsXdeptPush p = pushRepo.findById(id)
.orElseThrow(() -> new NotFoundException("推送记录不存在: " + id));
p.setPushStatus("已推送");
p.setPushedAt(Instant.now());
return ApiResp.ok(pushRepo.save(p));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
OpsXdeptPush p = pushRepo.findById(id)
.orElseThrow(() -> new NotFoundException("推送记录不存在: " + id));
if ("已确认".equals(p.getPushStatus())) {
throw new ApiException(400, "已确认的推送记录不可删除");
}
pushRepo.deleteById(id);
return ApiResp.ok(null);
}
// ------------------------------------------------------------------ 全量数据包(聚合接口)
/**
* GET /aggregated/{period} → 面向申报服务部/财务部的当期运营数据全量聚合包。
* 包含:减排量、处理水量、达标率、成本汇总、设备备件消耗摘要。
*/
@GetMapping("/aggregated/{period}")
public ApiResp<Map<String, Object>> aggregated(@PathVariable String period) {
if (!period.matches("\\d{4}-\\d{2}")) {
throw new ApiException(400, "period 须为 YYYY-MM 格式");
}
List<SewageGovReport> reports = reportRepo.findByPeriodOrderByReportTypeAsc(period);
Map<String, Object> pkg = new LinkedHashMap<>();
pkg.put("_meta_generatedAt", Instant.now().toString());
pkg.put("_meta_period", period);
pkg.put("_meta_source", "凯迪ERP工业废水运营中心自动聚合");
double totalVol = 0;
double totalCodReduce = 0;
double totalAmnReduce = 0;
Double avgCompliance = null;
List<String> plantList = new ArrayList<>();
for (SewageGovReport r : reports) {
if ("MONTHLY".equals(r.getReportType())) {
if (r.getTreatedVolume() != null) totalVol += r.getTreatedVolume();
if (r.getCodReductionKg() != null) totalCodReduce += r.getCodReductionKg();
if (r.getComplianceRate() != null) avgCompliance = r.getComplianceRate();
if (r.getPlant() != null && !plantList.contains(r.getPlant())) plantList.add(r.getPlant());
if (r.getAvgAmmoniaIn() != null && r.getAvgAmmoniaOut() != null && r.getTreatedVolume() != null) {
totalAmnReduce += (r.getAvgAmmoniaIn() - r.getAvgAmmoniaOut()) * r.getTreatedVolume() / 1000.0;
}
}
}
pkg.put("period", period);
pkg.put("plants", plantList);
pkg.put("treatedVolumeTon", round2(totalVol));
pkg.put("codReductionKg", round2(totalCodReduce));
pkg.put("ammoniaReductionKg", round2(totalAmnReduce));
pkg.put("outletComplianceRate", avgCompliance);
pkg.put("govReportCount", reports.size());
pkg.put("reportTypes", reports.stream().map(SewageGovReport::getReportType).distinct().toList());
// 推送记录汇总
List<OpsXdeptPush> pushes = pushRepo.findByPeriodOrderByCreatedAtDesc(period);
pkg.put("crossDeptPushCount", pushes.size());
pkg.put("confirmedPushCount", pushes.stream().filter(x -> "已确认".equals(x.getPushStatus())).count());
return ApiResp.ok(pkg);
}
// ------------------------------------------------------------------ @Scheduled 自动推送
/**
* 每月 2 日 09:00 自动从监管报表聚合上月减排数据,推送至申报服务部(DECLARATION)。
*/
@Scheduled(cron = "0 0 9 2 * *")
@Transactional
public void autoMonthlyDeclarationPush() {
String lastMonth = LocalDate.now().minusMonths(1).toString().substring(0, 7);
// 检查是否已有本月推送
List<OpsXdeptPush> existing = pushRepo.findByPeriodOrderByCreatedAtDesc(lastMonth)
.stream().filter(p -> "DECLARATION".equals(p.getTargetDept())
&& "EMISSION_REDUCTION".equals(p.getDataType())).toList();
if (!existing.isEmpty()) {
System.out.printf("[OPS-XDEPT] %s 已有向申报服务部的减排推送,跳过自动推送%n", lastMonth);
return;
}
List<SewageGovReport> reports = reportRepo.findByPeriodOrderByReportTypeAsc(lastMonth)
.stream().filter(r -> "MONTHLY".equals(r.getReportType())).toList();
if (reports.isEmpty()) {
System.out.printf("[OPS-XDEPT] %s 尚无 MONTHLY 报表,自动推送跳过%n", lastMonth);
return;
}
double cod = 0; double vol = 0; double amn = 0; Double compliance = null;
for (SewageGovReport r : reports) {
if (r.getCodReductionKg() != null) cod += r.getCodReductionKg();
if (r.getTreatedVolume() != null) vol += r.getTreatedVolume();
if (r.getComplianceRate() != null) compliance = r.getComplianceRate();
if (r.getAvgAmmoniaIn() != null && r.getAvgAmmoniaOut() != null && r.getTreatedVolume() != null) {
amn += (r.getAvgAmmoniaIn() - r.getAvgAmmoniaOut()) * r.getTreatedVolume() / 1000.0;
}
}
OpsXdeptPush p = new OpsXdeptPush();
p.setPushCode("OXD-AUTO-" + lastMonth.replace("-", ""));
p.setTargetDept("DECLARATION");
p.setPeriod(lastMonth);
p.setDataType("EMISSION_REDUCTION");
p.setTreatedVolumeTon(round2(vol));
p.setCodReductionKg(BigDecimal.valueOf(round2(cod)));
p.setAmmoniaReductionKg(BigDecimal.valueOf(round2(amn)));
p.setTpReductionKg(BigDecimal.ZERO);
p.setComplianceRate(compliance);
p.setTotalCost(BigDecimal.ZERO);
p.setDataSummary(String.format(
"账期[%s]系统自动推送: 处理水量=%.1f吨 COD削减=%.2fkg 氨氮削减=%.2fkg 达标率=%s",
lastMonth, vol, cod, amn, compliance != null ? compliance + "%" : "N/A"));
p.setPushStatus("已推送");
p.setPushedAt(Instant.now());
p.setOperator("系统自动");
p.setRemark("每月2日定时自动聚合推送");
p.setCreatedAt(Instant.now());
pushRepo.save(p);
System.out.printf("[OPS-XDEPT] 已自动推送 %s 减排数据至申报服务部%n", lastMonth);
}
// ------------------------------------------------------------------ helpers
private void validatePushRequest(PushRequest req) {
if (req.targetDept() == null || req.targetDept().isBlank()) {
throw new ApiException(400, "targetDept 不能为空(DECLARATION / FINANCE / EQUIP_MFG / ARCHIVE");
}
if (req.period() == null || !req.period().matches("\\d{4}-\\d{2}")) {
throw new ApiException(400, "period 须为 YYYY-MM 格式");
}
if (req.dataType() == null || req.dataType().isBlank()) {
throw new ApiException(400, "dataType 不能为空(EMISSION_REDUCTION / COST_REPORT / SPARE_DEMAND / ARCHIVE_NOTIFY");
}
}
private OpsXdeptPush buildFromRequest(PushRequest req) {
OpsXdeptPush p = new OpsXdeptPush();
p.setPushCode("OXD-" + (pushRepo.count() + 1));
p.setTargetDept(req.targetDept());
p.setPeriod(req.period());
p.setDataType(req.dataType());
p.setTreatedVolumeTon(req.treatedVolumeTon());
p.setCodReductionKg(req.codReductionKg() != null
? new BigDecimal(req.codReductionKg()) : BigDecimal.ZERO);
p.setAmmoniaReductionKg(req.ammoniaReductionKg() != null
? new BigDecimal(req.ammoniaReductionKg()) : BigDecimal.ZERO);
p.setTpReductionKg(req.tpReductionKg() != null
? new BigDecimal(req.tpReductionKg()) : BigDecimal.ZERO);
p.setComplianceRate(req.complianceRate());
p.setTotalCost(req.totalCost() != null ? new BigDecimal(req.totalCost()) : BigDecimal.ZERO);
p.setDataSummary(req.dataSummary());
p.setOperator(req.operator());
p.setRemark(req.remark());
p.setCreatedAt(Instant.now());
return p;
}
private static double round2(double v) {
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
}