Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/OpsXdeptDataController.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

364 lines
17 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.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();
}
}