恢复点(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>
276 lines
14 KiB
Java
276 lines
14 KiB
Java
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=true(action=退回),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) { }
|
||
}
|
||
}
|