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,304 @@
|
||||
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.SewageCostSavingProject;
|
||||
import com.kaidi.oa.repository.SewageCostSavingProjectRepository;
|
||||
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.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
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.List;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 降本增效项目台账(城镇污水运营·绩效与成本分析)。
|
||||
*
|
||||
* 补深审计 low 缺口:降本增效项目需独立台账,记录节能技改项目
|
||||
* 立项+改造前基期+实际节约额计算;当前 KPI 聚合仅有文字建议,无项目级对账。
|
||||
*
|
||||
* 关键计算逻辑(写入时在控制器自动执行,前端无需传计算字段):
|
||||
* savedKwhPerMonth = (baseUnitPower - actualUnitPower) × actualMonthlyWater
|
||||
* savedKwhTotal = savedKwhPerMonth × verifyMonths
|
||||
* savedReagentKgPerMonth = (baseUnitReagent - actualUnitReagent)/1000 × actualMonthlyWater
|
||||
* savedPowerCost = savedKwhTotal × powerPrice(BigDecimal)
|
||||
* savedReagentCost = savedReagentKgPerMonth × verifyMonths × reagentPrice
|
||||
* savedTotalCost = savedPowerCost + savedReagentCost
|
||||
* paybackMonths = investCost / (savedTotalCost / verifyMonths)
|
||||
*
|
||||
* 路径:/api/oa/sewage-cost-saving-projects
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/oa/sewage-cost-saving-projects")
|
||||
public class SewageCostSavingProjectController {
|
||||
|
||||
private final SewageCostSavingProjectRepository repo;
|
||||
private static final AtomicLong seq = new AtomicLong(1);
|
||||
|
||||
public SewageCostSavingProjectController(SewageCostSavingProjectRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
// ---------- 请求体 ----------
|
||||
|
||||
record ProjectReq(
|
||||
String projectName,
|
||||
String projectType,
|
||||
String plant,
|
||||
String kickoffDate,
|
||||
String planFinishDate,
|
||||
String actualFinishDate,
|
||||
String investCost,
|
||||
Integer baseMonths,
|
||||
Double baseUnitPower,
|
||||
Double baseUnitReagent,
|
||||
Double baseMonthlyWater,
|
||||
Integer verifyMonths,
|
||||
Double actualUnitPower,
|
||||
Double actualUnitReagent,
|
||||
Double actualMonthlyWater,
|
||||
String powerPrice,
|
||||
String reagentPrice,
|
||||
String projStatus,
|
||||
String owner,
|
||||
String remark
|
||||
) {}
|
||||
|
||||
record StatusReq(String projStatus) {}
|
||||
|
||||
// ---------- 汇总响应 ----------
|
||||
|
||||
public record ProjectSummary(
|
||||
long total, long active, long completed,
|
||||
BigDecimal totalInvestCost,
|
||||
BigDecimal totalSavedCost,
|
||||
BigDecimal totalSavedPowerCost,
|
||||
BigDecimal totalSavedReagentCost,
|
||||
double totalSavedKwh,
|
||||
String roiNote
|
||||
) {}
|
||||
|
||||
// ---------- CRUD ----------
|
||||
|
||||
@GetMapping
|
||||
public ApiResp<List<SewageCostSavingProject>> list(
|
||||
@RequestParam(required = false) String plant,
|
||||
@RequestParam(required = false) String projStatus,
|
||||
@RequestParam(required = false) String projectType) {
|
||||
List<SewageCostSavingProject> all;
|
||||
if (plant != null && !plant.isBlank()) {
|
||||
all = repo.findByPlant(plant);
|
||||
} else if (projStatus != null && !projStatus.isBlank()) {
|
||||
all = repo.findByProjStatus(projStatus);
|
||||
} else if (projectType != null && !projectType.isBlank()) {
|
||||
all = repo.findByProjectType(projectType);
|
||||
} else {
|
||||
all = repo.findAll();
|
||||
}
|
||||
return ApiResp.ok(all);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResp<SewageCostSavingProject> get(@PathVariable Long id) {
|
||||
return ApiResp.ok(repo.findById(id).orElseThrow(() -> new NotFoundException("项目不存在")));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public ApiResp<SewageCostSavingProject> create(@RequestBody ProjectReq req) {
|
||||
if (req.projectName() == null || req.projectName().isBlank()) {
|
||||
throw new ApiException(400, "项目名称不能为空");
|
||||
}
|
||||
if (req.kickoffDate() == null || req.kickoffDate().isBlank()) {
|
||||
throw new ApiException(400, "立项日期不能为空");
|
||||
}
|
||||
SewageCostSavingProject p = applyReq(new SewageCostSavingProject(), req);
|
||||
long count = repo.count();
|
||||
p.setCode("JNJ-" + String.format("%04d", count + seq.getAndIncrement()));
|
||||
p.setCreatedAt(Instant.now());
|
||||
p.setUpdatedAt(Instant.now());
|
||||
recalc(p);
|
||||
return ApiResp.ok(repo.save(p));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public ApiResp<SewageCostSavingProject> update(@PathVariable Long id, @RequestBody ProjectReq req) {
|
||||
SewageCostSavingProject p = repo.findById(id).orElseThrow(() -> new NotFoundException("项目不存在"));
|
||||
applyReq(p, req);
|
||||
p.setUpdatedAt(Instant.now());
|
||||
recalc(p);
|
||||
return ApiResp.ok(repo.save(p));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
@Transactional
|
||||
public ApiResp<SewageCostSavingProject> updateStatus(@PathVariable Long id, @RequestBody StatusReq req) {
|
||||
SewageCostSavingProject p = repo.findById(id).orElseThrow(() -> new NotFoundException("项目不存在"));
|
||||
List<String> validStatuses = List.of("立项中", "改造中", "效果验证", "已完结", "暂停");
|
||||
if (!validStatuses.contains(req.projStatus())) {
|
||||
throw new ApiException(400, "无效状态,允许值:" + String.join("/", validStatuses));
|
||||
}
|
||||
p.setProjStatus(req.projStatus());
|
||||
p.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(repo.save(p));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ApiResp<String> delete(@PathVariable Long id) {
|
||||
if (!repo.existsById(id)) throw new NotFoundException("项目不存在");
|
||||
repo.deleteById(id);
|
||||
return ApiResp.ok("已删除");
|
||||
}
|
||||
|
||||
// ---------- 汇总统计 ----------
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResp<ProjectSummary> summary(@RequestParam(required = false) String plant) {
|
||||
List<SewageCostSavingProject> all = plant != null && !plant.isBlank()
|
||||
? repo.findByPlant(plant) : repo.findAll();
|
||||
|
||||
long total = all.size();
|
||||
long active = all.stream().filter(p ->
|
||||
List.of("改造中", "效果验证", "立项中").contains(p.getProjStatus())).count();
|
||||
long completed = all.stream().filter(p -> "已完结".equals(p.getProjStatus())).count();
|
||||
|
||||
BigDecimal totalInvest = all.stream()
|
||||
.map(p -> p.getInvestCost() != null ? p.getInvestCost() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal totalSaved = all.stream()
|
||||
.map(p -> p.getSavedTotalCost() != null ? p.getSavedTotalCost() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal savedPower = all.stream()
|
||||
.map(p -> p.getSavedPowerCost() != null ? p.getSavedPowerCost() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal savedReagent = all.stream()
|
||||
.map(p -> p.getSavedReagentCost() != null ? p.getSavedReagentCost() : BigDecimal.ZERO)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
double totalKwh = all.stream()
|
||||
.mapToDouble(p -> p.getSavedKwhTotal() != null ? p.getSavedKwhTotal() : 0.0).sum();
|
||||
|
||||
String roiPct;
|
||||
if (totalInvest.compareTo(BigDecimal.ZERO) > 0) {
|
||||
roiPct = totalSaved.multiply(BigDecimal.valueOf(100))
|
||||
.divide(totalInvest, 1, RoundingMode.HALF_UP) + "%";
|
||||
} else {
|
||||
roiPct = "—";
|
||||
}
|
||||
String roi = totalInvest.compareTo(BigDecimal.ZERO) == 0 ? "暂无投入数据" :
|
||||
"累计投入 " + formatMoney(totalInvest) + " 元,累计节约 " + formatMoney(totalSaved) +
|
||||
" 元,静态回报率 " + roiPct;
|
||||
|
||||
return ApiResp.ok(new ProjectSummary(total, active, completed,
|
||||
totalInvest, totalSaved, savedPower, savedReagent, Math.round(totalKwh * 10.0) / 10.0, roi));
|
||||
}
|
||||
|
||||
// ---------- 节约核算:重算一条 ----------
|
||||
|
||||
@PostMapping("/{id}/recalc")
|
||||
@Transactional
|
||||
public ApiResp<SewageCostSavingProject> triggerRecalc(@PathVariable Long id) {
|
||||
SewageCostSavingProject p = repo.findById(id).orElseThrow(() -> new NotFoundException("项目不存在"));
|
||||
recalc(p);
|
||||
p.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(repo.save(p));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private SewageCostSavingProject applyReq(SewageCostSavingProject p, ProjectReq req) {
|
||||
p.setProjectName(req.projectName());
|
||||
p.setProjectType(req.projectType());
|
||||
p.setPlant(req.plant());
|
||||
p.setKickoffDate(req.kickoffDate());
|
||||
p.setPlanFinishDate(req.planFinishDate());
|
||||
p.setActualFinishDate(req.actualFinishDate());
|
||||
p.setInvestCost(parseBD(req.investCost()));
|
||||
p.setBaseMonths(req.baseMonths() != null ? req.baseMonths() : 3);
|
||||
p.setBaseUnitPower(req.baseUnitPower() != null ? req.baseUnitPower() : 0.0);
|
||||
p.setBaseUnitReagent(req.baseUnitReagent() != null ? req.baseUnitReagent() : 0.0);
|
||||
p.setBaseMonthlyWater(req.baseMonthlyWater() != null ? req.baseMonthlyWater() : 0.0);
|
||||
p.setVerifyMonths(req.verifyMonths() != null ? req.verifyMonths() : 3);
|
||||
p.setActualUnitPower(req.actualUnitPower() != null ? req.actualUnitPower() : 0.0);
|
||||
p.setActualUnitReagent(req.actualUnitReagent() != null ? req.actualUnitReagent() : 0.0);
|
||||
p.setActualMonthlyWater(req.actualMonthlyWater() != null ? req.actualMonthlyWater() : 0.0);
|
||||
p.setPowerPrice(parseBD(req.powerPrice()));
|
||||
p.setReagentPrice(parseBD(req.reagentPrice()));
|
||||
if (req.projStatus() != null) p.setProjStatus(req.projStatus());
|
||||
p.setOwner(req.owner());
|
||||
p.setRemark(req.remark());
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动计算节约量(使用 BigDecimal 乘法,避免浮点误差影响金额)。
|
||||
* savedKwhPerMonth = (baseUnitPower - actualUnitPower) × actualMonthlyWater
|
||||
*/
|
||||
private void recalc(SewageCostSavingProject p) {
|
||||
double basePow = nvlD(p.getBaseUnitPower());
|
||||
double actPow = nvlD(p.getActualUnitPower());
|
||||
double actWater = nvlD(p.getActualMonthlyWater());
|
||||
int verifyM = p.getVerifyMonths() != null ? p.getVerifyMonths() : 3;
|
||||
|
||||
double deltaUnitPower = basePow - actPow;
|
||||
double savedKwhPerM = deltaUnitPower * actWater;
|
||||
if (savedKwhPerM < 0) savedKwhPerM = 0;
|
||||
double savedKwhTotal = savedKwhPerM * verifyM;
|
||||
|
||||
double baseRea = nvlD(p.getBaseUnitReagent());
|
||||
double actRea = nvlD(p.getActualUnitReagent());
|
||||
double deltaUnitReagent = baseRea - actRea;
|
||||
double savedReagentKgPerM = deltaUnitReagent / 1000.0 * actWater;
|
||||
if (savedReagentKgPerM < 0) savedReagentKgPerM = 0;
|
||||
|
||||
BigDecimal pp = nvlBD(p.getPowerPrice());
|
||||
BigDecimal rp = nvlBD(p.getReagentPrice());
|
||||
|
||||
BigDecimal savedPowerCost = BigDecimal.valueOf(savedKwhTotal).multiply(pp)
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal savedReagentCost = BigDecimal.valueOf(savedReagentKgPerM * verifyM).multiply(rp)
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal savedTotal = savedPowerCost.add(savedReagentCost);
|
||||
|
||||
p.setSavedKwhPerMonth(round2(savedKwhPerM));
|
||||
p.setSavedKwhTotal(round2(savedKwhTotal));
|
||||
p.setSavedReagentKgPerMonth(round2(savedReagentKgPerM));
|
||||
p.setSavedPowerCost(savedPowerCost);
|
||||
p.setSavedReagentCost(savedReagentCost);
|
||||
p.setSavedTotalCost(savedTotal);
|
||||
|
||||
BigDecimal invest = nvlBD(p.getInvestCost());
|
||||
if (savedTotal.compareTo(BigDecimal.ZERO) > 0 && verifyM > 0) {
|
||||
BigDecimal monthlyRate = savedTotal.divide(BigDecimal.valueOf(verifyM), 4, RoundingMode.HALF_UP);
|
||||
if (monthlyRate.compareTo(BigDecimal.ZERO) > 0) {
|
||||
double pb = invest.divide(monthlyRate, 2, RoundingMode.HALF_UP).doubleValue();
|
||||
p.setPaybackMonths(pb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static double nvlD(Double v) { return v != null ? v : 0.0; }
|
||||
private static BigDecimal nvlBD(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; }
|
||||
private static double round2(double v) { return Math.round(v * 100.0) / 100.0; }
|
||||
private static String formatMoney(BigDecimal v) {
|
||||
return v != null ? v.setScale(0, RoundingMode.HALF_UP).toPlainString() : "0";
|
||||
}
|
||||
private static BigDecimal parseBD(String s) {
|
||||
if (s == null || s.isBlank()) return BigDecimal.ZERO;
|
||||
try { return new BigDecimal(s.trim()).setScale(2, RoundingMode.HALF_UP); }
|
||||
catch (NumberFormatException e) { return BigDecimal.ZERO; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user