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

258 lines
11 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.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();
}
}