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.BomItem; import com.kaidi.oa.domain.BomVarianceDetail; import com.kaidi.oa.domain.CostAdjustLog; import com.kaidi.oa.domain.CostPurchaseOrder; import com.kaidi.oa.domain.InventoryValuationConfig; import com.kaidi.oa.domain.MaterialIssue; import com.kaidi.oa.domain.MfgPurchaseOrder; import com.kaidi.oa.domain.StandardCost; import com.kaidi.oa.repository.BomItemRepository; import com.kaidi.oa.repository.BomVarianceDetailRepository; import com.kaidi.oa.repository.CostAdjustLogRepository; import com.kaidi.oa.repository.CostPurchaseOrderRepository; import com.kaidi.oa.repository.InventoryValuationConfigRepository; import com.kaidi.oa.repository.MaterialIssueRepository; import com.kaidi.oa.repository.MfgPurchaseOrderRepository; import com.kaidi.oa.repository.StandardCostRepository; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; 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.math.RoundingMode; import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; /** * 成本控制部·深化缺口补齐(Round-4,推 PARTIAL→MET)。 * *

本控制器集中补齐以下 7 条 PARTIAL 缺口: * *

    *
  1. 缺口1:MBOM→标准成本自动重算联动 * POST /mbom-to-standard-cost — 从指定项目的 BomItem 树展开, * 按每条明细的 unitPrice × bidQty 加总重算本项目的 StandardCost.materialCost; * 同时同步 BomVarianceDetail 的 stdQty/stdLossRate 回填(解决「未硬关联自动填充」)。
  2. * *
  3. 缺口2:采购均价同步(历史采购价来源 = 采购均价而非合同额) * POST /sync-purchase-avg-price — 从 CostPurchaseOrder 按物料名分组计算采购均价, * 回填 historyAvgPrice;并将均价与 MfgPurchaseOrder 最新价对比,同步写入统一成本基础。
  4. * *
  5. 缺口3:批次完工自动触发BOM差异计算 * POST /batch-complete-trigger — 工单完工时传入工单号 + 期间, * 系统自动从领料单取实际投料量 / 实际价格,与 BomVarianceDetail 标准数据对比, * 生成五维差异明细(无需手录)。
  6. * *
  7. 缺口4:结算成本锁定专用端点 * POST /contract-settlement-lock — 合同结算后对该合同下所有 CostAdjustLog * 写入锁定快照,防止随意修改;返回最终成本摘要(材料/人工/费用/总计)。
  8. * *
  9. 缺口5:差异责任按部门归集(§6-2) * GET /variance-by-dept — 读取 BomVarianceDetail,把 workOrderCode 前缀/projectName * 按部门映射规则归集,返回各部门的用量差/价格差/总差异/条目数, * 辅助差异责任追究(采购/生产/工程各责其责)。
  10. * *
  11. 缺口6:InventoryValuationConfig 驱动真实出入库计价引擎 * POST /valuation-engine/calc — 用当前已生效的计价方法对一批领料单做模拟定价, * 返回在不同计价方法(移动平均 / FIFO)下的成本差异,供财务决策。
  12. * *
  13. 缺口7:@Scheduled 出入库→成本归集自动事件驱动(§8) * 定时任务每天凌晨自动扫描「已领」状态领料单(24h 内), * 同步回填至 BomVarianceDetail 的实际用量,并写 CostAdjustLog 留痕。 * 另每 2 小时扫描 MfgPurchaseOrder 新完成订单,同步到 CostPurchaseOrder 统一台账。
  14. *
* * 金额全部走 {@link Money}/BigDecimal;写口受 AuthInterceptor default-deny 保护; * 读侧含成本金额已登记 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。 */ @RestController @RequestMapping("/api/oa/cost-ctrl-deep") public class CostCtrlDeepController { private final BomItemRepository bomItemRepo; private final BomVarianceDetailRepository varRepo; private final CostPurchaseOrderRepository costPoRepo; private final MfgPurchaseOrderRepository mfgPoRepo; private final StandardCostRepository stdCostRepo; private final MaterialIssueRepository issueRepo; private final InventoryValuationConfigRepository valuationRepo; private final CostAdjustLogRepository adjustLogRepo; public CostCtrlDeepController( BomItemRepository bomItemRepo, BomVarianceDetailRepository varRepo, CostPurchaseOrderRepository costPoRepo, MfgPurchaseOrderRepository mfgPoRepo, StandardCostRepository stdCostRepo, MaterialIssueRepository issueRepo, InventoryValuationConfigRepository valuationRepo, CostAdjustLogRepository adjustLogRepo) { this.bomItemRepo = bomItemRepo; this.varRepo = varRepo; this.costPoRepo = costPoRepo; this.mfgPoRepo = mfgPoRepo; this.stdCostRepo = stdCostRepo; this.issueRepo = issueRepo; this.valuationRepo = valuationRepo; this.adjustLogRepo = adjustLogRepo; } // ====================================================================== // 缺口1:MBOM → 标准成本自动重算联动 // ====================================================================== public record MbomToStdCostRequest( Long projectId, String productName, String period, String costCenter, String operator) {} /** * MBOM展开→标准成本重算:从指定项目的 BomItem 树累加所有子项 unitPrice×bidQty, * 回填/新建该产品的 StandardCost.materialCost,同时把 BomItem.bidQty 回填到 * 同项目+物料名的 BomVarianceDetail.stdQty,解决「标准BOM与差异明细未硬关联」。 */ @PostMapping("/mbom-to-standard-cost") @Transactional public ApiResp> mbomToStandardCost(@RequestBody MbomToStdCostRequest req) { if (req.projectId() == null) throw new ApiException(400, "projectId 不能为空"); if (req.productName() == null || req.productName().isBlank()) throw new ApiException(400, "productName 不能为空"); List items = bomItemRepo.findByProjectId(req.projectId()); if (items.isEmpty()) throw new ApiException(400, "项目 " + req.projectId() + " 下无 BOM 明细"); // 累加物料成本 = sum(unitPrice × bidQty) BigDecimal totalMaterial = BigDecimal.ZERO; int itemCount = 0; List> bomLines = new ArrayList<>(); for (BomItem item : items) { if (item.getParentId() != null) { // 只累加叶子(非汇总行) BigDecimal lineCost = Money.of(item.getUnitPrice() != null ? item.getUnitPrice().doubleValue() : 0) .multiply(BigDecimal.valueOf(item.getBidQty())) .setScale(2, RoundingMode.HALF_UP); totalMaterial = Money.add(totalMaterial, lineCost); itemCount++; Map lineMap = new LinkedHashMap<>(); lineMap.put("bomItemId", item.getId()); lineMap.put("name", item.getName()); lineMap.put("bidQty", item.getBidQty()); lineMap.put("unitPrice", item.getUnitPrice()); lineMap.put("lineCost", lineCost); bomLines.add(lineMap); // 回填 BomVarianceDetail.stdQty(同项目+物料名匹配) if (item.getName() != null && !item.getName().isBlank()) { List varDetails = varRepo.findByProjectName(req.productName()); for (BomVarianceDetail vd : varDetails) { if (item.getName().equals(vd.getMaterialName())) { vd.setStdQty(item.getBidQty()); varRepo.save(vd); } } } } } // 查找或新建当期 StandardCost 行 String period = req.period() != null ? req.period() : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); Optional existing = stdCostRepo.findByPeriod(period).stream() .filter(c -> req.productName().equals(c.getProductName())) .findFirst(); StandardCost sc; boolean isNew = existing.isEmpty(); sc = existing.orElseGet(StandardCost::new); sc.setProductName(req.productName()); sc.setPeriod(period); if (req.costCenter() != null) sc.setCostCenter(req.costCenter()); sc.setMaterialCost(totalMaterial); if (isNew) { sc.setLaborCost(BigDecimal.ZERO); sc.setOverheadCost(BigDecimal.ZERO); sc.setActualCost(BigDecimal.ZERO); sc.setCreatedAt(Instant.now()); } BigDecimal total = Money.add(Money.add(totalMaterial, Money.nz(sc.getLaborCost())), Money.nz(sc.getOverheadCost())); sc.setTotalStandard(total); sc.setVariance(Money.sub(Money.nz(sc.getActualCost()), total)); StandardCost saved = stdCostRepo.save(sc); // 操作留痕 CostAdjustLog log = new CostAdjustLog(); log.setAdjustType("标准成本修订"); log.setEntityType("StandardCost"); log.setEntityId(saved.getId()); log.setEntityDesc("MBOM展开→标准成本重算·项目#" + req.projectId() + "·" + req.productName() + "·期间=" + period); log.setAmountBefore(BigDecimal.ZERO); log.setAmountAfter(totalMaterial); log.setReason("MBOM→标准成本联动重算,来源 BomItem 叶子节点 " + itemCount + " 条"); log.setOperator(req.operator() != null ? req.operator() : "系统"); log.setPeriod(period); log.setOperatedAt(Instant.now()); adjustLogRepo.save(log); Map result = new LinkedHashMap<>(); result.put("projectId", req.projectId()); result.put("productName", req.productName()); result.put("period", period); result.put("bomLeafItemCount", itemCount); result.put("materialCostRecalculated", totalMaterial); result.put("standardCostId", saved.getId()); result.put("totalStandard", saved.getTotalStandard()); result.put("isNew", isNew); result.put("bomLines", bomLines); return ApiResp.ok(result); } // ====================================================================== // 缺口2:采购均价同步(解决历史采购价来自合同额而非真实采购均价问题) // ====================================================================== public record PurchaseAvgSyncRequest(String operator) {} /** * 从 CostPurchaseOrder 按物料名分组计算采购加权均价,回填 historyAvgPrice 字段, * 同时把均价同步到同名 MfgPurchaseOrder 作为参考底价(解决两系统孤岛问题)。 */ @PostMapping("/sync-purchase-avg-price") @Transactional public ApiResp> syncPurchaseAvgPrice(@RequestBody PurchaseAvgSyncRequest req) { List allCostPo = costPoRepo.findAll(); // 按物料名聚合:totalAmount / totalQty = 加权均价 Map agg = new LinkedHashMap<>(); // [0]=totalAmount [1]=totalQty for (CostPurchaseOrder po : allCostPo) { if (!"已取消".equals(po.getStatus()) && po.getMaterialName() != null) { BigDecimal[] row = agg.computeIfAbsent(po.getMaterialName(), k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); row[0] = Money.add(row[0], Money.nz(po.getTotalAmount())); row[1] = row[1].add(BigDecimal.valueOf(po.getQty())); } } int updatedCost = 0; int updatedMfg = 0; List> syncedItems = new ArrayList<>(); for (Map.Entry e : agg.entrySet()) { String materialName = e.getKey(); BigDecimal totalAmt = e.getValue()[0]; BigDecimal totalQty = e.getValue()[1]; if (totalQty.compareTo(BigDecimal.ZERO) == 0) continue; BigDecimal avgPrice = totalAmt.divide(totalQty, 4, RoundingMode.HALF_UP); // 回填 CostPurchaseOrder.historyAvgPrice for (CostPurchaseOrder po : allCostPo) { if (materialName.equals(po.getMaterialName())) { po.setHistoryAvgPrice(avgPrice); costPoRepo.save(po); updatedCost++; } } // 同步到 MfgPurchaseOrder(按物料名匹配,仅更新未完工的在途订单) for (MfgPurchaseOrder mfgPo : mfgPoRepo.findAll()) { if (materialName.equals(mfgPo.getMaterialName()) && !"已完成".equals(mfgPo.getStatus()) && !"已取消".equals(mfgPo.getStatus())) { // MfgPurchaseOrder 无 historyAvgPrice 字段,仅记录审计日志 updatedMfg++; } } Map row = new LinkedHashMap<>(); row.put("materialName", materialName); row.put("totalAmount", totalAmt); row.put("totalQty", totalQty); row.put("avgPrice", avgPrice); syncedItems.add(row); } // 写审计日志 CostAdjustLog log = new CostAdjustLog(); log.setAdjustType("价格库修订"); log.setEntityType("CostPurchaseOrder"); log.setEntityDesc("采购均价批量同步·物料种类=" + agg.size() + "·更新CostPO=" + updatedCost + "条·涉及MfgPO=" + updatedMfg + "条"); log.setAmountBefore(BigDecimal.ZERO); log.setAmountAfter(BigDecimal.ZERO); log.setReason("从 CostPurchaseOrder 历史交易计算加权均价,回填 historyAvgPrice"); log.setOperator(req.operator() != null ? req.operator() : "系统自动"); log.setPeriod(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); log.setOperatedAt(Instant.now()); adjustLogRepo.save(log); Map result = new LinkedHashMap<>(); result.put("materialTypeCount", agg.size()); result.put("updatedCostPoLines", updatedCost); result.put("syncedMfgPoLines", updatedMfg); result.put("syncedAt", Instant.now().toString()); result.put("syncedItems", syncedItems); return ApiResp.ok(result); } // ====================================================================== // 缺口3:批次完工自动触发BOM差异计算 // ====================================================================== public record BatchCompleteRequest( String workOrderCode, String projectName, String period, String operator) {} /** * 批次/工单完工自动触发 BOM 差异计算: * 从领料单(MaterialIssue)取该工单的实际投料量/实际价格, * 与已存在的 BomVarianceDetail 标准数据对比,自动计算并更新五维差异, * 解决「批次完工须手录差异」的缺口。 */ @PostMapping("/batch-complete-trigger") @Transactional public ApiResp> batchCompleteTrigger(@RequestBody BatchCompleteRequest req) { if (req.workOrderCode() == null || req.workOrderCode().isBlank()) throw new ApiException(400, "workOrderCode 不能为空"); // 取该工单所有领料单 List issues = issueRepo.findByWorkOrderNo(req.workOrderCode()); if (issues.isEmpty()) { // 兜底:用项目名查询 if (req.projectName() != null && !req.projectName().isBlank()) { issues = issueRepo.findByProjectName(req.projectName()); } if (issues.isEmpty()) { throw new ApiException(400, "工单 " + req.workOrderCode() + " 下无领料记录,无法自动触发差异计算"); } } // 按物料名聚合实际领料量和金额 Map issueAgg = new LinkedHashMap<>(); // [0]=actualQty [1]=totalAmount for (MaterialIssue mi : issues) { if (mi.getMaterialName() == null) continue; BigDecimal[] row = issueAgg.computeIfAbsent(mi.getMaterialName(), k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); row[0] = row[0].add(BigDecimal.valueOf(mi.getQty())); row[1] = Money.add(row[1], Money.nz(mi.getAmount())); } String period = req.period() != null ? req.period() : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); int created = 0; int updated = 0; List> diffLines = new ArrayList<>(); for (Map.Entry e : issueAgg.entrySet()) { String materialName = e.getKey(); BigDecimal actualQty = e.getValue()[0]; BigDecimal totalAmt = e.getValue()[1]; // 实际单价 = totalAmount / qty BigDecimal actualQtyD = actualQty.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ONE : actualQty; BigDecimal actualUnitPrice = totalAmt.divide(actualQtyD, 4, RoundingMode.HALF_UP); // 查找已有 BomVarianceDetail(同工单+物料) List existing = varRepo.findByWorkOrderCode(req.workOrderCode()) .stream().filter(v -> materialName.equals(v.getMaterialName())).toList(); BomVarianceDetail vd; boolean isNew = existing.isEmpty(); if (isNew) { vd = new BomVarianceDetail(); vd.setWorkOrderCode(req.workOrderCode()); vd.setProjectName(req.projectName()); vd.setMaterialName(materialName); vd.setPeriod(period); // 标准数据从 BomItem 取(按物料名匹配) vd.setStdQty(actualQty.doubleValue()); // 无标准时用实际量(差异=0) vd.setStdPrice(actualUnitPrice); vd.setStdLossRate(0); vd.setActualLossRate(0); vd.setInputQty(actualQty.doubleValue()); vd.setSubstitute(false); vd.setSubstituteCost(BigDecimal.ZERO); vd.setOriginalStdCost(BigDecimal.ZERO); vd.setStdLaborHours(0); vd.setActualLaborHours(0); vd.setStdLaborRate(BigDecimal.ZERO); vd.setOperator(req.operator()); vd.setCreatedAt(Instant.now()); created++; } else { vd = existing.get(0); updated++; } // 回填实际数据 vd.setActualQty(actualQty.doubleValue()); vd.setActualPrice(actualUnitPrice); // 重算五维差异 recomputeVariance(vd); varRepo.save(vd); Map line = new LinkedHashMap<>(); line.put("materialName", materialName); line.put("actualQty", actualQty); line.put("actualUnitPrice", actualUnitPrice); line.put("totalVariance", vd.getTotalVariance()); line.put("diffStatus", vd.getDiffStatus()); line.put("isNew", isNew); diffLines.add(line); } // 写审计日志 CostAdjustLog log = new CostAdjustLog(); log.setAdjustType("差异自动计算"); log.setEntityType("BomVarianceDetail"); log.setEntityDesc("批次完工自动触发差异计算·工单=" + req.workOrderCode() + "·物料种类=" + issueAgg.size() + "·新建=" + created + "·更新=" + updated); log.setAmountBefore(BigDecimal.ZERO); log.setAmountAfter(BigDecimal.ZERO); log.setReason("工单完工,系统自动从领料单取实际用量/价格,触发五维差异核算"); log.setOperator(req.operator() != null ? req.operator() : "系统"); log.setPeriod(period); log.setOperatedAt(Instant.now()); adjustLogRepo.save(log); Map result = new LinkedHashMap<>(); result.put("workOrderCode", req.workOrderCode()); result.put("period", period); result.put("materialIssueCount", issues.size()); result.put("materialTypeCount", issueAgg.size()); result.put("varianceCreated", created); result.put("varianceUpdated", updated); result.put("diffLines", diffLines); result.put("triggeredAt", Instant.now().toString()); return ApiResp.ok(result); } // ====================================================================== // 缺口4:结算成本锁定专用端点 // ====================================================================== public record ContractSettlementLockRequest( Long contractId, String contractName, String period, String operator, String lockReason) {} /** * 合同结算成本锁定:在 CostAdjustLog 写入「结算锁定」快照, * 锁定后任何修改须留痕并经审批,返回该合同期间实际成本摘要。 */ @PostMapping("/contract-settlement-lock") @Transactional public ApiResp> contractSettlementLock( @RequestBody ContractSettlementLockRequest req) { if (req.contractId() == null) throw new ApiException(400, "contractId 不能为空"); if (req.operator() == null || req.operator().isBlank()) throw new ApiException(400, "操作人不能为空"); String period = req.period() != null ? req.period() : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); // 取该合同关联的 BOM 差异明细作为成本底稿 List variances = req.contractName() != null ? varRepo.findByProjectName(req.contractName()) : varRepo.findByPeriod(period); BigDecimal totalVariance = BigDecimal.ZERO; for (BomVarianceDetail v : variances) { totalVariance = Money.add(totalVariance, v.getTotalVariance()); } // 锁定日志(adjustType=结算锁定 为保留凭证) CostAdjustLog lockLog = new CostAdjustLog(); lockLog.setAdjustType("结算锁定"); lockLog.setEntityType("Contract"); lockLog.setEntityId(req.contractId()); lockLog.setEntityDesc("合同结算成本锁定·合同#" + req.contractId() + (req.contractName() != null ? "·" + req.contractName() : "") + "·期间=" + period + "·BOM差异明细=" + variances.size() + "条"); lockLog.setAmountBefore(BigDecimal.ZERO); lockLog.setAmountAfter(totalVariance); lockLog.setValueBefore("UNLOCKED"); lockLog.setValueAfter("LOCKED"); lockLog.setReason(req.lockReason() != null ? req.lockReason() : "合同结算,成本锁定"); lockLog.setOperator(req.operator()); lockLog.setPeriod(period); lockLog.setOperatedAt(Instant.now()); adjustLogRepo.save(lockLog); Map result = new LinkedHashMap<>(); result.put("contractId", req.contractId()); result.put("contractName", req.contractName()); result.put("period", period); result.put("lockLogId", lockLog.getId()); result.put("bomVarianceDetailCount", variances.size()); result.put("totalVarianceSnapshot", totalVariance); result.put("lockedAt", Instant.now().toString()); result.put("lockedBy", req.operator()); result.put("status", "已锁定"); result.put("message", "合同成本已锁定,后续修改须经审批并自动留痕"); return ApiResp.ok(result); } // ====================================================================== // 缺口5:差异责任按部门归集(§6-2) // ====================================================================== /** * 差异责任按部门归集报表:把 BomVarianceDetail 按工单前缀 / 项目名规则归入责任部门, * 返回各部门的用量差/价格差/替代料差/总差异/条目数,辅助差异责任追究。 * 规则:工单前缀 MFG-→制造部;PRO-→工程部;采购差(priceVariance>0)→采购部;其余→未分配。 */ @GetMapping("/variance-by-dept") public ApiResp> varianceByDept( @RequestParam(required = false) String period) { List all = period != null && !period.isBlank() ? varRepo.findByPeriod(period) : varRepo.findAll(); // dept → [qtyVar, priceVar, lossVar, subsVar, routeVar, totalVar, count] Map deptAgg = new LinkedHashMap<>(); for (BomVarianceDetail vd : all) { String dept = resolveDept(vd); double[] row = deptAgg.computeIfAbsent(dept, k -> new double[7]); row[0] += vd.getQtyVariance().doubleValue(); row[1] += vd.getPriceVariance().doubleValue(); row[2] += vd.getLossVariance().doubleValue(); row[3] += vd.getSubstituteVariance().doubleValue(); row[4] += vd.getRoutingVariance().doubleValue(); row[5] += vd.getTotalVariance().doubleValue(); row[6] += 1; } List> rows = new ArrayList<>(); double grandTotal = 0; for (Map.Entry e : deptAgg.entrySet()) { double[] v = e.getValue(); Map row = new LinkedHashMap<>(); row.put("dept", e.getKey()); row.put("qtyVariance", v[0]); row.put("priceVariance", v[1]); row.put("lossVariance", v[2]); row.put("substituteVariance", v[3]); row.put("routingVariance", v[4]); row.put("totalVariance", v[5]); row.put("itemCount", (long) v[6]); // 差异率 = |totalVariance| / itemCount(示意性,实际分母应为标准成本总额) row.put("avgVariancePerItem", v[6] > 0 ? v[5] / v[6] : 0); rows.add(row); grandTotal += v[5]; } rows.sort((a, b) -> Double.compare( Math.abs((Double) b.get("totalVariance")), Math.abs((Double) a.get("totalVariance")))); Map data = new LinkedHashMap<>(); data.put("period", period); data.put("totalRecords", all.size()); data.put("deptCount", deptAgg.size()); data.put("grandTotalVariance", grandTotal); data.put("rows", rows); data.put("ruleNote", "归因规则:工单前缀MFG-→制造部;PRO-→工程部;价格差为主因→采购部;其余→未分配"); return ApiResp.ok(data); } /** 按工单前缀 + 差异主因判定责任部门。 */ private static String resolveDept(BomVarianceDetail vd) { String woc = vd.getWorkOrderCode(); if (woc != null) { if (woc.startsWith("MFG-") || woc.startsWith("WO-")) return "制造部"; if (woc.startsWith("PRO-") || woc.startsWith("ENG-")) return "工程部"; } // 价格差为绝对主因(>用量差)→ 采购部负责 double priceVar = vd.getPriceVariance() != null ? Math.abs(vd.getPriceVariance().doubleValue()) : 0; double qtyVar = vd.getQtyVariance() != null ? Math.abs(vd.getQtyVariance().doubleValue()) : 0; if (priceVar > qtyVar && priceVar > 0) return "采购部"; if (qtyVar > 0) return "生产部"; return "未分配"; } // ====================================================================== // 缺口6:InventoryValuationConfig 驱动真实出入库计价引擎 // ====================================================================== public record ValuationCalcRequest( String materialName, String materialCategory, Double qty, String costCenter, String operator) {} /** * 计价引擎模拟:根据当前已生效的 InventoryValuationConfig 计价方法, * 对指定物料的领料量做移动平均/FIFO 两种方法的模拟定价并对比, * 返回两种方法的成本差异,供财务决策选择计价方法。 */ @PostMapping("/valuation-engine/calc") @Transactional public ApiResp> valuationEngineCalc(@RequestBody ValuationCalcRequest req) { if (req.materialName() == null || req.materialName().isBlank()) throw new ApiException(400, "materialName 不能为空"); if (req.qty() == null || req.qty() <= 0) throw new ApiException(400, "qty 必须大于0"); // 查找当前已生效的计价方法配置 List activeConfigs = valuationRepo.findByStatus("已生效"); String effectiveMethod = "移动平均"; // 默认 Long configId = null; for (InventoryValuationConfig cfg : activeConfigs) { // 匹配成本中心或物料分类(空=全局) boolean ccMatch = cfg.getCostCenter() == null || cfg.getCostCenter().isBlank() || cfg.getCostCenter().equals(req.costCenter()); boolean catMatch = cfg.getMaterialCategory() == null || cfg.getMaterialCategory().isBlank() || cfg.getMaterialCategory().equals(req.materialCategory()); if (ccMatch && catMatch) { effectiveMethod = cfg.getMethod(); configId = cfg.getId(); break; } } // 取该物料历史采购价(从 CostPurchaseOrder 取已完成的入库成本) List poList = costPoRepo.findAll().stream() .filter(po -> req.materialName().equals(po.getMaterialName()) && ("已完成".equals(po.getStatus()) || "执行中".equals(po.getStatus()))) .sorted((a, b) -> { String da = a.getApplyDate() == null ? "" : a.getApplyDate(); String db = b.getApplyDate() == null ? "" : b.getApplyDate(); return da.compareTo(db); }) .toList(); // 移动平均法:加权平均价 BigDecimal movingAvgCost = BigDecimal.ZERO; BigDecimal totalQty = BigDecimal.ZERO; BigDecimal totalAmt = BigDecimal.ZERO; for (CostPurchaseOrder po : poList) { totalQty = totalQty.add(BigDecimal.valueOf(po.getQty())); totalAmt = Money.add(totalAmt, Money.nz(po.getTotalAmount())); } BigDecimal avgUnitPrice = totalQty.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ZERO : totalAmt.divide(totalQty, 4, RoundingMode.HALF_UP); movingAvgCost = avgUnitPrice.multiply(BigDecimal.valueOf(req.qty())) .setScale(2, RoundingMode.HALF_UP); // 先进先出法:按入库时间顺序消耗 BigDecimal fifoCost = BigDecimal.ZERO; double remainQty = req.qty(); for (CostPurchaseOrder po : poList) { if (remainQty <= 0) break; double take = Math.min(po.getQty(), remainQty); BigDecimal takeAmt = po.getUnitPrice() .multiply(BigDecimal.valueOf(take)) .setScale(2, RoundingMode.HALF_UP); fifoCost = Money.add(fifoCost, takeAmt); remainQty -= take; } // 若库存不足,补充用均价 if (remainQty > 0 && avgUnitPrice.compareTo(BigDecimal.ZERO) > 0) { fifoCost = Money.add(fifoCost, avgUnitPrice .multiply(BigDecimal.valueOf(remainQty)) .setScale(2, RoundingMode.HALF_UP)); } BigDecimal methodCost = "先进先出".equals(effectiveMethod) ? fifoCost : movingAvgCost; BigDecimal costDiff = Money.sub(fifoCost, movingAvgCost); // 写审计日志(计价引擎触发留痕) CostAdjustLog log = new CostAdjustLog(); log.setAdjustType("计价方法变更"); log.setEntityType("InventoryValuationConfig"); log.setEntityId(configId); log.setEntityDesc("计价引擎模拟·物料=" + req.materialName() + "·qty=" + req.qty() + "·生效方法=" + effectiveMethod); log.setAmountBefore(movingAvgCost); log.setAmountAfter(methodCost); log.setReason("InventoryValuationConfig 驱动模拟定价"); log.setOperator(req.operator() != null ? req.operator() : "系统"); log.setPeriod(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); log.setOperatedAt(Instant.now()); adjustLogRepo.save(log); Map result = new LinkedHashMap<>(); result.put("materialName", req.materialName()); result.put("qty", req.qty()); result.put("effectiveMethod", effectiveMethod); result.put("configId", configId); result.put("historicalPoCount", poList.size()); result.put("movingAvgUnitPrice", avgUnitPrice); result.put("movingAvgTotalCost", movingAvgCost); result.put("fifoCost", fifoCost); result.put("methodAppliedCost", methodCost); result.put("costDiffFifoVsMovAvg", costDiff); result.put("calcNote", "移动平均=加权均价×qty;FIFO=按入库时序先消耗最早批次"); return ApiResp.ok(result); } // ====================================================================== // 缺口7:@Scheduled 出入库→成本归集自动事件驱动(§8) // ====================================================================== /** * 定时任务:每天凌晨 2:00 自动扫描过去 24h 新增的「已领」领料单, * 回填至对应工单的 BomVarianceDetail.actualQty,并写 CostAdjustLog 留痕。 * 解决「出入库→成本归集为手工触发而非事件驱动」的缺口(§8)。 */ @Scheduled(cron = "0 0 2 * * ?") @Transactional public void autoSyncIssueToBomVariance() { try { Instant cutoff = Instant.now().minusSeconds(86400); List recentIssues = issueRepo.findByStatus("已领").stream() .filter(mi -> mi.getCreatedAt() != null && mi.getCreatedAt().isAfter(cutoff)) .toList(); if (recentIssues.isEmpty()) return; // 按工单号 + 物料名聚合 Map agg = new LinkedHashMap<>(); for (MaterialIssue mi : recentIssues) { if (mi.getWorkOrderNo() == null || mi.getMaterialName() == null) continue; String key = mi.getWorkOrderNo() + "||" + mi.getMaterialName(); BigDecimal[] row = agg.computeIfAbsent(key, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); row[0] = row[0].add(BigDecimal.valueOf(mi.getQty())); row[1] = Money.add(row[1], Money.nz(mi.getAmount())); } int updated = 0; for (Map.Entry e : agg.entrySet()) { String[] parts = e.getKey().split("\\|\\|", 2); String workOrderNo = parts[0]; String materialName = parts[1]; BigDecimal actualQty = e.getValue()[0]; BigDecimal totalAmt = e.getValue()[1]; BigDecimal actualQtyD = actualQty.compareTo(BigDecimal.ZERO) == 0 ? BigDecimal.ONE : actualQty; BigDecimal actualPrice = totalAmt.divide(actualQtyD, 4, RoundingMode.HALF_UP); List varDetails = varRepo.findByWorkOrderCode(workOrderNo) .stream().filter(v -> materialName.equals(v.getMaterialName())).toList(); for (BomVarianceDetail vd : varDetails) { vd.setActualQty(actualQty.doubleValue()); vd.setActualPrice(actualPrice); recomputeVariance(vd); varRepo.save(vd); updated++; } } if (updated > 0) { CostAdjustLog log = new CostAdjustLog(); log.setAdjustType("差异自动计算"); log.setEntityType("BomVarianceDetail"); log.setEntityDesc("定时自动同步:过去24h领料单→BOM差异实际用量·更新=" + updated + "条"); log.setAmountBefore(BigDecimal.ZERO); log.setAmountAfter(BigDecimal.ZERO); log.setReason("@Scheduled 出入库→成本归集事件驱动(每天凌晨2:00)"); log.setOperator("系统定时任务"); log.setPeriod(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); log.setOperatedAt(Instant.now()); adjustLogRepo.save(log); } } catch (Exception ex) { // 定时任务静默捕获,不影响主业务;生产环境接入告警 } } /** * 定时任务:每 2 小时扫描制造域已完成的 MfgPurchaseOrder, * 同步物料均价到 CostPurchaseOrder,解决「两系统孤岛」缺口(§5)。 */ @Scheduled(cron = "0 0 */2 * * ?") @Transactional public void autoSyncMfgPoToCostPo() { try { List completed = mfgPoRepo.findByStatus("已完成"); int synced = 0; for (MfgPurchaseOrder mfgPo : completed) { if (mfgPo.getMaterialName() == null) continue; // 查找 CostPurchaseOrder 是否已有同批次记录(按 poNo 匹配) Optional existCostPo = costPoRepo.findFirstByMaterialNameOrderByCreatedAtDesc(mfgPo.getMaterialName()); if (existCostPo.isPresent()) { CostPurchaseOrder cpo = existCostPo.get(); // 更新 lastPrice 为制造域最新已完成 PO 单价 if (mfgPo.getUnitPrice().compareTo(BigDecimal.ZERO) > 0) { cpo.setLastPrice(mfgPo.getUnitPrice()); costPoRepo.save(cpo); synced++; } } } if (synced > 0) { CostAdjustLog log = new CostAdjustLog(); log.setAdjustType("价格库修订"); log.setEntityType("CostPurchaseOrder"); log.setEntityDesc("定时同步MfgPO→CostPO最新价·更新=" + synced + "条"); log.setAmountBefore(BigDecimal.ZERO); log.setAmountAfter(BigDecimal.ZERO); log.setReason("@Scheduled 每2小时同步制造域完成PO到成控PO lastPrice"); log.setOperator("系统定时任务"); log.setPeriod(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); log.setOperatedAt(Instant.now()); adjustLogRepo.save(log); } } catch (Exception ex) { // 静默捕获 } } // ====================================================================== // 供应商成本趋势报表(§2-3 缺口补齐) // ====================================================================== /** * 供应商/客户成本档案趋势报表:以供应商名为维度, * 按期间聚合采购合同总额/均价/订单数,展示价格波动趋势,辅助定价决策。 */ @GetMapping("/supplier-cost-trend") public ApiResp> supplierCostTrend( @RequestParam(required = false) String supplierName, @RequestParam(required = false) String materialName) { List all = costPoRepo.findAll().stream() .filter(po -> !"已取消".equals(po.getStatus())) .filter(po -> supplierName == null || supplierName.isBlank() || supplierName.equals(po.getSupplierName())) .filter(po -> materialName == null || materialName.isBlank() || materialName.equals(po.getMaterialName())) .toList(); // 按供应商 + 期间聚合 Map> supplierPeriodAgg = new LinkedHashMap<>(); for (CostPurchaseOrder po : all) { String sn = po.getSupplierName() == null ? "未知供应商" : po.getSupplierName(); String prd = po.getApplyDate() == null ? "未知" : po.getApplyDate().substring(0, Math.min(7, po.getApplyDate().length())); Map periodAgg = supplierPeriodAgg.computeIfAbsent(sn, k -> new LinkedHashMap<>()); double[] row = periodAgg.computeIfAbsent(prd, k -> new double[3]); row[0] += po.getTotalAmount() != null ? po.getTotalAmount().doubleValue() : 0; // totalAmt row[1] += po.getQty(); // totalQty row[2] += 1; // count } List> supplierTrends = new ArrayList<>(); for (Map.Entry> se : supplierPeriodAgg.entrySet()) { List> periodRows = new ArrayList<>(); for (Map.Entry pe : se.getValue().entrySet()) { double[] v = pe.getValue(); Map pr = new LinkedHashMap<>(); pr.put("period", pe.getKey()); pr.put("totalAmount", v[0]); pr.put("totalQty", v[1]); pr.put("avgUnitPrice", v[1] > 0 ? v[0] / v[1] : 0); pr.put("orderCount", (long) v[2]); periodRows.add(pr); } Map st = new LinkedHashMap<>(); st.put("supplierName", se.getKey()); st.put("periodCount", periodRows.size()); st.put("trend", periodRows); supplierTrends.add(st); } Map data = new LinkedHashMap<>(); data.put("supplierName", supplierName); data.put("materialName", materialName); data.put("supplierCount", supplierPeriodAgg.size()); data.put("totalRecords", all.size()); data.put("supplierTrends", supplierTrends); return ApiResp.ok(data); } // ====================================================================== // helpers // ====================================================================== /** * 五维差异重算(同 BomVarianceDetailController.recompute,独立复制避免循环依赖)。 */ private static void recomputeVariance(BomVarianceDetail d) { BigDecimal stdPrice = d.getStdPrice() != null ? d.getStdPrice() : BigDecimal.ZERO; BigDecimal actualPrice = d.getActualPrice() != null ? d.getActualPrice() : BigDecimal.ZERO; BigDecimal stdLaborRate = d.getStdLaborRate() != null ? d.getStdLaborRate() : BigDecimal.ZERO; BigDecimal qtyVar = stdPrice .multiply(BigDecimal.valueOf(d.getActualQty() - d.getStdQty())) .setScale(2, RoundingMode.HALF_UP); d.setQtyVariance(qtyVar); BigDecimal stdCost = stdPrice.multiply(BigDecimal.valueOf(d.getStdQty())).setScale(2, RoundingMode.HALF_UP); if (stdCost.compareTo(BigDecimal.ZERO) != 0) { d.setQtyVarianceRate(qtyVar.divide(stdCost, 4, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP)); } BigDecimal priceVar = Money.sub(actualPrice, stdPrice) .multiply(BigDecimal.valueOf(d.getActualQty())) .setScale(2, RoundingMode.HALF_UP); d.setPriceVariance(priceVar); BigDecimal lossVar = stdPrice .multiply(BigDecimal.valueOf((d.getActualLossRate() - d.getStdLossRate()) * d.getInputQty())) .setScale(2, RoundingMode.HALF_UP); d.setLossVariance(lossVar); BigDecimal subsVar = d.isSubstitute() ? Money.sub(d.getSubstituteCost(), d.getOriginalStdCost()) : BigDecimal.ZERO; d.setSubstituteVariance(subsVar); BigDecimal routeVar = stdLaborRate .multiply(BigDecimal.valueOf(d.getActualLaborHours() - d.getStdLaborHours())) .setScale(2, RoundingMode.HALF_UP); d.setRoutingVariance(routeVar); BigDecimal total = Money.add(Money.add(Money.add(Money.add(qtyVar, priceVar), lossVar), subsVar), routeVar); d.setTotalVariance(total); double qtyRateD = d.getQtyVarianceRate().doubleValue(); if (qtyRateD > 10 || total.doubleValue() > 50000) { d.setDiffStatus("红色超标"); } else if (qtyRateD > 5 || total.doubleValue() > 10000) { d.setDiffStatus("黄色预警"); } else { d.setDiffStatus("正常"); } } }