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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user