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.Contract; import com.kaidi.oa.repository.ContractRepository; 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 org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Contract master data. type is one of 经营合同 / 成本合同 / 采购合同; * status is one of 履约中 / 已结算 / 已终止. */ @RestController @RequestMapping("/api/oa/contracts") public class ContractController { private final ContractRepository contractRepo; private final com.kaidi.oa.repository.ContractTemplateRepository templateRepo; public ContractController(ContractRepository contractRepo, com.kaidi.oa.repository.ContractTemplateRepository templateRepo) { this.contractRepo = contractRepo; this.templateRepo = templateRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) Long projectId, @RequestParam(required = false) String type, @RequestParam(required = false) String status) { if (projectId != null) return ApiResp.ok(contractRepo.findByProjectId(projectId)); if (type != null && !type.isBlank()) return ApiResp.ok(contractRepo.findByType(type)); if (status != null && !status.isBlank()) return ApiResp.ok(contractRepo.findByStatus(status)); return ApiResp.ok(contractRepo.findAll()); } /** * GET /search?keyword=xxx -> 合同全文关键词检索(含合同名称+乙方名称模糊匹配)。 * 补齐缺口:合同档案库全文检索(附件扫描件全文需 ES,此处先闭合结构化字段检索)。 */ @GetMapping("/search") public ApiResp> search(@RequestParam String keyword) { if (keyword == null || keyword.isBlank()) { throw new ApiException(400, "keyword 不能为空"); } String kw = keyword.trim(); List byName = contractRepo.findByNameContaining(kw); List byPartyB = contractRepo.findByPartyBContaining(kw); java.util.LinkedHashSet seen = new java.util.LinkedHashSet<>(); List merged = new ArrayList<>(); for (Contract c : byName) { if (seen.add(c.getId())) merged.add(c); } for (Contract c : byPartyB) { if (seen.add(c.getId())) merged.add(c); } return ApiResp.ok(merged); } /** * GET /overdue-alert -> 履约逾期预警:列出状态=「履约中」且 signDate 超过180天且 paidAmount < amount 的合同。 * 法务人员可据此主动介入追款/推函。供采购/销售/财务联动调用。 */ @GetMapping("/overdue-alert") public ApiResp>> overdueAlert() { List active = contractRepo.findByStatus("履约中"); LocalDate threshold = LocalDate.now().minusDays(180); List> result = new ArrayList<>(); for (Contract c : active) { if (c.getSignDate() == null || c.getSignDate().isBlank()) continue; try { LocalDate sign = LocalDate.parse(c.getSignDate()); if (sign.isBefore(threshold)) { BigDecimal paid = Money.nz(c.getPaidAmount()); BigDecimal total = Money.nz(c.getAmount()); if (total.compareTo(BigDecimal.ZERO) > 0 && paid.compareTo(total) < 0) { Map row = new LinkedHashMap<>(); row.put("id", c.getId()); row.put("code", c.getCode()); row.put("name", c.getName()); row.put("partyB", c.getPartyB()); row.put("amount", total); row.put("paidAmount", paid); row.put("unpaidAmount", total.subtract(paid)); row.put("signDate", c.getSignDate()); row.put("overdueDays", sign.until(LocalDate.now(), java.time.temporal.ChronoUnit.DAYS)); row.put("status", c.getStatus()); result.add(row); } } } catch (Exception ignore) {} } return ApiResp.ok(result); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(contractRepo.findById(id) .orElseThrow(() -> new NotFoundException("contract not found: " + id))); } public record CreateContractRequest( String code, String name, String type, String partyA, String partyB, Double amount, Long projectId, String companySubject, String signDate, String status, Double paidAmount, Double invoicedAmount, String custodian, Long parentId) { } @PostMapping public ApiResp create(@RequestBody CreateContractRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "name is required"); } Contract c = new Contract(); c.setCode(req.code()); c.setName(req.name()); c.setType(req.type()); c.setPartyA(req.partyA()); c.setPartyB(req.partyB()); c.setAmount(Money.of(req.amount() == null ? 0 : req.amount())); c.setProjectId(req.projectId()); c.setCompanySubject(req.companySubject()); c.setSignDate(req.signDate()); c.setStatus(req.status() == null ? "履约中" : req.status()); // 钱权分离(H13):已付/已开票额是系统口径,只能由 PaymentService.confirmPay 与 // InvoiceController.applyContractDelta 回写,绝不接受客户端直传——新合同一律从 0 起算。 c.setPaidAmount(Money.ZERO); c.setInvoicedAmount(Money.ZERO); c.setCustodian(req.custodian()); c.setParentId(req.parentId()); return ApiResp.ok(contractRepo.save(c)); } public record DraftFromTemplateRequest( Long templateId, String name, String type, String partyA, String partyB, Double amount, String companySubject, String signDate) { } /** * POST /draft-from-template -> 合同闭环·从模板起草:校验模板存在并继承其合同类型, * 落库为一张「草稿」合同(已付/已开票从 0 起算,与现有 create 钱权分离口径一致)。 * 起草后可送条款比对(/api/oa/clause-compare)与电子签(/api/oa/contracts/... esign)。 */ @Transactional @PostMapping("/draft-from-template") public ApiResp draftFromTemplate(@RequestBody DraftFromTemplateRequest req) { if (req.templateId() == null) { throw new ApiException(400, "templateId 不能为空"); } var tpl = templateRepo.findById(req.templateId()) .orElseThrow(() -> new NotFoundException("contract template not found: " + req.templateId())); if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "合同名称不能为空"); } Contract c = new Contract(); c.setCode("HT-CG" + (contractRepo.count() + 1)); c.setName(req.name()); // 合同类型缺省继承模板的 contractType。 c.setType(req.type() == null || req.type().isBlank() ? tpl.getContractType() : req.type()); c.setPartyA(req.partyA()); c.setPartyB(req.partyB()); c.setAmount(Money.of(req.amount())); c.setCompanySubject(req.companySubject()); c.setSignDate(req.signDate()); c.setStatus("草稿"); c.setPaidAmount(Money.ZERO); c.setInvoicedAmount(Money.ZERO); return ApiResp.ok(contractRepo.save(c)); } public record CreateSubRequest(String name, String partyB, Double amount, String type) { } /** * POST /{id}/create-sub -> 在某总包合同下一键派生分包合同(工程项目生命周期⑧材料等分包合同)。 * 自动:甲乙方倒换(我方=原总包乙方变发包甲方,分包商为乙方)、继承项目/主体、type=分包合同、status=草稿、parentId=总包。 * 成本控制规则:分包累计金额不得超过总包合同金额,否则拒绝(防分包超支)。仅支持总包→分包两级。 */ @PostMapping("/{id}/create-sub") @Transactional public ApiResp createSub(@PathVariable Long id, @RequestBody CreateSubRequest req) { Contract parent = contractRepo.findById(id) .orElseThrow(() -> new NotFoundException("contract not found: " + id)); if (parent.getParentId() != null) { throw new ApiException(400, "分包合同不能再派生下级分包(仅支持总包→分包两级)"); } BigDecimal newAmount = Money.of(req.amount()); // 分包金额必须 >0:否则负额分包既是脏数据,又会在下面的带符号累计求和里"抵消"敞口, // 让实际超总包的正向分包绕过"分包不得超总包"成本控制。 if (Money.lte0(newAmount)) { throw new ApiException(400, "分包金额必须大于 0"); } // 一次性取出现有分包,既用于累计校验也用于编号,避免二次查询拉大 check-then-act 窗口 List existing = contractRepo.findByParentId(id); BigDecimal existingSubs = existing.stream() .map(c -> Money.nz(c.getAmount())).reduce(BigDecimal.ZERO, BigDecimal::add); if (Money.gt(parent.getAmount(), BigDecimal.ZERO) && Money.gt(Money.add(existingSubs, newAmount), parent.getAmount())) { throw new ApiException(400, "分包累计金额 " + Money.add(existingSubs, newAmount) + " 超过总包合同金额 " + parent.getAmount() + ",请核对(成本控制:分包不得超总包)"); } Contract sub = new Contract(); // 残留:编号后缀按现有分包数 +1 生成,并发同时派生仍可能撞号;编号唯一性以 DB 约束为准(冲突→409)。 sub.setCode((parent.getCode() == null || parent.getCode().isBlank() ? "HT-" + id : parent.getCode()) + "-F" + (existing.size() + 1)); sub.setName(req.name() == null || req.name().isBlank() ? parent.getName() + " 分包合同" : req.name()); sub.setType(req.type() == null || req.type().isBlank() ? "分包合同" : req.type()); sub.setPartyA(parent.getPartyB()); sub.setPartyB(req.partyB()); sub.setAmount(Money.of(newAmount)); sub.setProjectId(parent.getProjectId()); sub.setCompanySubject(parent.getCompanySubject()); sub.setStatus("草稿"); sub.setCustodian(parent.getCustodian()); sub.setParentId(parent.getId()); return ApiResp.ok(contractRepo.save(sub)); } public record UpdateContractRequest( String code, String name, String type, String partyA, String partyB, Double amount, Long projectId, String companySubject, String signDate, String status, Double paidAmount, Double invoicedAmount, String custodian, Long parentId) { } /** PATCH /{id} -> partial update; only non-null fields are applied. */ @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody UpdateContractRequest req) { Contract c = contractRepo.findById(id) .orElseThrow(() -> new NotFoundException("contract not found: " + id)); if (req.name() != null && req.name().isBlank()) { throw new ApiException(400, "name cannot be blank"); } if (req.code() != null) c.setCode(req.code()); if (req.name() != null) c.setName(req.name()); if (req.type() != null) c.setType(req.type()); if (req.partyA() != null) c.setPartyA(req.partyA()); if (req.partyB() != null) c.setPartyB(req.partyB()); if (req.amount() != null) c.setAmount(Money.of(req.amount())); if (req.projectId() != null) c.setProjectId(req.projectId()); if (req.companySubject() != null) c.setCompanySubject(req.companySubject()); if (req.signDate() != null) c.setSignDate(req.signDate()); if (req.status() != null) c.setStatus(req.status()); // 钱权分离(H13):已付/已开票额不接受客户端直改,只由付款结算/发票回写系统口径维护, // 防止任意调用方绕过资金审批直接篡改 contract.paidAmount/invoicedAmount。 if (req.custodian() != null) c.setCustodian(req.custodian()); if (req.parentId() != null) c.setParentId(req.parentId()); return ApiResp.ok(contractRepo.save(c)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!contractRepo.existsById(id)) { throw new NotFoundException("contract not found: " + id); } contractRepo.deleteById(id); return ApiResp.ok(null); } /** * GET /{id}/print -> 合同打印预览(线上打印端点,补齐缺口 §2-3)。 * 返回一个合同打印数据包(含完整合同字段、大小写金额、当前时间戳), * 前端据此渲染打印视图或调用 window.print()。 */ @GetMapping("/{id}/print") public ApiResp> print(@PathVariable Long id) { Contract c = contractRepo.findById(id) .orElseThrow(() -> new NotFoundException("contract not found: " + id)); Map data = new LinkedHashMap<>(); data.put("id", c.getId()); data.put("code", c.getCode()); data.put("name", c.getName()); data.put("type", c.getType()); data.put("partyA", c.getPartyA()); data.put("partyB", c.getPartyB()); data.put("amount", c.getAmount()); data.put("amountChinese", ContractCostExtController.toChinese(c.getAmount())); data.put("signDate", c.getSignDate()); data.put("status", c.getStatus()); data.put("companySubject", c.getCompanySubject()); data.put("custodian", c.getCustodian()); data.put("paidAmount", c.getPaidAmount()); data.put("invoicedAmount", c.getInvoicedAmount()); data.put("printTime", java.time.Instant.now().toString()); return ApiResp.ok(data); } /** * POST /prereview -> 合同附件预审(补齐缺口 §2-1/2-3)。 * 校验:附件日期必须晚于或等于合同签订日期,否则 400。 * 预审通过后合同状态推进到「预审通过」(前提:合同处于草稿/待审批,不在终态)。 */ public record PrereviewRequest( Long contractId, String attachmentDate, String attachmentDesc, String operator) { } @PostMapping("/prereview") @Transactional public ApiResp> prereview(@RequestBody PrereviewRequest req) { if (req.contractId() == null) { throw new ApiException(400, "contractId 不能为空"); } Contract c = contractRepo.findById(req.contractId()) .orElseThrow(() -> new NotFoundException("contract not found: " + req.contractId())); // 合同终态不可预审 if ("已结算".equals(c.getStatus()) || "已终止".equals(c.getStatus())) { throw new ApiException(400, "合同已结算或终止,不能再预审"); } // 附件日期校验:附件日期必须 >= 合同签订日期 if (req.attachmentDate() != null && !req.attachmentDate().isBlank() && c.getSignDate() != null && !c.getSignDate().isBlank()) { LocalDate attDate = LocalDate.parse(req.attachmentDate()); LocalDate signDate = LocalDate.parse(c.getSignDate()); if (attDate.isBefore(signDate)) { throw new ApiException(400, "附件日期(" + req.attachmentDate() + ")早于合同签订日期(" + c.getSignDate() + "),无法提交"); } } // 预审通过,推进状态 String prevStatus = c.getStatus(); if ("草稿".equals(c.getStatus()) || "待审批".equals(c.getStatus()) || c.getStatus() == null) { c.setStatus("预审通过"); contractRepo.save(c); } Map result = new LinkedHashMap<>(); result.put("contractId", c.getId()); result.put("contractName", c.getName()); result.put("attachmentDate", req.attachmentDate()); result.put("signDate", c.getSignDate()); result.put("statusBefore", prevStatus); result.put("statusAfter", c.getStatus()); result.put("operator", req.operator()); result.put("prereviewTime", java.time.Instant.now().toString()); result.put("passed", true); return ApiResp.ok(result); } }