Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/LegalContractFlowController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

263 lines
13 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}