恢复点(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>
364 lines
17 KiB
Java
364 lines
17 KiB
Java
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();
|
||
}
|
||
}
|