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.BomSignoff; import com.kaidi.oa.repository.BomItemRepository; import com.kaidi.oa.repository.BomSignoffRepository; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; 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.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Bill-of-quantities / BOM comparison center (工程量清单/BOM 比对). Each line * compares the tendered quantity (bidQty) against the actual on-site quantity * (actualQty) for a project. category is one of 分部分项 / 措施 / 其他; status is * one of 正常 / 超量 and is set to 超量 automatically when actualQty exceeds * bidQty. Links to a project (projectId) and supports a parentId for multi-level * BOM trees. * * 会签 (sign-off): a BOM line carries signStatus (待会签 / 会签中 / 已通过). Each * role signs via POST /{id}/sign; once every role in the chain has signed 通过 * the line is marked 已通过. * * 需甲方规则: 会签角色链属甲方规则。当前默认链为 研发 → 工艺 → 采购 → 质量 → 成本 * 五签 (SIGN_ROLES)。 */ @RestController @RequestMapping("/api/oa/bom-items") public class BomItemController { /** 需甲方规则: 默认五签角色链。甲方可改为按物料类别/金额分级的会签矩阵。 */ static final List SIGN_ROLES = List.of("研发", "工艺", "采购", "质量", "成本"); private final BomItemRepository bomItemRepo; private final BomSignoffRepository bomSignoffRepo; public BomItemController(BomItemRepository bomItemRepo, BomSignoffRepository bomSignoffRepo) { this.bomItemRepo = bomItemRepo; this.bomSignoffRepo = bomSignoffRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) Long projectId, @RequestParam(required = false) Long parentId, @RequestParam(required = false) String status) { List list; if (projectId != null) { list = bomItemRepo.findByProjectId(projectId); } else if (parentId != null) { list = bomItemRepo.findByParentId(parentId); } else if (status != null && !status.isBlank()) { list = bomItemRepo.findByStatus(status); } else { list = bomItemRepo.findAll(); } return ApiResp.ok(list); } /** * 多级 BOM 树节点。除原 BOM 行的全部字段外,额外带: * - children:直接子节点列表(递归,叶子为空数组); * - level:层级深度(顶层为 0); * - rollupBidQty / rollupActualQty:含本节点 + 整棵子树的投标量 / 实际量上卷合计; * - rollupBidCost / rollupActualCost:含子树的投标金额 / 实际金额上卷合计; * - rollupOverrun:rollupActualCost − rollupBidCost(>0 超支)。 * 叶子节点的 rollup* 即自身量价;父节点为自身 + 子树之和。 */ public record BomTreeNode( Long id, Long projectId, String projectName, Long parentId, String name, String unit, double bidQty, double actualQty, double unitPrice, String category, String status, String signStatus, double unitUsage, int level, double rollupBidQty, double rollupActualQty, double rollupBidCost, double rollupActualCost, double rollupOverrun, List children) { } /** * GET /tree[?projectId=] -> 把扁平 BOM 行按 parentId 装成嵌套树并逐级上卷量价。 * * 上卷规则:每个节点的 rollupBidQty / rollupActualQty / rollupBidCost / * rollupActualCost = 本节点自身量价 + 所有子孙节点之和(参照 * BomAnalysisController.costCompare 的累加范式,自底向上递归汇总)。 * 不传 projectId 时对全部 BOM 行装树(可能跨项目多棵根)。 */ @GetMapping("/tree") public ApiResp> tree(@RequestParam(required = false) Long projectId) { List all = (projectId != null) ? bomItemRepo.findByProjectId(projectId) : bomItemRepo.findAll(); // 按 parentId 归组,保留首见顺序。 Map> byParent = new LinkedHashMap<>(); List roots = new ArrayList<>(); // 已知 id 集合:父不在本结果集(如跨项目被过滤)的行也当根处理,避免漏节点。 java.util.Set known = new java.util.HashSet<>(); for (BomItem b : all) known.add(b.getId()); for (BomItem b : all) { Long pid = b.getParentId(); if (pid == null || !known.contains(pid)) { roots.add(b); } else { byParent.computeIfAbsent(pid, k -> new ArrayList<>()).add(b); } } List tree = new ArrayList<>(); for (BomItem r : roots) { tree.add(buildNode(r, byParent, 0)); } return ApiResp.ok(tree); } /** 递归装一个节点并自底向上累加子树量价上卷。 */ private BomTreeNode buildNode(BomItem b, Map> byParent, int level) { List children = new ArrayList<>(); List kids = byParent.get(b.getId()); if (kids != null) { for (BomItem k : kids) { children.add(buildNode(k, byParent, level + 1)); } } // 自身量价。 double rollupBidQty = b.getBidQty(); double rollupActualQty = b.getActualQty(); double rollupBidCost = b.getBidQty() * b.getUnitPrice().doubleValue(); double rollupActualCost = b.getActualQty() * b.getUnitPrice().doubleValue(); // 累加子树上卷合计。 for (BomTreeNode c : children) { rollupBidQty += c.rollupBidQty(); rollupActualQty += c.rollupActualQty(); rollupBidCost += c.rollupBidCost(); rollupActualCost += c.rollupActualCost(); } return new BomTreeNode( b.getId(), b.getProjectId(), b.getProjectName(), b.getParentId(), b.getName(), b.getUnit(), b.getBidQty(), b.getActualQty(), b.getUnitPrice().doubleValue(), b.getCategory(), b.getStatus(), b.getSignStatus(), b.getUnitUsage(), level, rollupBidQty, rollupActualQty, rollupBidCost, rollupActualCost, rollupActualCost - rollupBidCost, children); } // ---- MRP 多级物料需求展开 ---- /** * MRP 物料需求展开的一行(扁平输出,按 BOM 树深度优先排序)。 * - level:相对根的层级(根为 0); * - unitUsage:本项相对其直接父项的单位用量(根固定记 1); * - requiredQty:本项在给定顶层需求下的累计需求量 * = 顶层需求 qty × 从根到本项一路的 unitUsage 连乘; * - amount:requiredQty × unitPrice(按本项单价计的需求金额)。 */ public record MrpLine( Long itemId, Long parentId, String name, String unit, String category, int level, double unitUsage, double requiredQty, double unitPrice, double amount) { } /** * MRP 展开结果:扁平需求清单 lines + 顶层信息 + 合计金额。 * - rootId / rootName / topDemand:展开的顶层项与输入需求量; * - lines:含顶层在内的逐级物料需求(顶层 requiredQty = topDemand); * - lineCount / totalAmount:行数与需求金额合计(各行 amount 之和)。 */ public record MrpResult( Long rootId, String rootName, double topDemand, List lines, int lineCount, double totalAmount) { } /** * GET /mrp?rootId=&qty= -> 以 rootId 为顶层、qty 为顶层需求量,沿 parentId 树向下 * 逐级展开物料需求:子项需求量 = 父项需求量 × 子项 unitUsage(单位用量连乘下钻), * 递归到叶子。返回扁平的物料需求清单(深度优先、保留树内顺序)。 * * 与 /tree 的量价上卷(自底向上累加合计)方向相反——这里是自顶向下穿透展开。 * 复用 /tree 同款 byParent 归组装树范式取直接子项(findByParentId)。 * * qty 默认 1(不传即按单台顶层需求展开)。 */ @GetMapping("/mrp") public ApiResp mrp(@RequestParam Long rootId, @RequestParam(required = false) Double qty) { BomItem root = bomItemRepo.findById(rootId) .orElseThrow(() -> new NotFoundException("bom item not found: " + rootId)); // 顶层需求量必须是有限数:拒绝 NaN/Infinity,否则下钻乘累加全程为非有限值, // JSON 里序列化成非法的 "NaN"/"Infinity" 字面量、下游按 number 解析会出错。 if (qty != null && !Double.isFinite(qty)) { throw new ApiException(400, "需求量 qty 必须是有限数值"); } double topDemand = (qty == null) ? 1d : qty; // 按 parentId 归组(取整个项目的行作为展开池,复用 findByProjectId; // projectId 为空时退回 findAll,避免漏挂在不同项目下的子树)。 List pool = (root.getProjectId() != null) ? bomItemRepo.findByProjectId(root.getProjectId()) : bomItemRepo.findAll(); Map> byParent = new LinkedHashMap<>(); for (BomItem b : pool) { if (b.getParentId() != null) { byParent.computeIfAbsent(b.getParentId(), k -> new ArrayList<>()).add(b); } } List lines = new ArrayList<>(); // 顶层需求量即 topDemand(根的 unitUsage 在展开里固定记 1,不参与连乘)。 explodeMrp(root, byParent, 0, topDemand, lines, new java.util.HashSet<>()); double totalAmount = 0d; for (MrpLine l : lines) totalAmount += l.amount(); return ApiResp.ok(new MrpResult( root.getId(), root.getName(), topDemand, lines, lines.size(), totalAmount)); } /** * 自顶向下递归展开 MRP。parentRequiredQty 为本节点的累计需求量; * 本节点 amount = parentRequiredQty × unitPrice,子节点需求量 = 本节点需求量 × 子 unitUsage。 * visited 防自/互引用导致的无限递归(BOM 理论上是 DAG,这里按树展开,遇环即止)。 */ private void explodeMrp(BomItem node, Map> byParent, int level, double parentRequiredQty, List out, java.util.Set visited) { if (node.getId() != null && !visited.add(node.getId())) { return; // 已访问,遇环防御。 } // 根(level 0)单位用量固定记 1;非根用其自身 unitUsage(已乘进 parentRequiredQty)。 double usage = (level == 0) ? 1d : node.getUnitUsage(); double amount = parentRequiredQty * node.getUnitPrice().doubleValue(); out.add(new MrpLine( node.getId(), node.getParentId(), node.getName(), node.getUnit(), node.getCategory(), level, usage, parentRequiredQty, node.getUnitPrice().doubleValue(), amount)); List kids = byParent.get(node.getId()); if (kids != null) { for (BomItem k : kids) { double childRequired = parentRequiredQty * k.getUnitUsage(); explodeMrp(k, byParent, level + 1, childRequired, out, visited); } } } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(bomItemRepo.findById(id) .orElseThrow(() -> new NotFoundException("bom item not found: " + id))); } public record CreateBomItemRequest( Long projectId, String projectName, Long parentId, String name, String unit, Double bidQty, Double actualQty, Double unitPrice, String category, Double unitUsage) { } @PostMapping public ApiResp create(@RequestBody CreateBomItemRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "name is required"); } BomItem b = new BomItem(); b.setProjectId(req.projectId()); b.setProjectName(req.projectName()); b.setParentId(req.parentId()); b.setName(req.name()); b.setUnit(req.unit()); double bidQty = req.bidQty() == null ? 0d : req.bidQty(); double actualQty = req.actualQty() == null ? 0d : req.actualQty(); b.setBidQty(bidQty); b.setActualQty(actualQty); b.setUnitPrice(Money.of(req.unitPrice())); b.setCategory(req.category()); // 单位用量:默认 1(顶层或未填即取 1,避免 MRP 展开乘 0)。 b.setUnitUsage(req.unitUsage() == null ? 1d : req.unitUsage()); b.setStatus(actualQty > bidQty ? "超量" : "正常"); b.setSignStatus("待会签"); b.setCreatedAt(Instant.now()); return ApiResp.ok(bomItemRepo.save(b)); } /** PUT /{id} -> 编辑 BOM 行;只覆盖请求体里给出的字段,并按量自动重算 status。 */ @PutMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody CreateBomItemRequest req) { BomItem b = bomItemRepo.findById(id) .orElseThrow(() -> new NotFoundException("bom item not found: " + id)); if (req.projectId() != null) b.setProjectId(req.projectId()); if (req.projectName() != null) b.setProjectName(req.projectName()); if (req.parentId() != null) b.setParentId(req.parentId()); if (req.name() != null) { if (req.name().isBlank()) { throw new ApiException(400, "name is required"); } b.setName(req.name()); } if (req.unit() != null) b.setUnit(req.unit()); if (req.bidQty() != null) b.setBidQty(req.bidQty()); if (req.actualQty() != null) b.setActualQty(req.actualQty()); if (req.unitPrice() != null) b.setUnitPrice(Money.of(req.unitPrice())); if (req.category() != null) b.setCategory(req.category()); if (req.unitUsage() != null) b.setUnitUsage(req.unitUsage()); b.setStatus(b.getActualQty() > b.getBidQty() ? "超量" : "正常"); return ApiResp.ok(bomItemRepo.save(b)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!bomItemRepo.existsById(id)) { throw new NotFoundException("bom item not found: " + id); } bomSignoffRepo.findByBomId(id).forEach(bomSignoffRepo::delete); bomItemRepo.deleteById(id); return ApiResp.ok(null); } // ---- 会签 (sign-off) ---- /** GET /{id}/signoffs -> 该 BOM 行各角色会签记录。 */ @GetMapping("/{id}/signoffs") public ApiResp> signoffs(@PathVariable Long id) { return ApiResp.ok(bomSignoffRepo.findByBomId(id)); } public record SignRequest(String role, String signer, String decision, String opinion) { } /** * POST /{id}/sign -> 记一个角色的会签。同一角色再签会覆盖原记录。集齐 * SIGN_ROLES 全部「通过」则 signStatus=已通过;有「退回」则保持会签中(默认退回 * 不重置已签角色,需甲方规则可改为退回即清空重签)。 */ @PostMapping("/{id}/sign") public ApiResp sign(@PathVariable Long id, @RequestBody SignRequest req) { BomItem b = bomItemRepo.findById(id) .orElseThrow(() -> new NotFoundException("bom item not found: " + id)); if (req.role() == null || !SIGN_ROLES.contains(req.role())) { throw new ApiException(400, "role must be one of " + SIGN_ROLES); } String decision = (req.decision() == null || req.decision().isBlank()) ? "通过" : req.decision(); BomSignoff s = bomSignoffRepo.findByBomIdAndRole(id, req.role()); if (s == null) { s = new BomSignoff(); s.setBomId(id); s.setRole(req.role()); } s.setSigner(req.signer()); s.setDecision(decision); s.setOpinion(req.opinion()); s.setSignedAt(Instant.now()); bomSignoffRepo.save(s); // 重新评估会签状态。 List all = bomSignoffRepo.findByBomId(id); boolean anyReject = all.stream().anyMatch(x -> "退回".equals(x.getDecision())); long passed = SIGN_ROLES.stream() .filter(role -> all.stream().anyMatch(x -> role.equals(x.getRole()) && "通过".equals(x.getDecision()))) .count(); if (!anyReject && passed == SIGN_ROLES.size()) { b.setSignStatus("已通过"); } else { b.setSignStatus("会签中"); } return ApiResp.ok(bomItemRepo.save(b)); } }