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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user