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(@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 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 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 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 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 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 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> supplierEval() { Map counts = new LinkedHashMap<>(); // [batches, rejected, downgraded, moistureN, impurityN] Map sums = new LinkedHashMap<>(); // [moistureSum, impuritySum] Map 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 out = new ArrayList<>(); for (Map.Entry 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); } }