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,296 @@
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.FeedstockBatch;
import com.kaidi.oa.domain.FertStockLot;
import com.kaidi.oa.repository.FeedstockBatchRepository;
import com.kaidi.oa.repository.FertStockLotRepository;
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.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.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 生物质肥料制造中心·原料到货批次与到货检验扣重扣价(需求 §1)。
* 覆盖:原料批次登记(生成唯一批次号)、到货检验录入并按检验结果自动计算扣重/扣价/结算金额、
* 合格入库 / 降级 / 拒收状态机、批次溯源台账、按供应商的到货质量评估聚合。
*
* 写口含金额(结算金额)与机密供应商成本,已登记进 FINANCE_PREFIXES + SENSITIVE_READ_PREFIXES
* /api/oa/feedstock-batches),由 AuthInterceptor 收口到 ADMIN/APPROVER。金额一律 BigDecimal/Money。
*/
@RestController
@RequestMapping("/api/oa/feedstock-batches")
public class FeedstockBatchController {
/** 缺省标准水分率(%):超出部分按比例扣重。 */
private static final double DEFAULT_STD_MOISTURE = 35.0;
/** 降级处理时结算价折扣(按 70% 计价)。 */
private static final BigDecimal DOWNGRADE_PRICE_FACTOR = new BigDecimal("0.70");
/** 原料库保质月数缺省(粪污等易腐败,3个月)。 */
private static final int DEFAULT_RAW_SHELF_MONTHS = 3;
private final FeedstockBatchRepository batchRepo;
private final FertStockLotRepository lotRepo;
public FeedstockBatchController(FeedstockBatchRepository batchRepo, FertStockLotRepository lotRepo) {
this.batchRepo = batchRepo;
this.lotRepo = lotRepo;
}
// ---------- 台账 CRUD ----------
@GetMapping
public ApiResp<List<FeedstockBatch>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String category) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(batchRepo.findByStatus(status));
}
if (category != null && !category.isBlank()) {
return ApiResp.ok(batchRepo.findByCategory(category));
}
return ApiResp.ok(batchRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<FeedstockBatch> get(@PathVariable Long id) {
return ApiResp.ok(batchRepo.findById(id)
.orElseThrow(() -> new NotFoundException("feedstock batch not found: " + id)));
}
public record BatchRequest(
String batchNo, String materialName, String category, String supplier, String sourceOrigin,
String arrivalDate, Double grossWeight, Double unitPrice, String inspector, String remark,
String owner) {
}
@PostMapping
public ApiResp<FeedstockBatch> create(@RequestBody BatchRequest req) {
if (req.materialName() == null || req.materialName().isBlank()) {
throw new ApiException(400, "原料名称(materialName) 不能为空");
}
FeedstockBatch b = new FeedstockBatch();
b.setBatchNo(req.batchNo() == null || req.batchNo().isBlank()
? "YL-" + (batchRepo.count() + 1) : req.batchNo());
b.setMaterialName(req.materialName());
b.setCategory(req.category());
b.setSupplier(req.supplier());
b.setSourceOrigin(req.sourceOrigin());
b.setArrivalDate(req.arrivalDate() == null || req.arrivalDate().isBlank()
? LocalDate.now().toString() : req.arrivalDate());
b.setGrossWeight(Money.of(req.grossWeight()));
b.setUnitPrice(Money.of(req.unitPrice()));
b.setInspector(req.inspector());
b.setRemark(req.remark());
b.setOwner(req.owner());
b.setStatus("待检");
b.setCreatedAt(Instant.now());
return ApiResp.ok(batchRepo.save(b));
}
@PatchMapping("/{id}")
public ApiResp<FeedstockBatch> update(@PathVariable Long id, @RequestBody BatchRequest req) {
FeedstockBatch b = batchRepo.findById(id)
.orElseThrow(() -> new NotFoundException("feedstock batch not found: " + id));
if (!"待检".equals(b.getStatus())) {
throw new ApiException(409, "批次已检验,不能再改基础信息(如需调整请走降级/拒收处理)");
}
if (req.materialName() != null && !req.materialName().isBlank()) b.setMaterialName(req.materialName());
if (req.category() != null) b.setCategory(req.category());
if (req.supplier() != null) b.setSupplier(req.supplier());
if (req.sourceOrigin() != null) b.setSourceOrigin(req.sourceOrigin());
if (req.arrivalDate() != null && !req.arrivalDate().isBlank()) b.setArrivalDate(req.arrivalDate());
if (req.grossWeight() != null) b.setGrossWeight(Money.of(req.grossWeight()));
if (req.unitPrice() != null) b.setUnitPrice(Money.of(req.unitPrice()));
if (req.inspector() != null) b.setInspector(req.inspector());
if (req.remark() != null) b.setRemark(req.remark());
if (req.owner() != null) b.setOwner(req.owner());
return ApiResp.ok(batchRepo.save(b));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!batchRepo.existsById(id)) {
throw new NotFoundException("feedstock batch not found: " + id);
}
batchRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 到货检验扣重扣价状态机 ----------
public record InspectRequest(
Double moisturePct, Double stdMoisturePct, Double impurityPct, Double organicPct,
Double cnRatio, Boolean heavyMetalFail, String decision, String inspector, String remark) {
}
/**
* 录入检验数据并自动结算扣重扣价。decision:合格 / 降级 / 拒收(缺省按规则自动判)。
* 规则:重金属超标 → 强制拒收(不计价);水分/杂质超标按超出比例折算扣重,
* 净重 = 毛重 × (1 − 扣重比例);结算金额 = 净重 × 单价(降级再乘 0.7 折价)。
* 合格/降级 → 入库可用量 receivedQty = 净重;拒收 → receivedQty=0。
*/
@PostMapping("/{id}/inspect")
@Transactional
public ApiResp<FeedstockBatch> inspect(@PathVariable Long id, @RequestBody InspectRequest req) {
FeedstockBatch b = batchRepo.findById(id)
.orElseThrow(() -> new NotFoundException("feedstock batch not found: " + id));
if ("合格".equals(b.getStatus()) || "降级".equals(b.getStatus()) || "拒收".equals(b.getStatus())) {
throw new ApiException(409, "该批次已完成检验,不能重复检验");
}
b.setMoisturePct(req.moisturePct());
double stdMoisture = req.stdMoisturePct() != null && req.stdMoisturePct() > 0
? req.stdMoisturePct() : DEFAULT_STD_MOISTURE;
b.setStdMoisturePct(stdMoisture);
b.setImpurityPct(req.impurityPct());
b.setOrganicPct(req.organicPct());
b.setCnRatio(req.cnRatio());
boolean heavyFail = Boolean.TRUE.equals(req.heavyMetalFail());
b.setHeavyMetalFail(heavyFail);
if (req.inspector() != null) b.setInspector(req.inspector());
// 扣重比例 = 水分超标比例 + 杂质比例(均下限 0)。
double moisture = req.moisturePct() == null ? 0 : req.moisturePct();
double impurity = req.impurityPct() == null ? 0 : req.impurityPct();
double moistureExcess = Math.max(0, moisture - stdMoisture) / 100.0;
double impurityRate = Math.max(0, impurity) / 100.0;
double deductRate = Math.min(0.95, moistureExcess + impurityRate); // 封顶 95%,杜绝负净重
BigDecimal gross = Money.nz(b.getGrossWeight());
BigDecimal deductWeight = Money.of(gross.multiply(BigDecimal.valueOf(deductRate)));
BigDecimal settleWeight = Money.sub(gross, deductWeight);
b.setDeductWeight(deductWeight);
b.setSettleWeight(settleWeight);
// 决策:重金属超标强制拒收;否则按入参 decision,缺省「合格」(扣重比例≥30% 自动降级)。
String decision = req.decision();
if (heavyFail) {
decision = "拒收";
} else if (decision == null || decision.isBlank()) {
decision = deductRate >= 0.30 ? "降级" : "合格";
}
if (!"合格".equals(decision) && !"降级".equals(decision) && !"拒收".equals(decision)) {
throw new ApiException(400, "检验结论(decision) 只能是 合格 / 降级 / 拒收");
}
if ("拒收".equals(decision)) {
b.setSettleAmount(BigDecimal.ZERO);
b.setReceivedQty(BigDecimal.ZERO);
} else {
BigDecimal unitPrice = Money.nz(b.getUnitPrice());
BigDecimal amount = Money.of(settleWeight.multiply(unitPrice));
if ("降级".equals(decision)) {
amount = Money.of(amount.multiply(DOWNGRADE_PRICE_FACTOR));
}
b.setSettleAmount(amount);
b.setReceivedQty(settleWeight);
}
b.setStatus(decision);
if (req.remark() != null) b.setRemark(req.remark());
return ApiResp.ok(batchRepo.save(b));
}
// ---------- 合格后自动入库到原料库台账(补Gap1/Gap6:溯源链条打通)----------
/**
* 检验合格/降级后,一键在 FertStockLot 原料库创建对应批次库存条目,打通原料入库台账与 FIFO 扣库存链条。
* 重复调用幂等:若该 batchNo 已在 FertStockLot 存在则 409 提示不重复建账。
* 入库后 FeedstockBatch.receivedQty 作为初始库存量写入 FertStockLot.qty。
*/
@PostMapping("/{id}/inbound-to-stock")
@Transactional
public ApiResp<FertStockLot> inboundToStock(@PathVariable Long id) {
FeedstockBatch b = batchRepo.findById(id)
.orElseThrow(() -> new NotFoundException("feedstock batch not found: " + id));
if (!"合格".equals(b.getStatus()) && !"降级".equals(b.getStatus())) {
throw new ApiException(409, "只有合格/降级批次才能入库,当前状态:" + b.getStatus());
}
// 幂等:batchNo 已在库则 409
if (lotRepo.findFirstByBatchNo(b.getBatchNo()) != null) {
throw new ApiException(409, "该批次号已在库存台账中存在,无需重复入库:" + b.getBatchNo());
}
FertStockLot lot = new FertStockLot();
lot.setBatchNo(b.getBatchNo());
lot.setWarehouse("原料库");
lot.setMaterialName(b.getMaterialName());
lot.setCategory(b.getCategory());
lot.setUnit("");
BigDecimal qty = Money.nz(b.getReceivedQty());
lot.setQty(qty);
lot.setInboundTotal(qty);
lot.setOutboundTotal(BigDecimal.ZERO);
lot.setInboundDate(b.getArrivalDate() != null ? b.getArrivalDate() : LocalDate.now().toString());
lot.setShelfLifeMonths(DEFAULT_RAW_SHELF_MONTHS);
String inDate = lot.getInboundDate();
try {
lot.setExpiryDate(LocalDate.parse(inDate).plusMonths(DEFAULT_RAW_SHELF_MONTHS).toString());
} catch (Exception e) {
lot.setExpiryDate(LocalDate.now().plusMonths(DEFAULT_RAW_SHELF_MONTHS).toString());
}
lot.setSourceRef("来源批次号:" + b.getBatchNo());
lot.setSupplier(b.getSupplier());
lot.setUnitCost(Money.nz(b.getUnitPrice()));
lot.setStatus(qty.signum() > 0 ? "在库" : "已耗尽");
lot.setRemark("由原料到货批次" + b.getBatchNo() + "检验" + b.getStatus() + "自动入库,结算净重" + qty + "");
lot.setCreatedAt(Instant.now());
return ApiResp.ok(lotRepo.save(lot));
}
// ---------- 供应商到货质量评估(跨批次聚合)----------
public record SupplierEval(String supplier, long batches, long rejected, long downgraded,
double rejectRate, double avgMoisture, double avgImpurity,
double totalSettleAmount) {
}
/**
* 按供应商汇总到货批次质量(需求 §1 定期评估供应质量:杂质率、水分波动、有害物质)。
* 给批次数、拒收数、降级数、拒收率、平均水分/杂质、累计结算金额,支撑供应商准入与评估。
*/
@GetMapping("/supplier-eval")
public ApiResp<List<SupplierEval>> supplierEval() {
Map<String, long[]> counts = new LinkedHashMap<>(); // [batches, rejected, downgraded, moistureN, impurityN]
Map<String, double[]> sums = new LinkedHashMap<>(); // [moistureSum, impuritySum]
Map<String, BigDecimal> amountSum = new LinkedHashMap<>();
for (FeedstockBatch b : batchRepo.findAll()) {
String sup = b.getSupplier() == null || b.getSupplier().isBlank() ? "(未填供应商)" : b.getSupplier();
long[] c = counts.computeIfAbsent(sup, k -> new long[5]);
double[] s = sums.computeIfAbsent(sup, k -> new double[2]);
c[0]++;
if ("拒收".equals(b.getStatus())) c[1]++;
if ("降级".equals(b.getStatus())) c[2]++;
if (b.getMoisturePct() != null) { s[0] += b.getMoisturePct(); c[3]++; }
if (b.getImpurityPct() != null) { s[1] += b.getImpurityPct(); c[4]++; }
amountSum.merge(sup, Money.nz(b.getSettleAmount()), Money::add);
}
List<SupplierEval> out = new ArrayList<>();
for (Map.Entry<String, long[]> e : counts.entrySet()) {
long[] c = e.getValue();
double[] s = sums.get(e.getKey());
double rejectRate = c[0] == 0 ? 0 : Math.round((double) c[1] / c[0] * 10000.0) / 100.0;
double avgMoisture = c[3] == 0 ? 0 : Math.round(s[0] / c[3] * 100.0) / 100.0;
double avgImpurity = c[4] == 0 ? 0 : Math.round(s[1] / c[4] * 100.0) / 100.0;
out.add(new SupplierEval(e.getKey(), c[0], c[1], c[2], rejectRate, avgMoisture, avgImpurity,
amountSum.getOrDefault(e.getKey(), BigDecimal.ZERO).doubleValue()));
}
out.sort((a, x) -> Double.compare(x.rejectRate(), a.rejectRate()));
return ApiResp.ok(out);
}
}