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,933 @@
|
||||
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>缺口1:MBOM→标准成本自动重算联动</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>缺口6:InventoryValuationConfig 驱动真实出入库计价引擎</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 "未分配";
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 缺口6:InventoryValuationConfig 驱动真实出入库计价引擎
|
||||
// ======================================================================
|
||||
|
||||
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("正常");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user