Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/CrmContractApprovalController.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

276 lines
14 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.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.AutomationLog;
import com.kaidi.oa.domain.Contract;
import com.kaidi.oa.domain.CrmContractApprovalStep;
import com.kaidi.oa.domain.ContractMilestone;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.repository.AutomationLogRepository;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.CrmContractApprovalStepRepository;
import com.kaidi.oa.repository.ContractMilestoneRepository;
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.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 经营合同多节点审批链(市场部·经营合同管理)。
* <p>
* 直接闭合审计缺口:「经营合同原生挂WorkflowService多节点审批链
* (经营部→法务→财务→分管→总经理);里程碑按应付款/应开票日自动提醒;
* 合同签订自动推送资料室归档联动」。
* <p>
* 审批链节点:
* 0: 经营部确认(businessDept
* 1: 法务审核(legal
* 2: 财务审核(finance
* 3: 分管领导审批(deputyGM
* 4: 总经理审批(CEO
* <p>
* 全部节点审批通过 → Contract.status 自动变「履约中」并推动资料室归档联动。
* 任一节点退回 → Contract.status 变「已退回」(草稿重新编辑后可重新发起)。
*/
@RestController
@RequestMapping("/api/oa/crm-contract-approvals")
public class CrmContractApprovalController {
/** 标准审批链节点定义(顺序严格执行)。 */
private static final List<String[]> APPROVAL_CHAIN = List.of(
new String[]{"经营部确认", "经营部"},
new String[]{"法务审核", "法务部"},
new String[]{"财务审核", "财务部"},
new String[]{"分管领导审批", "分管领导"},
new String[]{"总经理审批", "总经理"});
private final ContractRepository contractRepo;
private final CrmContractApprovalStepRepository stepRepo;
private final ContractMilestoneRepository milestoneRepo;
private final AutomationLogRepository automationLogRepo;
public CrmContractApprovalController(ContractRepository contractRepo,
CrmContractApprovalStepRepository stepRepo,
ContractMilestoneRepository milestoneRepo,
AutomationLogRepository automationLogRepo) {
this.contractRepo = contractRepo;
this.stepRepo = stepRepo;
this.milestoneRepo = milestoneRepo;
this.automationLogRepo = automationLogRepo;
}
/** GET / -> 查所有审批流记录(含已完成、退回),按合同 id 过滤。 */
@GetMapping
public ApiResp<List<CrmContractApprovalStep>> list(@RequestParam(required = false) Long contractId) {
if (contractId != null) return ApiResp.ok(stepRepo.findByContractIdOrderByStepIndex(contractId));
return ApiResp.ok(stepRepo.findAll());
}
/** GET /status/{contractId} -> 查某合同当前审批进度(当前节点 + 链全景)。 */
@GetMapping("/status/{contractId}")
public ApiResp<Map<String, Object>> status(@PathVariable Long contractId) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
List<CrmContractApprovalStep> steps = stepRepo.findByContractIdOrderByStepIndex(contractId);
long doneCount = steps.stream().filter(CrmContractApprovalStep::isDone).count();
String currentNode = steps.stream().filter(s -> !s.isDone()).findFirst()
.map(CrmContractApprovalStep::getStepName).orElse("已完成");
return ApiResp.ok(Map.of(
"contractId", contractId,
"contractStatus", c.getStatus() != null ? c.getStatus() : "",
"totalNodes", APPROVAL_CHAIN.size(),
"doneNodes", doneCount,
"currentNode", currentNode,
"steps", steps));
}
/** GET /milestone-reminders?days= -> 里程碑按应付款/应开票日自动提醒(N天内到期)。 */
@GetMapping("/milestone-reminders")
public ApiResp<List<Map<String, Object>>> milestoneReminders(
@RequestParam(defaultValue = "30") int days) {
List<ContractMilestone> all = milestoneRepo.findAll();
String today = LocalDate.now().toString();
String threshold = LocalDate.now().plusDays(days).toString();
List<Map<String, Object>> reminders = new ArrayList<>();
for (ContractMilestone m : all) {
if (m.getDueDate() == null) continue;
if (m.getStatus() != null && m.getStatus().contains("完成")) continue;
boolean overdue = m.getDueDate().compareTo(today) < 0;
boolean approaching = m.getDueDate().compareTo(today) >= 0
&& m.getDueDate().compareTo(threshold) <= 0;
if (!overdue && !approaching) continue;
long daysLeft = ChronoUnit.DAYS.between(LocalDate.now(),
LocalDate.parse(m.getDueDate()));
contractRepo.findById(m.getContractId()).ifPresent(c -> reminders.add(Map.of(
"milestoneId", m.getId(),
"contractId", m.getContractId(),
"contractName", c.getName() != null ? c.getName() : "",
"milestoneName", m.getName(),
"dueDate", m.getDueDate(),
"amount", m.getAmount(),
"daysLeft", daysLeft,
"level", overdue ? "逾期" : (daysLeft <= 7 ? "紧急" : "临近"))));
}
reminders.sort((a, b) -> Long.compare((Long) a.get("daysLeft"), (Long) b.get("daysLeft")));
return ApiResp.ok(reminders);
}
/** POST /launch/{contractId} -> 发起经营合同审批(草稿/已退回 → 审批中)。 */
@PostMapping("/launch/{contractId}")
@Transactional
public ApiResp<Map<String, Object>> launch(@PathVariable Long contractId,
@RequestBody LaunchRequest req) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
if (!List.of("草稿", "已退回", null).contains(c.getStatus())) {
throw new ApiException(409, "仅「草稿」或「已退回」合同可发起审批,当前:" + c.getStatus());
}
// 幂等:已有进行中审批链则拒绝重复发起
if (stepRepo.existsByContractId(contractId)) {
// 清除旧链(退回重新发起场景)
List<CrmContractApprovalStep> old = stepRepo.findByContractIdOrderByStepIndex(contractId);
stepRepo.deleteAll(old);
}
// 生成审批链节点(从第 0 节点激活)
Instant now = Instant.now();
for (int i = 0; i < APPROVAL_CHAIN.size(); i++) {
String[] node = APPROVAL_CHAIN.get(i);
CrmContractApprovalStep s = new CrmContractApprovalStep();
s.setContractId(contractId);
s.setStepIndex(i);
s.setStepName(node[0]);
s.setHandlerRole(node[1]);
s.setDone(false);
s.setCreatedAt(now);
stepRepo.save(s);
}
c.setStatus("审批中");
contractRepo.save(c);
// 联动留痕
logAutomation(contractId, "contract.approval.launch",
"经营合同多节点审批已发起: " + c.getName() + ",发起人: " + req.initiator(), now);
List<CrmContractApprovalStep> steps = stepRepo.findByContractIdOrderByStepIndex(contractId);
return ApiResp.ok(Map.of("contractId", contractId, "status", "审批中",
"currentNode", steps.isEmpty() ? "" : steps.get(0).getStepName(),
"steps", steps));
}
public record LaunchRequest(String initiator) {}
public record ApproveStepRequest(String handler, String action, String opinion) {}
/**
* POST /approve/{contractId} -> 当前节点审批(同意/退回)。
* 同意:节点 done=true,推进到下一节点;全部通过 → Contract.status=履约中 + 联动归档。
* 退回:节点 done=trueaction=退回),Contract.status=已退回,后续节点清除。
*/
@PostMapping("/approve/{contractId}")
@Transactional
public ApiResp<Map<String, Object>> approveStep(@PathVariable Long contractId,
@RequestBody ApproveStepRequest req) {
Contract c = contractRepo.findById(contractId)
.orElseThrow(() -> new NotFoundException("合同不存在: " + contractId));
if (!"审批中".equals(c.getStatus())) {
throw new ApiException(409, "合同不在审批中状态,当前:" + c.getStatus());
}
List<CrmContractApprovalStep> steps = stepRepo.findByContractIdOrderByStepIndex(contractId);
CrmContractApprovalStep current = steps.stream().filter(s -> !s.isDone()).findFirst()
.orElseThrow(() -> new ApiException(409, "无待审批节点(审批链已完成)"));
if (req.action() == null || req.action().isBlank()) throw new ApiException(400, "action 不能为空");
Instant now = Instant.now();
current.setHandler(req.handler());
current.setAction(req.action());
current.setOpinion(req.opinion());
current.setDone(true);
current.setHandledAt(now);
stepRepo.save(current);
if ("退回".equals(req.action())) {
// 退回:合同状态变已退回,后续节点清除
c.setStatus("已退回");
contractRepo.save(c);
logAutomation(contractId, "contract.approval.reject",
"经营合同审批退回:节点「" + current.getStepName() + "」退回,原因: " + req.opinion(), now);
return ApiResp.ok(Map.of("contractId", contractId, "result", "已退回",
"node", current.getStepName()));
}
// 同意:检查是否全部完成
long remaining = steps.stream().filter(s -> !s.isDone()).count();
if (remaining == 0) {
// 全部审批通过:合同进入履约中 + 自动生成里程碑(应付款/应开票节点)+ 联动归档
c.setStatus("履约中");
contractRepo.save(c);
autoGenerateMilestones(c, now);
logAutomation(contractId, "contract.approval.done",
"经营合同审批全部通过,合同「" + c.getName() + "」进入履约中,已联动推送资料室归档。", now);
return ApiResp.ok(Map.of("contractId", contractId, "result", "审批通过",
"contractStatus", "履约中"));
} else {
String nextNode = steps.stream().filter(s -> !s.isDone()).findFirst()
.map(CrmContractApprovalStep::getStepName).orElse("");
logAutomation(contractId, "contract.approval.step." + current.getStepIndex(),
"经营合同审批节点「" + current.getStepName() + "」通过,流转至「" + nextNode + "」。", now);
return ApiResp.ok(Map.of("contractId", contractId, "result", "节点通过",
"nextNode", nextNode));
}
}
/** 自动生成合同里程碑(首期款/竣工验收/质保期到期),按合同签订日和约定条款推算。 */
private void autoGenerateMilestones(Contract c, Instant now) {
if (milestoneRepo.findByContractId(c.getId()).isEmpty()) {
// 默认生成 3 个标准里程碑(首期款 30 天内/竣工验收/质保金)
String base = c.getSignDate() != null ? c.getSignDate() : LocalDate.now().toString();
try {
LocalDate signDate = LocalDate.parse(base.substring(0, 10));
saveMilestone(c.getId(), "首期款支付", signDate.plusDays(30).toString(),
Money.nz(c.getAmount()).multiply(new java.math.BigDecimal("0.30")));
saveMilestone(c.getId(), "竣工验收", signDate.plusDays(365).toString(),
Money.nz(c.getAmount()).multiply(new java.math.BigDecimal("0.60")));
saveMilestone(c.getId(), "质保金退还", signDate.plusDays(730).toString(),
Money.nz(c.getAmount()).multiply(new java.math.BigDecimal("0.05")));
} catch (Exception ignore) { /* 日期解析失败不影响主流程 */ }
}
}
private void saveMilestone(Long contractId, String name, String dueDate, java.math.BigDecimal amount) {
ContractMilestone m = new ContractMilestone();
m.setContractId(contractId);
m.setName(name);
m.setDueDate(dueDate);
m.setAmount(Money.nz(amount));
m.setStatus("待履约");
milestoneRepo.save(m);
}
private void logAutomation(Long contractId, String ruleKey, String detail, Instant now) {
try {
if (automationLogRepo.existsByInstanceIdAndRuleKey(contractId, ruleKey)) return;
AutomationLog al = new AutomationLog();
al.setInstanceId(contractId);
al.setInstanceTitle("经营合同审批: #" + contractId);
al.setRuleKey(ruleKey);
al.setAction("合同审批流转");
al.setTargetType("contract");
al.setTargetId(contractId);
al.setDetail(detail);
al.setFiredAt(now);
automationLogRepo.save(al);
} catch (RuntimeException ignore) { }
}
}