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(@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 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 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 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 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 rows, double totalWater, double totalCost, double weightedUnitCost) { } /** * 成本归集:按月(logDate 前 7 位 yyyy-MM)汇总处理水量、电耗、药量、电费、药费、合计, * 并算月吨水电耗/药耗/成本与全期加权吨水成本。供绩效成本看板与预算对比取数。 */ @GetMapping("/cost/summary") public ApiResp costSummary() { Map 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 rows = new ArrayList<>(); double totalWater = 0; double totalCost = 0; for (Map.Entry 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> energyAlerts(@RequestParam(required = false, defaultValue = "15") double thresholdPct) { List 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 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(); } }