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,380 @@
|
||||
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<String> 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<BomItem>> list(@RequestParam(required = false) Long projectId,
|
||||
@RequestParam(required = false) Long parentId,
|
||||
@RequestParam(required = false) String status) {
|
||||
List<BomItem> 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<BomTreeNode> children) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tree[?projectId=] -> 把扁平 BOM 行按 parentId 装成嵌套树并逐级上卷量价。
|
||||
*
|
||||
* 上卷规则:每个节点的 rollupBidQty / rollupActualQty / rollupBidCost /
|
||||
* rollupActualCost = 本节点自身量价 + 所有子孙节点之和(参照
|
||||
* BomAnalysisController.costCompare 的累加范式,自底向上递归汇总)。
|
||||
* 不传 projectId 时对全部 BOM 行装树(可能跨项目多棵根)。
|
||||
*/
|
||||
@GetMapping("/tree")
|
||||
public ApiResp<List<BomTreeNode>> tree(@RequestParam(required = false) Long projectId) {
|
||||
List<BomItem> all = (projectId != null)
|
||||
? bomItemRepo.findByProjectId(projectId)
|
||||
: bomItemRepo.findAll();
|
||||
|
||||
// 按 parentId 归组,保留首见顺序。
|
||||
Map<Long, List<BomItem>> byParent = new LinkedHashMap<>();
|
||||
List<BomItem> roots = new ArrayList<>();
|
||||
// 已知 id 集合:父不在本结果集(如跨项目被过滤)的行也当根处理,避免漏节点。
|
||||
java.util.Set<Long> 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<BomTreeNode> tree = new ArrayList<>();
|
||||
for (BomItem r : roots) {
|
||||
tree.add(buildNode(r, byParent, 0));
|
||||
}
|
||||
return ApiResp.ok(tree);
|
||||
}
|
||||
|
||||
/** 递归装一个节点并自底向上累加子树量价上卷。 */
|
||||
private BomTreeNode buildNode(BomItem b, Map<Long, List<BomItem>> byParent, int level) {
|
||||
List<BomTreeNode> children = new ArrayList<>();
|
||||
List<BomItem> 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<MrpLine> lines, int lineCount, double totalAmount) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /mrp?rootId=&qty= -> 以 rootId 为顶层、qty 为顶层需求量,沿 parentId 树向下
|
||||
* 逐级展开物料需求:子项需求量 = 父项需求量 × 子项 unitUsage(单位用量连乘下钻),
|
||||
* 递归到叶子。返回扁平的物料需求清单(深度优先、保留树内顺序)。
|
||||
*
|
||||
* 与 /tree 的量价上卷(自底向上累加合计)方向相反——这里是自顶向下穿透展开。
|
||||
* 复用 /tree 同款 byParent 归组装树范式取直接子项(findByParentId)。
|
||||
*
|
||||
* qty 默认 1(不传即按单台顶层需求展开)。
|
||||
*/
|
||||
@GetMapping("/mrp")
|
||||
public ApiResp<MrpResult> 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<BomItem> pool = (root.getProjectId() != null)
|
||||
? bomItemRepo.findByProjectId(root.getProjectId())
|
||||
: bomItemRepo.findAll();
|
||||
Map<Long, List<BomItem>> byParent = new LinkedHashMap<>();
|
||||
for (BomItem b : pool) {
|
||||
if (b.getParentId() != null) {
|
||||
byParent.computeIfAbsent(b.getParentId(), k -> new ArrayList<>()).add(b);
|
||||
}
|
||||
}
|
||||
|
||||
List<MrpLine> 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<Long, List<BomItem>> byParent, int level,
|
||||
double parentRequiredQty, List<MrpLine> out, java.util.Set<Long> 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<BomItem> 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<BomItem> 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<BomItem> 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<BomItem> 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<Void> 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<List<BomSignoff>> 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<BomItem> 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<BomSignoff> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user