package com.kaidi.oa.web; 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.CslContract; import com.kaidi.oa.domain.CslProject; import com.kaidi.oa.repository.CslContractRepository; import com.kaidi.oa.repository.CslProjectRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; 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.math.RoundingMode; 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; /** * 设计研究中心 / 咨询可研院 —— CSL 专属合同台账(补审计 PARTIAL 缺口·模块5·合同管理)。 * * 解决缺口:CslProject 仅靠 contractAmount 字段承载金额,与通用 ContractController 需按 * 客户名手工关联(模糊/易错),且无 CSL 专属合同台账页——客户-项目-合同-结算全链条断层。 * * 本控制器建立: * 1. CslContract 专属实体,显式 cslProjectId 外键精确关联 CslProject; * 2. 完整合同状态机(草稿→待审查→审查通过→签署生效→履约中→已竣工→结算中→已结算/已终止); * 3. 法务审查端点(/legal-review,记录审查意见,状态迁移到"审查通过"或打回); * 4. 付款登记端点(/record-payment,累加 receivedAmount,系统口径,不接受客户端直传); * 5. 开票登记端点(/record-invoice,累加 invoicedAmount,系统口径); * 6. 合同变更端点(/change,变更计数 +1,可选更新金额/期限); * 7. 客户-项目-合同-结算全链条聚合看板(/chain?cslProjectId=x); * 8. 结算逾期预警(/alerts/overdue)。 * * 金额口径:totalAmount 含税 BigDecimal;amountExclTax 由控制器按 taxRate 自动计算; * receivedAmount/invoicedAmount 系统口径,只能通过专属端点递增。 * * 写口登记进 AuthInterceptor FINANCE_PREFIXES;读口登记进 SENSITIVE_READ_PREFIXES(见 sharedFileSnippets)。 */ @RestController @RequestMapping("/api/oa/csl-contracts") public class CslContractController { private final CslContractRepository contractRepo; private final CslProjectRepository projectRepo; public CslContractController(CslContractRepository contractRepo, CslProjectRepository projectRepo) { this.contractRepo = contractRepo; this.projectRepo = projectRepo; } // ===== 台账 CRUD ===== @GetMapping public ApiResp> list( @RequestParam(required = false) Long cslProjectId, @RequestParam(required = false) String contractStatus, @RequestParam(required = false) String keyword, @RequestParam(required = false) String manager) { if (cslProjectId != null) { return ApiResp.ok(contractRepo.findByCslProjectId(cslProjectId)); } if (contractStatus != null && !contractStatus.isBlank()) { return ApiResp.ok(contractRepo.findByContractStatus(contractStatus)); } if (keyword != null && !keyword.isBlank()) { java.util.LinkedHashSet seen = new java.util.LinkedHashSet<>(); List merged = new ArrayList<>(); for (CslContract c : contractRepo.findByNameContaining(keyword)) { if (seen.add(c.getId())) merged.add(c); } for (CslContract c : contractRepo.findByPartyAContaining(keyword)) { if (seen.add(c.getId())) merged.add(c); } return ApiResp.ok(merged); } if (manager != null && !manager.isBlank()) { return ApiResp.ok(contractRepo.findByManager(manager)); } return ApiResp.ok(contractRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(find(id)); } public record CslContractRequest( String contractCode, String name, Long cslProjectId, String partyA, String partyB, String contractType, Double totalAmount, Double taxRate, String signDate, String dueDate, String manager, String paymentPlanDesc, String invoiceReq, String remark) { } /** * 新建 CSL 合同: * - 自动生成合同编号(若未提供); * - 若提供 cslProjectId,校验项目存在且自动回填甲方; * - amountExclTax 按 totalAmount / (1 + taxRate/100) 自动计算; * - receivedAmount / invoicedAmount 初始化为 0(系统口径,只能通过专属端点递增); * - 初始状态:草稿。 */ @PostMapping @Transactional public ApiResp create(@RequestBody CslContractRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "合同名称不能为空"); } CslContract c = new CslContract(); // 合同编号 String code = req.contractCode(); if (code == null || code.isBlank()) { code = "CSLHT-" + (contractRepo.count() + 1); } else { final String codeTrim = code.trim(); contractRepo.findByContractCode(codeTrim).ifPresent(existing -> { throw new ApiException(409, "合同编号已存在:" + codeTrim); }); } c.setContractCode(code.trim()); c.setName(req.name().trim()); // 项目关联(精确外键) if (req.cslProjectId() != null) { CslProject proj = projectRepo.findById(req.cslProjectId()) .orElseThrow(() -> new NotFoundException("csl project not found: " + req.cslProjectId())); c.setCslProjectId(proj.getId()); // 自动回填甲方(若未显式传入) if ((req.partyA() == null || req.partyA().isBlank()) && proj.getClient() != null) { c.setPartyA(proj.getClient()); } else { c.setPartyA(req.partyA()); } } else { c.setPartyA(req.partyA()); } c.setPartyB(req.partyB()); c.setContractType(req.contractType() == null ? "咨询服务合同" : req.contractType()); c.setTotalAmount(Money.of(req.totalAmount())); BigDecimal taxRate = req.taxRate() == null ? BigDecimal.ZERO : Money.of(req.taxRate()); c.setTaxRate(taxRate); c.setAmountExclTax(calcExclTax(c.getTotalAmount(), taxRate)); c.setReceivedAmount(Money.ZERO); c.setInvoicedAmount(Money.ZERO); c.setContractStatus("草稿"); c.setChangeCount(0); c.setSignDate(req.signDate()); c.setDueDate(req.dueDate()); c.setManager(req.manager()); c.setPaymentPlanDesc(req.paymentPlanDesc()); c.setInvoiceReq(req.invoiceReq()); c.setRemark(req.remark()); c.setCreatedAt(Instant.now()); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } /** * 更新合同基础字段(名称/类型/期限/负责人/收款开票说明等)。 * totalAmount/taxRate 可修改(会重算 amountExclTax); * receivedAmount/invoicedAmount 由专属端点控制,此口不接受。 */ @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody CslContractRequest req) { CslContract c = find(id); if ("已结算".equals(c.getContractStatus()) || "已终止".equals(c.getContractStatus())) { throw new ApiException(409, "合同已" + c.getContractStatus() + ",不能修改基础信息"); } if (req.name() != null && !req.name().isBlank()) c.setName(req.name().trim()); if (req.cslProjectId() != null) { projectRepo.findById(req.cslProjectId()) .orElseThrow(() -> new NotFoundException("csl project not found: " + req.cslProjectId())); c.setCslProjectId(req.cslProjectId()); } if (req.partyA() != null) c.setPartyA(req.partyA()); if (req.partyB() != null) c.setPartyB(req.partyB()); if (req.contractType() != null) c.setContractType(req.contractType()); if (req.totalAmount() != null) { c.setTotalAmount(Money.of(req.totalAmount())); BigDecimal taxRate = req.taxRate() != null ? Money.of(req.taxRate()) : Money.nz(c.getTaxRate()); c.setTaxRate(taxRate); c.setAmountExclTax(calcExclTax(c.getTotalAmount(), taxRate)); } else if (req.taxRate() != null) { BigDecimal taxRate = Money.of(req.taxRate()); c.setTaxRate(taxRate); c.setAmountExclTax(calcExclTax(Money.nz(c.getTotalAmount()), taxRate)); } if (req.signDate() != null) c.setSignDate(req.signDate()); if (req.dueDate() != null) c.setDueDate(req.dueDate()); if (req.manager() != null) c.setManager(req.manager()); if (req.paymentPlanDesc() != null) c.setPaymentPlanDesc(req.paymentPlanDesc()); if (req.invoiceReq() != null) c.setInvoiceReq(req.invoiceReq()); if (req.remark() != null) c.setRemark(req.remark()); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { CslContract c = find(id); if (!"草稿".equals(c.getContractStatus())) { throw new ApiException(409, "仅草稿状态合同可删除,当前状态:" + c.getContractStatus()); } contractRepo.deleteById(id); return ApiResp.ok(null); } // ===== 状态机 ===== /** 提交审查:草稿 → 待审查。 */ @PostMapping("/{id}/submit-review") @Transactional public ApiResp submitReview(@PathVariable Long id) { CslContract c = find(id); if (!"草稿".equals(c.getContractStatus())) { throw new ApiException(409, "仅草稿状态可提交审查,当前:" + c.getContractStatus()); } c.setContractStatus("待审查"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } public record LegalReviewRequest(String opinion, String remark) { } /** * 法务审查:待审查 → 审查通过 / 打回草稿。 * opinion: 审查通过 / 待整改(打回) / 不通过(终止合同流程)。 */ @PostMapping("/{id}/legal-review") @Transactional public ApiResp legalReview(@PathVariable Long id, @RequestBody LegalReviewRequest req) { CslContract c = find(id); if (!"待审查".equals(c.getContractStatus())) { throw new ApiException(409, "仅待审查状态可做法务审查,当前:" + c.getContractStatus()); } if (req.opinion() == null || req.opinion().isBlank()) { throw new ApiException(400, "审查意见不能为空(审查通过 / 待整改 / 不通过)"); } c.setLegalReview(req.opinion()); c.setLegalRemark(req.remark()); if ("审查通过".equals(req.opinion())) { c.setContractStatus("审查通过"); } else if ("不通过".equals(req.opinion())) { c.setContractStatus("已终止"); } else { c.setContractStatus("草稿"); // 待整改打回草稿 } c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } /** 签署生效:审查通过 → 签署生效 → 履约中(同步打上签署日期)。 */ @PostMapping("/{id}/sign") @Transactional public ApiResp sign(@PathVariable Long id, @RequestBody(required = false) Map body) { CslContract c = find(id); if (!"审查通过".equals(c.getContractStatus())) { throw new ApiException(409, "仅审查通过状态可签署生效,当前:" + c.getContractStatus()); } String signDate = body != null ? body.get("signDate") : null; c.setSignDate(signDate != null && !signDate.isBlank() ? signDate : LocalDate.now().toString()); c.setContractStatus("履约中"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } /** 标记竣工:履约中 → 已竣工(服务交付完成,进入结算)。 */ @PostMapping("/{id}/complete") @Transactional public ApiResp complete(@PathVariable Long id) { CslContract c = find(id); if (!"履约中".equals(c.getContractStatus())) { throw new ApiException(409, "仅履约中状态可标记竣工,当前:" + c.getContractStatus()); } c.setContractStatus("已竣工"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } /** 开始结算:已竣工 → 结算中。 */ @PostMapping("/{id}/start-settlement") @Transactional public ApiResp startSettlement(@PathVariable Long id) { CslContract c = find(id); if (!"已竣工".equals(c.getContractStatus())) { throw new ApiException(409, "仅已竣工状态可开始结算,当前:" + c.getContractStatus()); } c.setContractStatus("结算中"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } /** 结算完成:结算中 → 已结算(需已收款额 = 合同总额)。 */ @PostMapping("/{id}/settle") @Transactional public ApiResp settle(@PathVariable Long id) { CslContract c = find(id); if (!"结算中".equals(c.getContractStatus())) { throw new ApiException(409, "仅结算中状态可结算完成,当前:" + c.getContractStatus()); } BigDecimal total = Money.nz(c.getTotalAmount()); BigDecimal received = Money.nz(c.getReceivedAmount()); if (Money.gt(total, received)) { throw new ApiException(409, "已收款额(" + received + ")小于合同总额(" + total + "),不能结算完成"); } c.setContractStatus("已结算"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } /** 终止合同:任意非结算完成状态 → 已终止(需提供原因)。 */ @PostMapping("/{id}/terminate") @Transactional public ApiResp terminate(@PathVariable Long id, @RequestBody(required = false) Map body) { CslContract c = find(id); if ("已结算".equals(c.getContractStatus())) { throw new ApiException(409, "合同已结算,不能终止"); } String reason = body != null ? body.get("reason") : null; if (reason != null && !reason.isBlank()) { String existing = c.getRemark() == null ? "" : c.getRemark(); c.setRemark((existing.isBlank() ? "" : existing + " | ") + "终止原因:" + reason); } c.setContractStatus("已终止"); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } // ===== 付款登记(系统口径,只增不减) ===== public record PaymentRequest(Double amount, String payDate, String remark) { } /** * 登记收款(系统口径 receivedAmount 递增)。 * 累计收款不得超过合同总额(超收拦截)。 * 合同状态须在 履约中 / 已竣工 / 结算中 之一才能收款。 */ @PostMapping("/{id}/record-payment") @Transactional public ApiResp recordPayment(@PathVariable Long id, @RequestBody PaymentRequest req) { CslContract c = find(id); List payableStatuses = List.of("履约中", "已竣工", "结算中"); if (!payableStatuses.contains(c.getContractStatus())) { throw new ApiException(409, "当前合同状态不允许登记收款:" + c.getContractStatus()); } BigDecimal amount = Money.of(req.amount()); if (Money.lte0(amount)) { throw new ApiException(400, "收款金额必须大于 0"); } BigDecimal newReceived = Money.add(c.getReceivedAmount(), amount); if (Money.gt(newReceived, c.getTotalAmount())) { throw new ApiException(409, "累计收款额(" + newReceived + ")超过合同总额(" + c.getTotalAmount() + ")"); } c.setReceivedAmount(newReceived); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } // ===== 开票登记(系统口径,只增不减) ===== public record InvoiceRequest(Double amount, String invoiceDate, String invoiceNo, String remark) { } /** * 登记开票(系统口径 invoicedAmount 递增)。 * 累计开票额不得超过合同总额(超开拦截)。 */ @PostMapping("/{id}/record-invoice") @Transactional public ApiResp recordInvoice(@PathVariable Long id, @RequestBody InvoiceRequest req) { CslContract c = find(id); List invoiceableStatuses = List.of("履约中", "已竣工", "结算中", "已结算"); if (!invoiceableStatuses.contains(c.getContractStatus())) { throw new ApiException(409, "当前合同状态不允许登记开票:" + c.getContractStatus()); } BigDecimal amount = Money.of(req.amount()); if (Money.lte0(amount)) { throw new ApiException(400, "开票金额必须大于 0"); } BigDecimal newInvoiced = Money.add(c.getInvoicedAmount(), amount); if (Money.gt(newInvoiced, c.getTotalAmount())) { throw new ApiException(409, "累计开票额(" + newInvoiced + ")超过合同总额(" + c.getTotalAmount() + ")"); } c.setInvoicedAmount(newInvoiced); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } // ===== 合同变更 ===== public record ChangeRequest(Double newTotalAmount, String newDueDate, String changeReason) { } /** * 合同变更(合同变更次数 +1)。 * 可选更新合同总额(含税)和/或履约截止日期,并重算不含税额。 * 仅履约中 / 已竣工 / 结算中 状态可发起变更。 */ @PostMapping("/{id}/change") @Transactional public ApiResp change(@PathVariable Long id, @RequestBody ChangeRequest req) { CslContract c = find(id); List changeable = List.of("履约中", "已竣工", "结算中"); if (!changeable.contains(c.getContractStatus())) { throw new ApiException(409, "当前合同状态不允许发起变更:" + c.getContractStatus()); } if (req.changeReason() == null || req.changeReason().isBlank()) { throw new ApiException(400, "变更原因不能为空"); } if (req.newTotalAmount() != null) { BigDecimal newTotal = Money.of(req.newTotalAmount()); if (Money.lte0(newTotal)) { throw new ApiException(400, "变更后合同总额必须大于 0"); } c.setTotalAmount(newTotal); c.setAmountExclTax(calcExclTax(newTotal, Money.nz(c.getTaxRate()))); } if (req.newDueDate() != null && !req.newDueDate().isBlank()) { c.setDueDate(req.newDueDate()); } int count = c.getChangeCount() == null ? 0 : c.getChangeCount(); c.setChangeCount(count + 1); // 变更记录追加到备注(简单留痕,完整变更记录可建子表扩展) String existing = c.getRemark() == null ? "" : c.getRemark(); String note = "第" + (count + 1) + "次变更(" + LocalDate.now() + "):" + req.changeReason(); c.setRemark((existing.isBlank() ? "" : existing + " | ") + note); c.setUpdatedAt(Instant.now()); return ApiResp.ok(contractRepo.save(c)); } // ===== 客户-项目-合同-结算全链条聚合看板 ===== public record ChainView( Long cslProjectId, String projectCode, String projectName, String projectStage, String client, String manager, int contractCount, BigDecimal totalContractAmount, BigDecimal totalReceived, BigDecimal totalInvoiced, BigDecimal uncollected, List contracts) { } /** * 全链条看板:给定 cslProjectId,返回该项目的所有 CSL 合同以及聚合统计。 * 打通"客户→项目→合同→收款/开票"全链条数据(解决缺口核心)。 */ @GetMapping("/chain") public ApiResp chain(@RequestParam Long cslProjectId) { CslProject proj = projectRepo.findById(cslProjectId) .orElseThrow(() -> new NotFoundException("csl project not found: " + cslProjectId)); List contracts = contractRepo.findByCslProjectId(cslProjectId); BigDecimal totalAmount = contracts.stream() .map(c -> Money.nz(c.getTotalAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalReceived = contracts.stream() .map(c -> Money.nz(c.getReceivedAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalInvoiced = contracts.stream() .map(c -> Money.nz(c.getInvoicedAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal uncollected = Money.sub(totalAmount, totalReceived); return ApiResp.ok(new ChainView( proj.getId(), proj.getCode(), proj.getName(), proj.getStage(), proj.getClient(), proj.getManager(), contracts.size(), totalAmount, totalReceived, totalInvoiced, uncollected, contracts)); } // ===== 结算逾期预警 ===== public record OverdueContract( Long id, String contractCode, String name, String partyA, String dueDate, long overdueDays, BigDecimal totalAmount, BigDecimal receivedAmount, BigDecimal uncollected, String contractStatus) { } /** * 逾期预警:扫描所有"履约中/已竣工/结算中"状态合同,找出 dueDate 已过且仍有未收款余额的, * 按逾期天数降序,供合同管理员催收/介入。 */ @GetMapping("/alerts/overdue") public ApiResp> overdueAlerts() { LocalDate today = LocalDate.now(); List result = new ArrayList<>(); List activeStatuses = List.of("履约中", "已竣工", "结算中"); for (CslContract c : contractRepo.findAll()) { if (!activeStatuses.contains(c.getContractStatus())) continue; if (c.getDueDate() == null || c.getDueDate().isBlank()) continue; LocalDate due; try { due = LocalDate.parse(c.getDueDate().trim().substring(0, Math.min(10, c.getDueDate().trim().length()))); } catch (Exception e) { continue; } if (!due.isBefore(today)) continue; BigDecimal uncollected = Money.sub(c.getTotalAmount(), c.getReceivedAmount()); if (uncollected.compareTo(BigDecimal.ZERO) <= 0) continue; long overdueDays = ChronoUnit.DAYS.between(due, today); result.add(new OverdueContract( c.getId(), c.getContractCode(), c.getName(), c.getPartyA(), c.getDueDate(), overdueDays, Money.nz(c.getTotalAmount()), Money.nz(c.getReceivedAmount()), uncollected, c.getContractStatus())); } result.sort((a, b) -> Long.compare(b.overdueDays(), a.overdueDays())); return ApiResp.ok(result); } // ===== 台账统计概览 ===== public record ContractSummary( long total, long draft, long reviewing, long approved, long active, long completed, long settled, long terminated, BigDecimal totalContractAmount, BigDecimal totalReceived, BigDecimal totalInvoiced, BigDecimal totalUncollected) { } /** GET /summary -> CSL 合同台账统计(各状态数量+金额汇总)。 */ @GetMapping("/summary") public ApiResp summary() { List all = contractRepo.findAll(); long draft = count(all, "草稿"); long reviewing = count(all, "待审查"); long approved = count(all, "审查通过"); long active = count(all, "履约中"); long completed = count(all, "已竣工"); long settled = count(all, "已结算"); long terminated = count(all, "已终止"); BigDecimal totalAmount = all.stream().map(c -> Money.nz(c.getTotalAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalReceived = all.stream().map(c -> Money.nz(c.getReceivedAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalInvoiced = all.stream().map(c -> Money.nz(c.getInvoicedAmount())) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal uncollected = Money.sub(totalAmount, totalReceived); return ApiResp.ok(new ContractSummary(all.size(), draft, reviewing, approved, active, completed, settled, terminated, totalAmount, totalReceived, totalInvoiced, uncollected)); } // ===== helpers ===== private CslContract find(Long id) { return contractRepo.findById(id) .orElseThrow(() -> new NotFoundException("csl contract not found: " + id)); } /** * 不含税额 = 含税额 / (1 + taxRate/100),标度 2 HALF_UP。 * taxRate 为 0(无税)时直接返回含税额。 */ private static BigDecimal calcExclTax(BigDecimal totalAmount, BigDecimal taxRate) { BigDecimal nzTotal = Money.nz(totalAmount); BigDecimal nzRate = Money.nz(taxRate); if (nzRate.compareTo(BigDecimal.ZERO) == 0) { return nzTotal; } // divisor = 1 + taxRate/100 BigDecimal divisor = BigDecimal.ONE.add( nzRate.divide(new BigDecimal("100"), 6, RoundingMode.HALF_UP)); return nzTotal.divide(divisor, 2, RoundingMode.HALF_UP); } private static long count(List list, String status) { return list.stream().filter(c -> status.equals(c.getContractStatus())).count(); } }