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; /** * 经营合同多节点审批链(市场部·经营合同管理)。 *

* 直接闭合审计缺口:「经营合同原生挂WorkflowService多节点审批链 * (经营部→法务→财务→分管→总经理);里程碑按应付款/应开票日自动提醒; * 合同签订自动推送资料室归档联动」。 *

* 审批链节点: * 0: 经营部确认(businessDept) * 1: 法务审核(legal) * 2: 财务审核(finance) * 3: 分管领导审批(deputyGM) * 4: 总经理审批(CEO) *

* 全部节点审批通过 → Contract.status 自动变「履约中」并推动资料室归档联动。 * 任一节点退回 → Contract.status 变「已退回」(草稿重新编辑后可重新发起)。 */ @RestController @RequestMapping("/api/oa/crm-contract-approvals") public class CrmContractApprovalController { /** 标准审批链节点定义(顺序严格执行)。 */ private static final List 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(@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> status(@PathVariable Long contractId) { Contract c = contractRepo.findById(contractId) .orElseThrow(() -> new NotFoundException("合同不存在: " + contractId)); List 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>> milestoneReminders( @RequestParam(defaultValue = "30") int days) { List all = milestoneRepo.findAll(); String today = LocalDate.now().toString(); String threshold = LocalDate.now().plusDays(days).toString(); List> 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> 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 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 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> 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 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) { } } }