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,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 与 WorkflowService(FormInstance/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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user