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,257 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.SewageEnergyLog;
import com.kaidi.oa.repository.SewageEnergyLogRepository;
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.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 城镇污水运营·药剂与能耗管理。药耗/电耗日台账 CRUD,单耗与成本由后端自动计算(非前端传):
* 吨水电耗(kWh/m³)、吨水药耗(g/m³)、当日电费/药剂费(BigDecimal)、吨水成本(元/m³)。
* 另提供按月成本归集汇总(电费/药费/合计、加权吨水成本)与单耗异常预警(与历史均值偏离)。
*
* 金额一律 BigDecimal(电价、药剂单价、各项费用);水量/电耗/药量为数量用 double。
* 写口默认受 default-deny(ADMIN/APPROVER) 保护,已登记 sewage-energy-logs 进财务/敏感读前缀。
*/
@RestController
@RequestMapping("/api/oa/sewage-energy-logs")
public class SewageEnergyLogController {
private final SewageEnergyLogRepository logRepo;
public SewageEnergyLogController(SewageEnergyLogRepository logRepo) {
this.logRepo = logRepo;
}
@GetMapping
public ApiResp<List<SewageEnergyLog>> list(@RequestParam(required = false) String plant) {
if (plant != null && !plant.isBlank()) {
return ApiResp.ok(logRepo.findByPlant(plant));
}
return ApiResp.ok(logRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<SewageEnergyLog> get(@PathVariable Long id) {
return ApiResp.ok(logRepo.findById(id)
.orElseThrow(() -> new NotFoundException("energy log not found: " + id)));
}
public record EnergyRequest(
String code, String logDate, String plant, Double treatedWater, Double powerKwh,
Double powerPrice, String reagentName, Double reagentKg, Double reagentPrice,
String owner) {
}
@PostMapping
public ApiResp<SewageEnergyLog> create(@RequestBody EnergyRequest req) {
if (req.logDate() == null || req.logDate().isBlank()) {
throw new ApiException(400, "统计日期(logDate) 不能为空");
}
SewageEnergyLog e = new SewageEnergyLog();
e.setCode(req.code() == null || req.code().isBlank()
? "NHD-" + (logRepo.count() + 1) : req.code());
e.setLogDate(req.logDate());
e.setPlant(req.plant());
e.setReagentName(req.reagentName());
e.setOwner(req.owner());
e.setCreatedAt(Instant.now());
applyMeasures(e, req.treatedWater(), req.powerKwh(), req.powerPrice(),
req.reagentKg(), req.reagentPrice());
return ApiResp.ok(logRepo.save(e));
}
@PatchMapping("/{id}")
public ApiResp<SewageEnergyLog> update(@PathVariable Long id, @RequestBody EnergyRequest req) {
SewageEnergyLog e = logRepo.findById(id)
.orElseThrow(() -> new NotFoundException("energy log not found: " + id));
if (req.logDate() != null && !req.logDate().isBlank()) e.setLogDate(req.logDate());
if (req.plant() != null) e.setPlant(req.plant());
if (req.reagentName() != null) e.setReagentName(req.reagentName());
if (req.owner() != null) e.setOwner(req.owner());
// 任一计量字段变更则按合并后的值重算(null 视为沿用旧值)。
Double water = req.treatedWater() != null ? req.treatedWater() : e.getTreatedWater();
Double power = req.powerKwh() != null ? req.powerKwh() : e.getPowerKwh();
Double pPrice = req.powerPrice() != null ? req.powerPrice() : e.getPowerPrice().doubleValue();
Double rKg = req.reagentKg() != null ? req.reagentKg() : e.getReagentKg();
Double rPrice = req.reagentPrice() != null ? req.reagentPrice() : e.getReagentPrice().doubleValue();
applyMeasures(e, water, power, pPrice, rKg, rPrice);
return ApiResp.ok(logRepo.save(e));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!logRepo.existsById(id)) {
throw new NotFoundException("energy log not found: " + id);
}
logRepo.deleteById(id);
return ApiResp.ok(null);
}
/**
* 计量字段落库并自动派生单耗与成本:
* 吨水电耗 = powerKwh / water;吨水药耗(g/m³) = reagentKg*1000 / water
* 电费 = powerKwh × powerPrice;药剂费 = reagentKg × reagentPriceBigDecimal.multiply);
* 吨水成本 = (电费 + 药剂费) / water。
*/
private void applyMeasures(SewageEnergyLog e, Double water, Double power, Double powerPrice,
Double reagentKg, Double reagentPrice) {
double w = nz(water);
double pw = nz(power);
double rk = nz(reagentKg);
BigDecimal pPrice = Money.of(powerPrice);
BigDecimal rPrice = Money.of(reagentPrice);
e.setTreatedWater(w);
e.setPowerKwh(pw);
e.setPowerPrice(pPrice);
e.setReagentKg(rk);
e.setReagentPrice(rPrice);
e.setUnitPower(w <= 0 ? 0 : round3(pw / w));
e.setUnitReagent(w <= 0 ? 0 : round3(rk * 1000.0 / w));
BigDecimal powerCost = Money.of(BigDecimal.valueOf(pw).multiply(pPrice));
BigDecimal reagentCost = Money.of(BigDecimal.valueOf(rk).multiply(rPrice));
e.setPowerCost(powerCost);
e.setReagentCost(reagentCost);
BigDecimal totalCost = Money.add(powerCost, reagentCost);
BigDecimal unitCost = w <= 0 ? BigDecimal.ZERO
: totalCost.divide(BigDecimal.valueOf(w), Money.SCALE + 2, RoundingMode.HALF_UP);
e.setUnitCost(Money.of(unitCost));
}
// ---------- 成本归集(按月) ----------
public record CostRow(String month, double treatedWater, double powerKwh, double reagentKg,
double powerCost, double reagentCost, double totalCost,
double unitPower, double unitReagent, double unitCost) {
}
public record CostSummary(List<CostRow> rows, double totalWater, double totalCost,
double weightedUnitCost) {
}
/**
* 成本归集:按月(logDate 前 7 位 yyyy-MM)汇总处理水量、电耗、药量、电费、药费、合计,
* 并算月吨水电耗/药耗/成本与全期加权吨水成本。供绩效成本看板与预算对比取数。
*/
@GetMapping("/cost/summary")
public ApiResp<CostSummary> costSummary() {
Map<String, double[]> agg = new LinkedHashMap<>();
// [water, powerKwh, reagentKg, powerCost, reagentCost]
for (SewageEnergyLog e : logRepo.findAll()) {
String month = monthOf(e.getLogDate());
double[] a = agg.computeIfAbsent(month, k -> new double[5]);
a[0] += nz(e.getTreatedWater());
a[1] += nz(e.getPowerKwh());
a[2] += nz(e.getReagentKg());
a[3] += e.getPowerCost() == null ? 0 : e.getPowerCost().doubleValue();
a[4] += e.getReagentCost() == null ? 0 : e.getReagentCost().doubleValue();
}
List<CostRow> rows = new ArrayList<>();
double totalWater = 0;
double totalCost = 0;
for (Map.Entry<String, double[]> en : agg.entrySet()) {
double[] a = en.getValue();
double tc = a[3] + a[4];
rows.add(new CostRow(en.getKey(), round1(a[0]), round1(a[1]), round1(a[2]),
round2(a[3]), round2(a[4]), round2(tc),
a[0] <= 0 ? 0 : round3(a[1] / a[0]),
a[0] <= 0 ? 0 : round3(a[2] * 1000.0 / a[0]),
a[0] <= 0 ? 0 : round3(tc / a[0])));
totalWater += a[0];
totalCost += tc;
}
rows.sort((x, y) -> x.month().compareTo(y.month()));
double weighted = totalWater <= 0 ? 0 : round3(totalCost / totalWater);
return ApiResp.ok(new CostSummary(rows, round1(totalWater), round2(totalCost), weighted));
}
// ---------- 单耗异常预警 ----------
public record AnomalyRow(Long id, String logDate, String plant, double unitPower,
double avgUnitPower, double deviationPct, String level) {
}
/**
* 单耗异常预警:以全期吨水电耗均值为基线,单条偏离 > thresholdPct%(默认 15)记预警,
* > 30% 记异常。与需求"异常时预警/与历史值对比"一致。
*/
@GetMapping("/alerts/energy")
public ApiResp<List<AnomalyRow>> energyAlerts(@RequestParam(required = false, defaultValue = "15") double thresholdPct) {
List<SewageEnergyLog> all = logRepo.findAll();
double sum = 0;
int cnt = 0;
for (SewageEnergyLog e : all) {
if (nz(e.getUnitPower()) > 0) {
sum += e.getUnitPower();
cnt++;
}
}
double avg = cnt == 0 ? 0 : sum / cnt;
List<AnomalyRow> out = new ArrayList<>();
if (avg <= 0) {
return ApiResp.ok(out);
}
for (SewageEnergyLog e : all) {
double up = nz(e.getUnitPower());
if (up <= 0) {
continue;
}
double dev = (up - avg) / avg * 100.0;
if (Math.abs(dev) < thresholdPct) {
continue;
}
String level = Math.abs(dev) >= 30 ? "异常" : "预警";
out.add(new AnomalyRow(e.getId(), e.getLogDate(), e.getPlant(), round3(up),
round3(avg), round1(dev), level));
}
out.sort((a, b) -> Double.compare(Math.abs(b.deviationPct()), Math.abs(a.deviationPct())));
return ApiResp.ok(out);
}
// ---------- helpers ----------
private static double nz(Double v) {
return v == null ? 0.0 : v;
}
private static String monthOf(String date) {
if (date == null || date.length() < 7) {
return "未知";
}
return date.substring(0, 7);
}
private static double round1(double v) {
return BigDecimal.valueOf(v).setScale(1, RoundingMode.HALF_UP).doubleValue();
}
private static double round2(double v) {
return BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
private static double round3(double v) {
return BigDecimal.valueOf(v).setScale(3, RoundingMode.HALF_UP).doubleValue();
}
}