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,262 @@
package com.kaidi.oa.web;
import com.fasterxml.jackson.databind.ObjectMapper;
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.Contract;
import com.kaidi.oa.domain.FormInstance;
import com.kaidi.oa.domain.LegalAuthLimit;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.FormInstanceRepository;
import com.kaidi.oa.repository.LegalAuthLimitRepository;
import com.kaidi.oa.service.WorkflowService;
import org.springframework.transaction.annotation.Transactional;
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.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.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 合同审批流接入 OA 审批引擎(内控部 / 法务风险部 · 合同全生命周期管理)。
*
* 补齐缺口:合同审批流未接入 OA 审批引擎(ApprovalInstance/WorkflowEngine)
* 差异化审批流程靠 status 字段手动推进,无引擎驱动联动。
*
* 本控制器是 Contract 与 WorkflowServiceFormInstance/FlowTask)之间的桥接层:
* - POST /legal-contract-flow/{contractId}/submit:为合同发起「合同法务审批单」
* (templateId=legal-contract-review) 流程实例,返回 FormInstance id
* - POST /legal-contract-flow/{contractId}/advance:推进流程节点(同意/退回/转交);
* - GET /legal-contract-flow/{contractId}/status:查询合同对应的审批实例状态;
* - GET /legal-contract-flow/pending:查询待当前用户审批的合同流程;
* - GET /legal-contract-flow/stats:合同审批流统计(各状态数量)。
*
* 合同状态机联动:
* - 提交流程时:Contract.status → 「审批中」;
* - 流程全部通过(已办结)时:Contract.status → 「履约中」(由 TriggerRuleEngine 驱动);
* - 任一节点退回时:Contract.status → 「草稿」(可重新修改再提交)。
*
* 读口含合同金额敏感信息,前端通过 http 标准请求头携带 token;写口受 default-deny 保护。
*/
@RestController
@RequestMapping("/api/oa/legal-contract-flow")
public class LegalContractFlowController {
/** OA 审批模板 ID,需在 TemplateSeedData 中已注册(种子数据已添加)。 */
private static final String TEMPLATE_ID = "legal-contract-review";
private final ContractRepository contractRepo;
private final FormInstanceRepository instanceRepo;
private final WorkflowService workflowService;
private final ObjectMapper objectMapper;
private final LegalAuthLimitRepository authLimitRepo;
public LegalContractFlowController(ContractRepository contractRepo,
FormInstanceRepository instanceRepo,
WorkflowService workflowService,
ObjectMapper objectMapper,
LegalAuthLimitRepository authLimitRepo) {
this.contractRepo = contractRepo;
this.instanceRepo = instanceRepo;
this.workflowService = workflowService;
this.objectMapper = objectMapper;
this.authLimitRepo = authLimitRepo;
}
// ==================== 提交审批流 ====================
public record SubmitRequest(
String applicant, String applyDept, String riskLevel,
String riskPoints, String remark) {
}
/**
* 为合同发起 OA 审批引擎流程(合同法务审批单)。
* 调用此接口后,审批任务将在 OA 待办中体现,法务/财务/高管可在流程中心审批。
* 合同 status 自动变「审批中」。
*/
@PostMapping("/{contractId}/submit")
@Transactional
public ApiResp<Map<String, Object>> submitReview(@PathVariable Long contractId,
@RequestBody SubmitRequest req) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
if ("审批中".equals(c.getStatus())) {
throw new ApiException(409, "合同 [" + c.getCode() + "] 已有进行中的审批流,请勿重复提交");
}
String applicant = req.applicant() == null || req.applicant().isBlank() ? "发起人" : req.applicant();
// ---- 授权前置校验(补齐缺口8:业务流程主动调用 authLimit/check 做超授权硬拦截)----
// 查询申请人是否具有有效的「合同签署」授权且金额上限覆盖本合同金额。
// 若授权记录不存在(未录入),仅作 riskLevel 提升,不阻断(允许法务在审批流中再审)。
// 若授权记录存在但金额超限,则硬拒绝(409),要求先申请超限授权。
BigDecimal contractAmount = Money.nz(c.getAmount());
List<LegalAuthLimit> authLimits = authLimitRepo.findByGrantee(applicant).stream()
.filter(a -> "有效".equals(a.getStatus()) && "合同签署".equals(a.getAuthType()))
.toList();
if (!authLimits.isEmpty() && contractAmount.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal maxLimit = authLimits.stream()
.map(LegalAuthLimit::getAmountLimit)
.max(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
// amountLimit=0 表示无金额上限授权
if (maxLimit.compareTo(BigDecimal.ZERO) > 0
&& contractAmount.compareTo(maxLimit) > 0) {
throw new ApiException(409,
"超授权拦截:申请人 [" + applicant + "] 合同签署授权上限为 "
+ maxLimit + " 元,合同金额 " + contractAmount + " 元已超限,"
+ "请先向授权管理台账申请提额后再发起审批。");
}
}
// ---- 授权前置校验结束 ----
// 构建表单 dataJson
Map<String, Object> data = new LinkedHashMap<>();
data.put("applicant", applicant);
data.put("applyDept", req.applyDept() == null ? "" : req.applyDept());
data.put("contractName", c.getName() != null ? c.getName() : "");
data.put("counterparty", c.getPartyB() != null ? c.getPartyB() : "");
data.put("amount", c.getAmount() != null ? c.getAmount().toPlainString() : "0");
data.put("signDate", c.getSignDate() != null ? c.getSignDate() : "");
data.put("contractType", c.getType() != null ? c.getType() : "其他");
data.put("riskLevel", req.riskLevel() == null ? "" : req.riskLevel());
data.put("riskPoints", req.riskPoints() == null ? "" : req.riskPoints());
data.put("remark", req.remark() == null ? "" : req.remark());
String dataJson;
try {
dataJson = objectMapper.writeValueAsString(data);
} catch (Exception e) {
dataJson = "{}";
}
String title = "合同法务审批 - " + (c.getCode() != null ? c.getCode() : "")
+ " - " + (c.getName() != null ? c.getName() : "");
FormInstance inst = workflowService.submit(TEMPLATE_ID, dataJson, title, applicant);
// 更新合同状态为「审批中」
c.setStatus("审批中");
contractRepo.save(c);
// 若申请人无授权记录,在响应中标记 authWarning 提示
boolean noAuthRecord = authLimits.isEmpty();
Map<String, Object> m = new LinkedHashMap<>();
m.put("contractId", contractId);
m.put("contractCode", c.getCode());
m.put("instanceId", inst.getId());
m.put("instanceStatus", inst.getStatus());
m.put("contractStatus", c.getStatus());
m.put("authWarning", noAuthRecord
? "申请人 [" + applicant + "] 无合同签署授权记录,已提交审批,请法务节点重点审核授权合规性"
: null);
return ApiResp.ok(m);
}
// ==================== 推进节点 ====================
public record AdvanceRequest(String action, String opinion, String user,
String targetUser) {
}
/**
* 推进合同审批节点(同意/退回/转交/加签/知会)。
* 办结时合同状态自动变「履约中」;退回时变「草稿」。
*/
@PostMapping("/{contractId}/advance")
@Transactional
public ApiResp<Map<String, Object>> advance(@PathVariable Long contractId,
@RequestBody AdvanceRequest req) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
FormInstance inst = findActiveInstance(contractId);
if (inst == null) {
throw new ApiException(409, "合同 [" + c.getCode() + "] 当前无进行中的审批流");
}
String user = req.user() == null || req.user().isBlank() ? req.opinion() : req.user();
FormInstance updated;
if (req.targetUser() != null && !req.targetUser().isBlank()) {
updated = workflowService.advance(inst.getId(), req.action(),
req.opinion(), user, req.targetUser());
} else {
updated = workflowService.advance(inst.getId(), req.action(), req.opinion(), user);
}
// 联动合同状态
if ("已办结".equals(updated.getStatus())) {
c.setStatus("履约中");
contractRepo.save(c);
} else if ("已退回".equals(updated.getStatus())) {
c.setStatus("草稿");
contractRepo.save(c);
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("contractId", contractId);
m.put("instanceId", updated.getId());
m.put("instanceStatus", updated.getStatus());
m.put("contractStatus", c.getStatus());
return ApiResp.ok(m);
}
// ==================== 查询合同审批状态 ====================
/**
* 查询某合同的当前审批流实例状态。
*/
@GetMapping("/{contractId}/status")
public ApiResp<Map<String, Object>> status(@PathVariable Long contractId) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
FormInstance inst = findActiveInstance(contractId);
Map<String, Object> m = new LinkedHashMap<>();
m.put("contractId", contractId);
m.put("contractCode", c.getCode());
m.put("contractName", c.getName());
m.put("contractStatus", c.getStatus());
m.put("instanceId", inst != null ? inst.getId() : null);
m.put("instanceStatus", inst != null ? inst.getStatus() : "无流程");
m.put("currentNode", inst != null ? inst.getCurrentNode() : null);
return ApiResp.ok(m);
}
/** 查询所有待某用户审批的合同流程。 */
@GetMapping("/pending")
public ApiResp<List<FormInstance>> pending(@RequestParam String user) {
return ApiResp.ok(instanceRepo.findByStatus("待办").stream()
.filter(i -> TEMPLATE_ID.equals(i.getTemplateId()))
.toList());
}
/** 合同审批流整体统计。 */
@GetMapping("/stats")
public ApiResp<Map<String, Object>> stats() {
List<FormInstance> all = instanceRepo.findAll().stream()
.filter(i -> TEMPLATE_ID.equals(i.getTemplateId())).toList();
Map<String, Object> m = new LinkedHashMap<>();
m.put("total", all.size());
m.put("pending", all.stream().filter(i -> "待办".equals(i.getStatus())).count());
m.put("done", all.stream().filter(i -> "已办结".equals(i.getStatus())).count());
m.put("returned", all.stream().filter(i -> "已退回".equals(i.getStatus())).count());
return ApiResp.ok(m);
}
// ---------- helpers ----------
/** 找合同对应的最新进行中的审批实例(按 title 前缀匹配 contractCode)。 */
private FormInstance findActiveInstance(Long contractId) {
Contract c = contractRepo.findById(contractId).orElse(null);
if (c == null) return null;
String prefix = "合同法务审批 - " + (c.getCode() != null ? c.getCode() : "");
return instanceRepo.findByStatusIn(List.of("待办", "办理中")).stream()
.filter(i -> TEMPLATE_ID.equals(i.getTemplateId())
&& i.getTitle() != null && i.getTitle().startsWith(prefix))
.findFirst().orElse(null);
}
}