Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FeedstockBatchController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

297 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}