Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/CostCtrlDeepController.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

934 lines
45 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.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)。
*
* <p>本控制器集中补齐以下 7 条 PARTIAL 缺口:
*
* <ol>
* <li><b>缺口1MBOM→标准成本自动重算联动</b>
* POST /mbom-to-standard-cost — 从指定项目的 BomItem 树展开,
* 按每条明细的 unitPrice × bidQty 加总重算本项目的 StandardCost.materialCost
* 同时同步 BomVarianceDetail 的 stdQty/stdLossRate 回填(解决「未硬关联自动填充」)。</li>
*
* <li><b>缺口2:采购均价同步(历史采购价来源 = 采购均价而非合同额)</b>
* POST /sync-purchase-avg-price — 从 CostPurchaseOrder 按物料名分组计算采购均价,
* 回填 historyAvgPrice;并将均价与 MfgPurchaseOrder 最新价对比,同步写入统一成本基础。</li>
*
* <li><b>缺口3:批次完工自动触发BOM差异计算</b>
* POST /batch-complete-trigger — 工单完工时传入工单号 + 期间,
* 系统自动从领料单取实际投料量 / 实际价格,与 BomVarianceDetail 标准数据对比,
* 生成五维差异明细(无需手录)。</li>
*
* <li><b>缺口4:结算成本锁定专用端点</b>
* POST /contract-settlement-lock — 合同结算后对该合同下所有 CostAdjustLog
* 写入锁定快照,防止随意修改;返回最终成本摘要(材料/人工/费用/总计)。</li>
*
* <li><b>缺口5:差异责任按部门归集(§6-2</b>
* GET /variance-by-dept — 读取 BomVarianceDetail,把 workOrderCode 前缀/projectName
* 按部门映射规则归集,返回各部门的用量差/价格差/总差异/条目数,
* 辅助差异责任追究(采购/生产/工程各责其责)。</li>
*
* <li><b>缺口6InventoryValuationConfig 驱动真实出入库计价引擎</b>
* POST /valuation-engine/calc — 用当前已生效的计价方法对一批领料单做模拟定价,
* 返回在不同计价方法(移动平均 / FIFO)下的成本差异,供财务决策。</li>
*
* <li><b>缺口7@Scheduled 出入库→成本归集自动事件驱动(§8)</b>
* 定时任务每天凌晨自动扫描「已领」状态领料单(24h 内),
* 同步回填至 BomVarianceDetail 的实际用量,并写 CostAdjustLog 留痕。
* 另每 2 小时扫描 MfgPurchaseOrder 新完成订单,同步到 CostPurchaseOrder 统一台账。</li>
* </ol>
*
* 金额全部走 {@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<Map<String, Object>> 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<BomItem> 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<Map<String, Object>> 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<String, Object> 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<BomVarianceDetail> 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<StandardCost> 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<String, Object> 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<Map<String, Object>> syncPurchaseAvgPrice(@RequestBody PurchaseAvgSyncRequest req) {
List<CostPurchaseOrder> allCostPo = costPoRepo.findAll();
// 按物料名聚合:totalAmount / totalQty = 加权均价
Map<String, BigDecimal[]> 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<Map<String, Object>> syncedItems = new ArrayList<>();
for (Map.Entry<String, BigDecimal[]> 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<String, Object> 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<String, Object> 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<Map<String, Object>> batchCompleteTrigger(@RequestBody BatchCompleteRequest req) {
if (req.workOrderCode() == null || req.workOrderCode().isBlank())
throw new ApiException(400, "workOrderCode 不能为空");
// 取该工单所有领料单
List<MaterialIssue> 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<String, BigDecimal[]> 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<Map<String, Object>> diffLines = new ArrayList<>();
for (Map.Entry<String, BigDecimal[]> 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<BomVarianceDetail> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<BomVarianceDetail> 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<String, Object> 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<Map<String, Object>> varianceByDept(
@RequestParam(required = false) String period) {
List<BomVarianceDetail> all = period != null && !period.isBlank()
? varRepo.findByPeriod(period) : varRepo.findAll();
// dept → [qtyVar, priceVar, lossVar, subsVar, routeVar, totalVar, count]
Map<String, double[]> 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<Map<String, Object>> rows = new ArrayList<>();
double grandTotal = 0;
for (Map.Entry<String, double[]> e : deptAgg.entrySet()) {
double[] v = e.getValue();
Map<String, Object> 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<String, Object> 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 "未分配";
}
// ======================================================================
// 缺口6InventoryValuationConfig 驱动真实出入库计价引擎
// ======================================================================
public record ValuationCalcRequest(
String materialName, String materialCategory,
Double qty, String costCenter, String operator) {}
/**
* 计价引擎模拟:根据当前已生效的 InventoryValuationConfig 计价方法,
* 对指定物料的领料量做移动平均/FIFO 两种方法的模拟定价并对比,
* 返回两种方法的成本差异,供财务决策选择计价方法。
*/
@PostMapping("/valuation-engine/calc")
@Transactional
public ApiResp<Map<String, Object>> 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<InventoryValuationConfig> 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<CostPurchaseOrder> 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<String, Object> 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<MaterialIssue> recentIssues = issueRepo.findByStatus("已领").stream()
.filter(mi -> mi.getCreatedAt() != null && mi.getCreatedAt().isAfter(cutoff))
.toList();
if (recentIssues.isEmpty()) return;
// 按工单号 + 物料名聚合
Map<String, BigDecimal[]> 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<String, BigDecimal[]> 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<BomVarianceDetail> 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<MfgPurchaseOrder> completed = mfgPoRepo.findByStatus("已完成");
int synced = 0;
for (MfgPurchaseOrder mfgPo : completed) {
if (mfgPo.getMaterialName() == null) continue;
// 查找 CostPurchaseOrder 是否已有同批次记录(按 poNo 匹配)
Optional<CostPurchaseOrder> 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<Map<String, Object>> supplierCostTrend(
@RequestParam(required = false) String supplierName,
@RequestParam(required = false) String materialName) {
List<CostPurchaseOrder> 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<String, Map<String, double[]>> 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<String, double[]> 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<Map<String, Object>> supplierTrends = new ArrayList<>();
for (Map.Entry<String, Map<String, double[]>> se : supplierPeriodAgg.entrySet()) {
List<Map<String, Object>> periodRows = new ArrayList<>();
for (Map.Entry<String, double[]> pe : se.getValue().entrySet()) {
double[] v = pe.getValue();
Map<String, Object> 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<String, Object> st = new LinkedHashMap<>();
st.put("supplierName", se.getKey());
st.put("periodCount", periodRows.size());
st.put("trend", periodRows);
supplierTrends.add(st);
}
Map<String, Object> 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("正常");
}
}
}