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,197 @@
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.FertEnergyLog;
import com.kaidi.oa.repository.FertEnergyLogRepository;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 生物质肥料制造中心·能源消耗逐班记录 + 分摊批次 + 单位能耗对标(需求 §7,补深审计 PARTIAL[med])。
*
* <ul>
* <li>逐班录入电/燃料/水耗,系统自动算综合能耗(kgce)与单位产品能耗(kgce/吨),并与行业基准对标超标预警;</li>
* <li>GET /by-batch?batchRef= 把同一批次/工单各班能耗合计,得到该批次单位能耗(分摊到生产批次);</li>
* <li>GET /benchmark 按工序汇总单耗与超标条数,干燥工序重点监控。</li>
* </ul>
* 综合能耗折标系数:电 1kWh≈0.1229kgce;燃料按填写的折标系数 fuelCoalFactor × fuelQty × 1000(吨标煤→kgce)。
* 写口受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护,前缀已登记入 FINANCE/SENSITIVE_READ。
*/
@RestController
@RequestMapping("/api/oa/fert-energy-logs")
public class FertEnergyLogController {
/** 电力折标煤系数 kgce/kWh(当量值 0.1229)。 */
private static final BigDecimal ELEC_FACTOR = new BigDecimal("0.1229");
private final FertEnergyLogRepository repo;
public FertEnergyLogController(FertEnergyLogRepository repo) {
this.repo = repo;
}
@GetMapping
public ApiResp<List<FertEnergyLog>> list(@RequestParam(required = false) String process,
@RequestParam(required = false) Boolean overOnly) {
if (Boolean.TRUE.equals(overOnly)) return ApiResp.ok(repo.findByOverBenchmarkTrue());
if (process != null && !process.isBlank()) return ApiResp.ok(repo.findByProcess(process));
return ApiResp.ok(repo.findAll());
}
@GetMapping("/{id}")
public ApiResp<FertEnergyLog> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
public record EnergyRequest(String logDate, String shift, String process, String batchRef,
Double outputQty, Double electricityKwh, String fuelType, Double fuelQty,
Double fuelCoalFactor, Double waterTon, Double benchmarkKgcePerTon,
String recorder, String remark) {
}
@PostMapping
public ApiResp<FertEnergyLog> create(@RequestBody EnergyRequest req) {
FertEnergyLog l = new FertEnergyLog();
l.setCreatedAt(Instant.now());
apply(l, req);
return ApiResp.ok(repo.save(l));
}
@PutMapping("/{id}")
public ApiResp<FertEnergyLog> update(@PathVariable Long id, @RequestBody EnergyRequest req) {
FertEnergyLog l = find(id);
apply(l, req);
return ApiResp.ok(repo.save(l));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
repo.delete(find(id));
return ApiResp.ok();
}
private void apply(FertEnergyLog l, EnergyRequest req) {
l.setLogDate(req.logDate() == null || req.logDate().isBlank() ? LocalDate.now().toString() : req.logDate());
l.setShift(req.shift() == null || req.shift().isBlank() ? "全天" : req.shift());
l.setProcess(req.process());
l.setBatchRef(req.batchRef());
l.setOutputQty(bd(req.outputQty()));
l.setElectricityKwh(bd(req.electricityKwh()));
l.setFuelType(req.fuelType());
l.setFuelQty(bd(req.fuelQty()));
l.setFuelCoalFactor(bd(req.fuelCoalFactor()));
l.setWaterTon(bd(req.waterTon()));
l.setBenchmarkKgcePerTon(bd(req.benchmarkKgcePerTon()));
l.setRecorder(req.recorder());
l.setRemark(req.remark());
recompute(l);
}
/** 自动计算综合能耗、单位能耗、超标判定。 */
private void recompute(FertEnergyLog l) {
// 电力折标:kWh × 0.1229 = kgce。
BigDecimal elecKgce = l.getElectricityKwh().multiply(ELEC_FACTOR);
// 燃料折标:fuelQty(吨等) × fuelCoalFactor(吨标煤/单位) × 1000(吨→千克标煤)。
BigDecimal fuelKgce = l.getFuelQty().multiply(l.getFuelCoalFactor()).multiply(new BigDecimal("1000"));
BigDecimal total = elecKgce.add(fuelKgce).setScale(2, RoundingMode.HALF_UP);
l.setTotalKgce(total);
if (l.getOutputQty().signum() > 0) {
BigDecimal unit = total.divide(l.getOutputQty(), 2, RoundingMode.HALF_UP);
l.setUnitKgcePerTon(unit);
l.setOverBenchmark(l.getBenchmarkKgcePerTon().signum() > 0
&& unit.compareTo(l.getBenchmarkKgcePerTon()) > 0);
} else {
l.setUnitKgcePerTon(BigDecimal.ZERO);
l.setOverBenchmark(false);
}
}
// ---------- 批次能耗分摊 ----------
public record BatchEnergy(String batchRef, BigDecimal totalKgce, BigDecimal totalOutput,
BigDecimal unitKgcePerTon, BigDecimal electricityKwh, BigDecimal waterTon,
long logCount) {
}
@GetMapping("/by-batch")
public ApiResp<BatchEnergy> byBatch(@RequestParam String batchRef) {
if (batchRef == null || batchRef.isBlank()) {
throw new ApiException(400, "批次/工单号(batchRef) 不能为空");
}
List<FertEnergyLog> logs = repo.findByBatchRef(batchRef.trim());
BigDecimal kgce = BigDecimal.ZERO, out = BigDecimal.ZERO, elec = BigDecimal.ZERO, water = BigDecimal.ZERO;
for (FertEnergyLog l : logs) {
kgce = kgce.add(nz(l.getTotalKgce()));
out = out.add(nz(l.getOutputQty()));
elec = elec.add(nz(l.getElectricityKwh()));
water = water.add(nz(l.getWaterTon()));
}
BigDecimal unit = out.signum() > 0 ? kgce.divide(out, 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
return ApiResp.ok(new BatchEnergy(batchRef.trim(), scale(kgce), scale(out), unit, scale(elec),
scale(water), logs.size()));
}
// ---------- 工序单耗对标看板 ----------
public record ProcessBench(String process, BigDecimal totalKgce, BigDecimal totalOutput,
BigDecimal unitKgcePerTon, long overCount, long logCount) {
}
@GetMapping("/benchmark")
public ApiResp<List<ProcessBench>> benchmark() {
Map<String, BigDecimal[]> acc = new LinkedHashMap<>(); // [kgce, output, over, count]
for (FertEnergyLog l : repo.findAll()) {
String p = l.getProcess() == null || l.getProcess().isBlank() ? "未分类" : l.getProcess();
BigDecimal[] v = acc.computeIfAbsent(p, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
v[0] = v[0].add(nz(l.getTotalKgce()));
v[1] = v[1].add(nz(l.getOutputQty()));
if (Boolean.TRUE.equals(l.getOverBenchmark())) v[2] = v[2].add(BigDecimal.ONE);
v[3] = v[3].add(BigDecimal.ONE);
}
List<ProcessBench> rows = new ArrayList<>();
for (Map.Entry<String, BigDecimal[]> e : acc.entrySet()) {
BigDecimal[] v = e.getValue();
BigDecimal unit = v[1].signum() > 0 ? v[0].divide(v[1], 2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
rows.add(new ProcessBench(e.getKey(), scale(v[0]), scale(v[1]), unit,
v[2].longValue(), v[3].longValue()));
}
rows.sort((a, b) -> b.unitKgcePerTon().compareTo(a.unitKgcePerTon()));
return ApiResp.ok(rows);
}
// ---------- helpers ----------
private FertEnergyLog find(Long id) {
return repo.findById(id).orElseThrow(() -> new NotFoundException("能耗记录不存在:" + id));
}
private static BigDecimal bd(Double v) {
return v == null ? BigDecimal.ZERO : BigDecimal.valueOf(v).setScale(2, RoundingMode.HALF_UP);
}
private static BigDecimal nz(BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private static BigDecimal scale(BigDecimal v) {
return nz(v).setScale(2, RoundingMode.HALF_UP);
}
}