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,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 × powerPriceBigDecimal
* 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; }
}
}