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