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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -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:含子树的投标金额 / 实际金额上卷合计;
* - rollupOverrunrollupActualCost 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 连乘;
* - amountrequiredQty × 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));
}
}