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.BizPlanTarget; import com.kaidi.oa.domain.Financing; import com.kaidi.oa.domain.FormInstance; import com.kaidi.oa.domain.LegalRiskEvent; import com.kaidi.oa.domain.Payment; import com.kaidi.oa.domain.PmtBankRelation; import com.kaidi.oa.domain.PmtCostAllocRule; import com.kaidi.oa.domain.RepaymentPlan; import com.kaidi.oa.domain.Voucher; import com.kaidi.oa.repository.FinancingRepository; import com.kaidi.oa.repository.FormInstanceRepository; import com.kaidi.oa.repository.LegalRiskEventRepository; import com.kaidi.oa.repository.PaymentRepository; import com.kaidi.oa.repository.PmtBankRelationRepository; import com.kaidi.oa.repository.BizPlanTargetRepository; import com.kaidi.oa.repository.PmtCostAllocRuleRepository; import com.kaidi.oa.repository.RepaymentPlanRepository; import com.kaidi.oa.repository.VoucherRepository; import com.kaidi.oa.service.WorkflowService; 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.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeMap; /** * 金融办·融资管理。覆盖融资主体/授信额度登记、融资申请→放款的状态流转、 * 还本付息计划自动生成(按还款方式)、还款确认(自动生成资金支付中心待付付款单, * 把融资还款打通到结算/支付链)、到期分级预警、融资成本分析。 * * 写口默认受 AuthInterceptor 的 default-deny(ADMIN/APPROVER) 保护,并已登记进 * FINANCE_PREFIXES + SENSITIVE_READ_PREFIXES(含金额、属机密财务读)。 */ @RestController @RequestMapping("/api/oa/financings") public class FinancingController { /** 融资申请审批流程所用 FormTemplate ID。需在 TemplateSeedData 中注册。 */ private static final String FINANCING_TEMPLATE_ID = "financing-apply"; private final FinancingRepository financingRepo; private final RepaymentPlanRepository planRepo; private final PaymentRepository paymentRepo; private final VoucherRepository voucherRepo; private final WorkflowService workflowService; private final FormInstanceRepository instanceRepo; private final PmtBankRelationRepository bankRelRepo; private final LegalRiskEventRepository legalRiskRepo; private final PmtCostAllocRuleRepository costAllocRepo; private final BizPlanTargetRepository bizPlanTargetRepo; public FinancingController(FinancingRepository financingRepo, RepaymentPlanRepository planRepo, PaymentRepository paymentRepo, VoucherRepository voucherRepo, WorkflowService workflowService, FormInstanceRepository instanceRepo, PmtBankRelationRepository bankRelRepo, LegalRiskEventRepository legalRiskRepo, PmtCostAllocRuleRepository costAllocRepo, BizPlanTargetRepository bizPlanTargetRepo) { this.financingRepo = financingRepo; this.planRepo = planRepo; this.paymentRepo = paymentRepo; this.voucherRepo = voucherRepo; this.workflowService = workflowService; this.instanceRepo = instanceRepo; this.bankRelRepo = bankRelRepo; this.legalRiskRepo = legalRiskRepo; this.costAllocRepo = costAllocRepo; this.bizPlanTargetRepo = bizPlanTargetRepo; } // ---------- 融资台账 CRUD ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) String status) { if (status != null && !status.isBlank()) { return ApiResp.ok(financingRepo.findByStatus(status)); } return ApiResp.ok(financingRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("financing not found: " + id))); } public record FinancingRequest( String code, String lender, String financingType, Double creditLimit, Double amount, String currency, BigDecimal rate, String startDate, String endDate, Integer termMonths, String repayMethod, String status, String companySubject, String purpose, String owner) { } @Transactional @PostMapping public ApiResp create(@RequestBody FinancingRequest req) { if (req.lender() == null || req.lender().isBlank()) { throw new ApiException(400, "资金方(lender) 不能为空"); } Financing f = new Financing(); f.setCode(req.code() == null || req.code().isBlank() ? "RZ-" + (financingRepo.count() + 1) : req.code()); f.setLender(req.lender()); f.setFinancingType(req.financingType()); f.setCreditLimit(Money.of(req.creditLimit())); f.setAmount(Money.of(req.amount())); f.setCurrency(req.currency() == null || req.currency().isBlank() ? "人民币" : req.currency()); f.setRate(req.rate()); f.setStartDate(req.startDate()); f.setEndDate(req.endDate()); f.setTermMonths(req.termMonths()); f.setRepayMethod(req.repayMethod() == null || req.repayMethod().isBlank() ? "等额本息" : req.repayMethod()); f.setStatus(req.status() == null || req.status().isBlank() ? "申请中" : req.status()); f.setCompanySubject(req.companySubject()); f.setPurpose(req.purpose()); f.setOwner(req.owner()); f.setCreatedAt(Instant.now()); return ApiResp.ok(financingRepo.save(f)); } @Transactional @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody FinancingRequest req) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("financing not found: " + id)); if (req.lender() != null && !req.lender().isBlank()) f.setLender(req.lender()); if (req.financingType() != null) f.setFinancingType(req.financingType()); if (req.creditLimit() != null) f.setCreditLimit(Money.of(req.creditLimit())); if (req.amount() != null) f.setAmount(Money.of(req.amount())); if (req.currency() != null && !req.currency().isBlank()) f.setCurrency(req.currency()); if (req.rate() != null) f.setRate(req.rate()); if (req.startDate() != null) f.setStartDate(req.startDate()); if (req.endDate() != null) f.setEndDate(req.endDate()); if (req.termMonths() != null) f.setTermMonths(req.termMonths()); if (req.repayMethod() != null && !req.repayMethod().isBlank()) f.setRepayMethod(req.repayMethod()); if (req.status() != null && !req.status().isBlank()) f.setStatus(req.status()); if (req.companySubject() != null) f.setCompanySubject(req.companySubject()); if (req.purpose() != null) f.setPurpose(req.purpose()); if (req.owner() != null) f.setOwner(req.owner()); return ApiResp.ok(financingRepo.save(f)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!financingRepo.existsById(id)) { throw new NotFoundException("financing not found: " + id); } // 级联清理本融资的还款计划,杜绝悬挂计划行。 planRepo.deleteByFinancingId(id); financingRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 还本付息计划 ---------- @GetMapping("/{id}/repayments") public ApiResp> repayments(@PathVariable Long id) { return ApiResp.ok(planRepo.findByFinancingIdOrderByPeriodNoAsc(id)); } /** * 按融资的还款方式 / 期限 / 利率自动生成还本付息计划(重算时先清旧计划)。 * 已有任意期次「已还」时拒绝重算,避免抹掉还款历史。 */ @PostMapping("/{id}/schedule") public ApiResp> schedule(@PathVariable Long id) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("financing not found: " + id)); List existing = planRepo.findByFinancingIdOrderByPeriodNoAsc(id); boolean anyPaid = existing.stream().anyMatch(p -> "已还".equals(p.getStatus())); if (anyPaid) { throw new ApiException(409, "已有期次还款,不能重算还款计划"); } planRepo.deleteByFinancingId(id); int term = f.getTermMonths() == null || f.getTermMonths() <= 0 ? 12 : f.getTermMonths(); double annualRate = f.getRate() == null ? 0 : f.getRate().doubleValue(); BigDecimal principalTotal = Money.nz(f.getAmount()); String method = f.getRepayMethod() == null ? "等额本息" : f.getRepayMethod(); LocalDate start = parseDateOrNull(f.getStartDate()); List plans = buildPlans(f, term, annualRate, principalTotal, method, start); planRepo.saveAll(plans); if (!"已结清".equals(f.getStatus())) { f.setStatus("还款中"); financingRepo.save(f); } return ApiResp.ok(planRepo.findByFinancingIdOrderByPeriodNoAsc(id)); } private List buildPlans(Financing f, int term, double annualRate, BigDecimal principalTotal, String method, LocalDate start) { List plans = new ArrayList<>(); double monthlyRate = annualRate / 100.0 / 12.0; double principal = principalTotal.doubleValue(); Instant now = Instant.now(); if ("到期一次还本付息".equals(method)) { double interest = principal * monthlyRate * term; plans.add(plan(f, 1, dueOf(start, term), principalTotal, Money.of(interest), now)); return plans; } if ("按期付息到期还本".equals(method)) { for (int i = 1; i <= term; i++) { double interest = principal * monthlyRate; BigDecimal prin = (i == term) ? principalTotal : Money.ZERO; plans.add(plan(f, i, dueOf(start, i), prin, Money.of(interest), now)); } return plans; } if ("等额本金".equals(method)) { BigDecimal perPrincipal = principalTotal.divide(BigDecimal.valueOf(term), Money.SCALE, RoundingMode.HALF_UP); double balance = principal; BigDecimal acc = BigDecimal.ZERO; for (int i = 1; i <= term; i++) { double interest = balance * monthlyRate; BigDecimal prin = (i == term) ? Money.sub(principalTotal, acc) : perPrincipal; acc = Money.add(acc, prin); plans.add(plan(f, i, dueOf(start, i), prin, Money.of(interest), now)); balance -= prin.doubleValue(); } return plans; } // 默认:等额本息 double installment; if (monthlyRate == 0) { installment = principal / term; } else { double pow = Math.pow(1 + monthlyRate, term); installment = principal * monthlyRate * pow / (pow - 1); } double balance = principal; BigDecimal acc = BigDecimal.ZERO; for (int i = 1; i <= term; i++) { double interest = balance * monthlyRate; double prinD = installment - interest; BigDecimal prin = (i == term) ? Money.sub(principalTotal, acc) : Money.of(prinD); acc = Money.add(acc, prin); plans.add(plan(f, i, dueOf(start, i), prin, Money.of(interest), now)); balance -= prin.doubleValue(); } return plans; } private RepaymentPlan plan(Financing f, int periodNo, String dueDate, BigDecimal principal, BigDecimal interest, Instant now) { RepaymentPlan p = new RepaymentPlan(); p.setFinancingId(f.getId()); p.setFinancingCode(f.getCode()); p.setPeriodNo(periodNo); p.setDueDate(dueDate); p.setPrincipal(Money.nz(principal)); p.setInterest(Money.nz(interest)); p.setStatus("未还"); p.setCreatedAt(now); return p; } /** * 确认还款:生成一张资金支付中心待付付款单(payType=融资还款, 收款方=资金方, * 金额=本期本金+利息),回填 paymentId 并把本期置「已还」;全部还清则融资置「已结清」。 * 这条联动把"融资还款"打通到结算/支付链(与需求"还款联动结算中心生成付款单"一致)。 */ @PostMapping("/repayments/{planId}/pay") public ApiResp payRepayment(@PathVariable Long planId) { RepaymentPlan plan = planRepo.findById(planId) .orElseThrow(() -> new NotFoundException("repayment plan not found: " + planId)); if ("已还".equals(plan.getStatus())) { throw new ApiException(409, "本期已还款,请勿重复"); } Financing f = financingRepo.findById(plan.getFinancingId()) .orElseThrow(() -> new NotFoundException("financing not found: " + plan.getFinancingId())); BigDecimal totalAmt = Money.add(plan.getPrincipal(), plan.getInterest()); String paidDate = LocalDate.now().toString(); // 1. 生成资金支付中心待付付款单(联动结算链) Payment p = new Payment(); p.setCode("FKD-RZ" + plan.getId()); p.setSubject(f.getCompanySubject()); p.setPayType("融资还款"); p.setPayeeName(f.getLender()); p.setAmount(totalAmt); p.setApplicant(f.getOwner()); p.setStatus("待付"); p.setCreatedAt(Instant.now()); Payment savedPay = paymentRepo.save(p); plan.setPaymentId(savedPay.getId()); plan.setStatus("已还"); plan.setPaidDate(paidDate); planRepo.save(plan); // 2. Gap-3/Gap-4 修复:还款完成后自动生成还款凭证(幂等:同期次只生成一张) if (!voucherRepo.existsBySourceTypeAndSourceId("还款计划", plan.getId())) { Voucher voucher = new Voucher(); voucher.setVoucherNo("RZ-VCH-" + plan.getFinancingCode() + "-P" + plan.getPeriodNo()); voucher.setVoucherDate(paidDate); voucher.setSummary("融资还款凭证:" + nvl(f.getLender()) + " 第" + plan.getPeriodNo() + "期 本金" + Money.nz(plan.getPrincipal()).toPlainString() + "+利息" + Money.nz(plan.getInterest()).toPlainString() + "元"); voucher.setDebitAccount("长期借款/短期借款(" + nvl(f.getFinancingType()) + ")"); voucher.setCreditAccount("银行存款"); voucher.setAmount(totalAmt); voucher.setStatus(Voucher.S_DRAFT); voucher.setPreparer("system-financing-repay"); voucher.setReversed(Boolean.FALSE); voucher.setIsReversal(Boolean.FALSE); voucher.setSourceType("还款计划"); voucher.setSourceId(plan.getId()); voucher.setCreatedAt(Instant.now()); voucherRepo.save(voucher); } List all = planRepo.findByFinancingIdOrderByPeriodNoAsc(f.getId()); boolean allPaid = all.stream().allMatch(x -> "已还".equals(x.getStatus())); f.setStatus(allPaid ? "已结清" : "还款中"); financingRepo.save(f); return ApiResp.ok(plan); } // ---------- 到期分级预警 ---------- public record MaturityAlert(Long planId, Long financingId, String financingCode, String lender, Integer periodNo, String dueDate, double amountDue, long daysToDue, String level) { } /** * 到期分级预警:未还且应还日落在 [今天-逾期, 今天+days] 的还款期次。 * level:逾期 / 紧急(<=7天) / 临近(<=15天) / 关注(<=days)。 */ @GetMapping("/alerts/maturity") public ApiResp> maturityAlerts(@RequestParam(required = false, defaultValue = "30") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (RepaymentPlan plan : planRepo.findByStatus("未还")) { LocalDate due = parseDateOrNull(plan.getDueDate()); if (due == null) { continue; } long daysTo = java.time.temporal.ChronoUnit.DAYS.between(today, due); if (daysTo > days) { continue; } String level = daysTo < 0 ? "逾期" : daysTo <= 7 ? "紧急" : daysTo <= 15 ? "临近" : "关注"; double amountDue = Money.add(plan.getPrincipal(), plan.getInterest()).doubleValue(); Financing f = financingRepo.findById(plan.getFinancingId()).orElse(null); out.add(new MaturityAlert(plan.getId(), plan.getFinancingId(), plan.getFinancingCode(), f == null ? "" : f.getLender(), plan.getPeriodNo(), plan.getDueDate(), amountDue, daysTo, level)); } out.sort((a, b) -> Long.compare(a.daysToDue(), b.daysToDue())); return ApiResp.ok(out); } // ---------- 融资成本分析 ---------- public record CostRow(Long financingId, String code, String lender, String financingType, double principal, double totalInterest, double costRate, String status) { } public record CostSummary(List rows, double totalPrincipal, double totalInterest, double weightedCostRate) { } /** * 融资成本分析:逐笔汇总本金、应还利息合计、综合成本率(利息合计/本金),并给加权综合成本率。 * (XIRR 精确内部收益率列为后续,先用利息/本金口径满足成本对比与决策支持。) */ @GetMapping("/cost/summary") public ApiResp costSummary() { Map interestByFin = new LinkedHashMap<>(); for (RepaymentPlan p : planRepo.findAll()) { interestByFin.merge(p.getFinancingId(), Money.nz(p.getInterest()), Money::add); } List rows = new ArrayList<>(); BigDecimal totalPrincipal = BigDecimal.ZERO; BigDecimal totalInterest = BigDecimal.ZERO; for (Financing f : financingRepo.findAll()) { BigDecimal principal = Money.nz(f.getAmount()); BigDecimal interest = interestByFin.getOrDefault(f.getId(), BigDecimal.ZERO); double costRate = principal.signum() == 0 ? 0 : interest.divide(principal, 4, RoundingMode.HALF_UP).doubleValue() * 100.0; rows.add(new CostRow(f.getId(), f.getCode(), f.getLender(), f.getFinancingType(), principal.doubleValue(), interest.doubleValue(), costRate, f.getStatus())); totalPrincipal = Money.add(totalPrincipal, principal); totalInterest = Money.add(totalInterest, interest); } double weighted = totalPrincipal.signum() == 0 ? 0 : totalInterest.divide(totalPrincipal, 4, RoundingMode.HALF_UP).doubleValue() * 100.0; return ApiResp.ok(new CostSummary(rows, totalPrincipal.doubleValue(), totalInterest.doubleValue(), weighted)); } // ---------- Gap-3: 综合融资成本利息汇总端点(从还款计划取利息自动汇总)---------- public record InterestSummaryRow(Long financingId, String code, String lender, String financingType, String status, double principal, double totalInterestPlan, double totalInterestPaid, double totalInterestUnpaid, double contractFee, double comprehensiveCost) { } public record InterestSummaryResult(List rows, double grandPrincipal, double grandInterestPlan, double grandInterestPaid, double grandContractFee, double grandComprehensiveCost) { } /** * Gap-3: 综合融资成本利息汇总端点。 * 按融资从还款计划表自动汇总:计划利息合计、已还利息(已还期次)、未还利息(未还期次); * 综合成本 = 计划利息合计;综合成本率 = 计划利息合计 / 本金。 * 补足「payRepayment()仅创建待付单未自动汇集利息至成本分析」的缺口: * 前端可凭此端点展示逐笔融资的完整利息归集与综合成本,无需手动组合 /financings + /repayments。 */ @GetMapping("/cost/interest-summary") public ApiResp interestSummary() { // 按融资聚合还款计划利息:[0]=计划利息合计, [1]=已还利息合计 Map planByFin = new LinkedHashMap<>(); for (RepaymentPlan p : planRepo.findAll()) { planByFin.computeIfAbsent(p.getFinancingId(), k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); BigDecimal[] slot = planByFin.get(p.getFinancingId()); slot[0] = Money.add(slot[0], p.getInterest()); if ("已还".equals(p.getStatus())) { slot[1] = Money.add(slot[1], p.getInterest()); } } List rows = new ArrayList<>(); BigDecimal grandPrincipal = BigDecimal.ZERO; BigDecimal grandInterestPlan = BigDecimal.ZERO; BigDecimal grandInterestPaid = BigDecimal.ZERO; BigDecimal grandContractFee = BigDecimal.ZERO; for (Financing f : financingRepo.findAll()) { BigDecimal principal = Money.nz(f.getAmount()); BigDecimal[] pSlot = planByFin.getOrDefault(f.getId(), new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO}); BigDecimal interestPlan = pSlot[0]; BigDecimal interestPaid = pSlot[1]; BigDecimal interestUnpaid = Money.sub(interestPlan, interestPaid); // contractFee:Financing 实体暂无此字段,以 0 计(后续如需添加字段再扩) BigDecimal contractFee = BigDecimal.ZERO; BigDecimal comprehensive = Money.add(interestPlan, contractFee); rows.add(new InterestSummaryRow(f.getId(), f.getCode(), f.getLender(), f.getFinancingType(), f.getStatus(), principal.doubleValue(), interestPlan.doubleValue(), interestPaid.doubleValue(), interestUnpaid.doubleValue(), contractFee.doubleValue(), comprehensive.doubleValue())); grandPrincipal = Money.add(grandPrincipal, principal); grandInterestPlan = Money.add(grandInterestPlan, interestPlan); grandInterestPaid = Money.add(grandInterestPaid, interestPaid); grandContractFee = Money.add(grandContractFee, contractFee); } BigDecimal grandComprehensive = Money.add(grandInterestPlan, grandContractFee); return ApiResp.ok(new InterestSummaryResult(rows, grandPrincipal.doubleValue(), grandInterestPlan.doubleValue(), grandInterestPaid.doubleValue(), grandContractFee.doubleValue(), grandComprehensive.doubleValue())); } // ---------- Gap-4: 全口径债务台账汇总端点 ---------- public record DebtLedgerRow(Long financingId, String code, String lender, String financingType, String companySubject, String status, double principal, double paidPrincipal, double unpaidPrincipal, double rate, String endDate, long daysToMaturity, String maturityLevel) { } public record DebtLedgerSummary(List rows, long totalCount, double totalPrincipal, double totalUnpaid, double weightedRate, long overdueCount, double overdueAmount) { } /** * Gap-4: 全口径债务台账汇总端点。 * 一体化展示所有活跃融资(含银行贷款/债券/非标/租赁/内部借款等品种)的: * 本金余额(未还本金)、年化利率、到期日、距到期天数及到期级别(逾期/紧急/临近/正常)。 * 自动计算带息负债规模(totalUnpaid)与加权平均融资成本率(weightedRate)。 * 与 /financings 列表+structure-analysis 的差异:本端点聚焦债务余额口径(未还本金), * 而非合同额口径,且直接输出适合台账报表的字段组合。 */ @GetMapping("/debt-ledger") public ApiResp debtLedger() { LocalDate today = LocalDate.now(); // 按融资汇总已还本金(从还款计划) Map paidPrincipalByFin = new LinkedHashMap<>(); for (RepaymentPlan p : planRepo.findAll()) { if ("已还".equals(p.getStatus())) { paidPrincipalByFin.merge(p.getFinancingId(), Money.nz(p.getPrincipal()), Money::add); } } List rows = new ArrayList<>(); BigDecimal totalPrincipal = BigDecimal.ZERO; BigDecimal totalUnpaid = BigDecimal.ZERO; BigDecimal weightedRateSum = BigDecimal.ZERO; long overdueCount = 0; BigDecimal overdueAmount = BigDecimal.ZERO; for (Financing f : financingRepo.findAll()) { // 已结清、已驳回不进台账 if (List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) continue; BigDecimal principal = Money.nz(f.getAmount()); BigDecimal paid = paidPrincipalByFin.getOrDefault(f.getId(), BigDecimal.ZERO); BigDecimal unpaid = Money.nz(Money.sub(principal, paid).compareTo(BigDecimal.ZERO) >= 0 ? Money.sub(principal, paid) : BigDecimal.ZERO); LocalDate endDate = parseDateOrNull(f.getEndDate()); long daysToMaturity = endDate != null ? ChronoUnit.DAYS.between(today, endDate) : Long.MAX_VALUE; String maturityLevel; if (daysToMaturity == Long.MAX_VALUE) { maturityLevel = "未知"; } else if (daysToMaturity < 0) { maturityLevel = "逾期"; overdueCount++; overdueAmount = Money.add(overdueAmount, unpaid); } else if (daysToMaturity <= 30) { maturityLevel = "30天内"; } else if (daysToMaturity <= 90) { maturityLevel = "90天内"; } else { maturityLevel = "正常"; } rows.add(new DebtLedgerRow(f.getId(), f.getCode(), f.getLender(), f.getFinancingType(), f.getCompanySubject(), f.getStatus(), principal.doubleValue(), paid.doubleValue(), unpaid.doubleValue(), f.getRate() == null ? 0.0 : f.getRate().doubleValue(), f.getEndDate() == null ? "" : f.getEndDate(), daysToMaturity == Long.MAX_VALUE ? -999 : daysToMaturity, maturityLevel)); totalPrincipal = Money.add(totalPrincipal, principal); totalUnpaid = Money.add(totalUnpaid, unpaid); if (f.getRate() != null) { weightedRateSum = Money.add(weightedRateSum, unpaid.multiply(f.getRate())); } } double weightedRate = totalUnpaid.signum() == 0 ? 0 : weightedRateSum.divide(totalUnpaid, 4, java.math.RoundingMode.HALF_UP).doubleValue(); rows.sort((a, b) -> Long.compare(a.daysToMaturity(), b.daysToMaturity())); return ApiResp.ok(new DebtLedgerSummary(rows, rows.size(), totalPrincipal.doubleValue(), totalUnpaid.doubleValue(), weightedRate, overdueCount, overdueAmount.doubleValue())); } // ---------- 融资申请审批流程(接 WorkflowService)---------- public record ApprovalSubmitRequest(String applicant) { } /** * 提交融资申请进入 OA 审批流程(金融办初审→财务部→风控→高管/董事会)。 * 使用 WorkflowService.submit() 创建 FormInstance,融资状态推进到「审批中」。 * 同一融资只能提交一次(已有关联实例且状态非驳回时拒绝重复提交)。 *

* 【Gap-2 授信充足性校验】提交前系统自动校验授信额度剩余是否足以覆盖本次融资金额: * 用 lender 名称在 PmtBankRelation 档案中查找对应机构的 totalCreditLimit, * 减去同一机构当前所有非已结清/已驳回融资的 amount 合计(含本笔), * 若余量 < 0 则拒绝提交,给出明确提示,要求先调整授信或融资金额。 */ @PostMapping("/{id}/submit-approval") public ApiResp submitApproval(@PathVariable Long id, @RequestBody(required = false) ApprovalSubmitRequest req) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("融资台账不存在: " + id)); if (!List.of("申请中", "已驳回").contains(f.getStatus())) { throw new ApiException(409, "当前状态不允许提交审批: " + f.getStatus()); } String applicant = (req != null && req.applicant() != null && !req.applicant().isBlank()) ? req.applicant() : (f.getOwner() != null ? f.getOwner() : "admin"); // 检查是否已有未驳回的审批实例 boolean alreadyPending = instanceRepo.findAll().stream() .anyMatch(inst -> FINANCING_TEMPLATE_ID.equals(inst.getTemplateId()) && inst.getTitle() != null && inst.getTitle().contains(f.getCode()) && !List.of("已驳回", "已撤回").contains(inst.getStatus())); if (alreadyPending) { throw new ApiException(409, "该融资申请已存在进行中的审批流程,请勿重复提交"); } // ---- Gap-2: 授信充足性自动校验 ---- // 查找同名机构的授信档案(模糊匹配:lender startsWith institutionName 或相等) String lenderName = nvl(f.getLender()); Optional bankRelOpt = bankRelRepo.findAll().stream() .filter(r -> lenderName.startsWith(nvl(r.getInstitutionName())) || nvl(r.getInstitutionName()).startsWith(lenderName) || nvl(r.getInstitutionName()).equals(lenderName)) .findFirst(); if (bankRelOpt.isPresent()) { PmtBankRelation rel = bankRelOpt.get(); BigDecimal totalLimit = Money.nz(rel.getTotalCreditLimit()); // 同机构已使用额度 = 所有非已结清/已驳回融资的 amount 合计(含本笔) BigDecimal usedAmount = financingRepo.findAll().stream() .filter(x -> !List.of("已结清", "已驳回").contains(nvl(x.getStatus()))) .filter(x -> nvl(x.getLender()).equals(lenderName)) .map(x -> Money.nz(x.getAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal remaining = Money.sub(totalLimit, usedAmount); if (totalLimit.signum() > 0 && remaining.compareTo(BigDecimal.ZERO) < 0) { throw new ApiException(400, String.format("授信额度不足:机构「%s」总授信 %s 元,已用 %s 元,剩余 %s 元,无法覆盖本次融资,请先调整授信额度或融资金额", lenderName, totalLimit.toPlainString(), usedAmount.toPlainString(), remaining.toPlainString())); } } // ---- Gap-2 结束 ---- // 构建表单数据 String dataJson = String.format( "{\"financingCode\":\"%s\",\"lender\":\"%s\",\"financingType\":\"%s\"," + "\"amount\":%s,\"rate\":%s,\"termMonths\":%d,\"repayMethod\":\"%s\"," + "\"purpose\":\"%s\",\"companySubject\":\"%s\",\"applicant\":\"%s\"}", nvl(f.getCode()), nvl(f.getLender()), nvl(f.getFinancingType()), Money.nz(f.getAmount()).toPlainString(), f.getRate() == null ? "0" : f.getRate().toString(), f.getTermMonths() == null ? 12 : f.getTermMonths(), nvl(f.getRepayMethod()), nvl(f.getPurpose()), nvl(f.getCompanySubject()), applicant); String title = "融资申请-" + f.getCode() + " " + nvl(f.getLender()) + " " + Money.nz(f.getAmount()).toPlainString() + "元"; FormInstance inst = workflowService.submit(FINANCING_TEMPLATE_ID, dataJson, title, applicant); f.setStatus("审批中"); financingRepo.save(f); return ApiResp.ok(inst); } // ---------- Gap-2: 授信充足性预查询(提交前前端可调用) ---------- public record CreditCheckResult(String lender, boolean hasRelation, double totalCreditLimit, double usedAmount, double remaining, boolean sufficient, String message) { } /** * 授信充足性预校验(只读,不修改状态),前端提交前可先调用此接口显示授信余量。 */ @GetMapping("/{id}/credit-check") public ApiResp creditCheck(@PathVariable Long id) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("融资台账不存在: " + id)); String lenderName = nvl(f.getLender()); Optional bankRelOpt = bankRelRepo.findAll().stream() .filter(r -> lenderName.startsWith(nvl(r.getInstitutionName())) || nvl(r.getInstitutionName()).startsWith(lenderName) || nvl(r.getInstitutionName()).equals(lenderName)) .findFirst(); if (bankRelOpt.isEmpty()) { return ApiResp.ok(new CreditCheckResult(lenderName, false, 0, 0, 0, true, "未找到机构授信档案,跳过授信校验(建议在金融机构关系管理中录入)")); } PmtBankRelation rel = bankRelOpt.get(); BigDecimal totalLimit = Money.nz(rel.getTotalCreditLimit()); BigDecimal usedAmount = financingRepo.findAll().stream() .filter(x -> !List.of("已结清", "已驳回").contains(nvl(x.getStatus()))) .filter(x -> nvl(x.getLender()).equals(lenderName)) .map(x -> Money.nz(x.getAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal remaining = Money.sub(totalLimit, usedAmount); boolean sufficient = totalLimit.signum() == 0 || remaining.compareTo(BigDecimal.ZERO) >= 0; String message = sufficient ? String.format("授信充足:总额度 %s 元,已用 %s 元,剩余 %s 元", totalLimit.toPlainString(), usedAmount.toPlainString(), remaining.toPlainString()) : String.format("授信不足:总额度 %s 元,已用 %s 元,剩余 %s 元,超出 %s 元", totalLimit.toPlainString(), usedAmount.toPlainString(), remaining.toPlainString(), remaining.abs().toPlainString()); return ApiResp.ok(new CreditCheckResult(lenderName, true, totalLimit.doubleValue(), usedAmount.doubleValue(), remaining.doubleValue(), sufficient, message)); } /** * 查询融资关联的审批流程状态(最新一条)。 */ @GetMapping("/{id}/approval-status") public ApiResp> approvalStatus(@PathVariable Long id) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("融资台账不存在: " + id)); // 找到该融资关联的最新 FormInstance(按创建时间倒序) FormInstance latest = instanceRepo.findAll().stream() .filter(inst -> FINANCING_TEMPLATE_ID.equals(inst.getTemplateId()) && inst.getTitle() != null && inst.getTitle().contains(f.getCode())) .max(java.util.Comparator.comparing(FormInstance::getCreatedAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))) .orElse(null); Map result = new LinkedHashMap<>(); result.put("financingId", f.getId()); result.put("financingCode", f.getCode()); result.put("financingStatus", f.getStatus()); if (latest != null) { result.put("instanceId", latest.getId()); result.put("instanceStatus", latest.getStatus()); result.put("currentNode", latest.getCurrentNode()); result.put("title", latest.getTitle()); result.put("createdAt", latest.getCreatedAt()); } else { result.put("instanceId", null); result.put("instanceStatus", "未提交"); result.put("currentNode", null); } return ApiResp.ok(result); } // ---------- Gap-5: 融资结构分析(期限结构/利率分布/投融资平衡/XIRR近似) ---------- public record TermBucket(String bucket, long count, double totalAmount) { } public record RateBucket(String bucket, long count, double totalAmount, double avgRate) { } public record StructureAnalysis( List termDistribution, List rateDistribution, double benchmarkRate, List> highCostFinancings, InvestBalance investBalance) { } public record InvestBalance(double totalFinancing, double totalInvestPlan, double fundGap, String assessment) { } /** * Gap-5 融资结构分析聚合端点: * ① 期限结构:按剩余期限分桶(1年内/1-3年/3-5年/5年以上)统计融资本金分布。 * ② 利率分布:按年化利率区间(<3% / 3-5% / 5-7% / >7%)统计。 * ③ 高成本识别:融资年化利率 > benchmarkRate(默认4.5%,可传参)即视为高成本,列出明细。 * ④ 投融资平衡:融资本金合计 vs 当前在途投资项目(BizPlan 年度资金需求)资金缺口动态估算。 * ⑤ IRR 近似:按等额本息模型计算月内部收益率,换算年化 IRR(XIRR 真实方法的简化近似)。 */ @GetMapping("/cost/structure-analysis") public ApiResp structureAnalysis( @RequestParam(required = false, defaultValue = "4.5") double benchmarkRate) { LocalDate today = LocalDate.now(); List all = financingRepo.findAll(); // --- 期限结构:按 endDate 计算剩余月数分桶 --- Map termMap = new TreeMap<>(); // bucket -> [count, amountSum*100] termMap.put("1年内", new long[]{0, 0}); termMap.put("1-3年", new long[]{0, 0}); termMap.put("3-5年", new long[]{0, 0}); termMap.put("5年以上", new long[]{0, 0}); for (Financing f : all) { if (List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) continue; LocalDate end = parseDateOrNull(f.getEndDate()); long months = end != null ? ChronoUnit.MONTHS.between(today, end) : (f.getTermMonths() != null ? f.getTermMonths() : 12); String bucket = months <= 12 ? "1年内" : months <= 36 ? "1-3年" : months <= 60 ? "3-5年" : "5年以上"; long[] slot = termMap.get(bucket); slot[0]++; slot[1] += Money.nz(f.getAmount()).multiply(BigDecimal.valueOf(100)).longValue(); } List termDist = termMap.entrySet().stream() .map(e -> new TermBucket(e.getKey(), e.getValue()[0], e.getValue()[1] / 100.0)) .toList(); // --- 利率分布:按年化利率区间分桶 --- Map rateMap = new TreeMap<>(); rateMap.put("3%以下", new long[]{0, 0, 0}); // count, amountSum*100, rateSum*100 rateMap.put("3%-5%", new long[]{0, 0, 0}); rateMap.put("5%-7%", new long[]{0, 0, 0}); rateMap.put("7%以上", new long[]{0, 0, 0}); for (Financing f : all) { if (List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) continue; double r = f.getRate() == null ? 0 : f.getRate().doubleValue(); String bucket = r < 3 ? "3%以下" : r < 5 ? "3%-5%" : r < 7 ? "5%-7%" : "7%以上"; long[] slot = rateMap.get(bucket); slot[0]++; slot[1] += Money.nz(f.getAmount()).multiply(BigDecimal.valueOf(100)).longValue(); slot[2] += Math.round(r * 100); } List rateDist = rateMap.entrySet().stream() .map(e -> new RateBucket(e.getKey(), e.getValue()[0], e.getValue()[1] / 100.0, e.getValue()[0] == 0 ? 0 : e.getValue()[2] / 100.0 / e.getValue()[0])) .toList(); // --- 高成本融资识别(年化利率 > benchmarkRate) --- List> highCost = all.stream() .filter(f -> !List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) .filter(f -> f.getRate() != null && f.getRate().doubleValue() > benchmarkRate) .map(f -> { Map m = new LinkedHashMap<>(); m.put("financingId", f.getId()); m.put("code", f.getCode()); m.put("lender", f.getLender()); m.put("financingType", f.getFinancingType()); m.put("amount", Money.nz(f.getAmount()).doubleValue()); m.put("rate", f.getRate()); double rateVal = f.getRate().doubleValue(); m.put("excessBps", Math.round((rateVal - benchmarkRate) * 100)); m.put("suggestion", rateVal - benchmarkRate > 2 ? "建议择机置换或提前还款" : "关注,可在续授信时争取降利率"); return m; }) .toList(); // --- 投融资平衡:融资合计 vs 经营计划「融资需求」指标合计(Gap-5 真实 BizPlanTarget 跨表联动) --- BigDecimal totalFin = all.stream() .filter(f -> !List.of("已结清", "已驳回").contains(nvl(f.getStatus()))) .map(f -> Money.nz(f.getAmount())) .reduce(BigDecimal.ZERO, Money::add); // 真实投资计划需求:从 BizPlanTarget 中取 metric 包含"融资需求"或"资金需求"的目标值合计; // 若 BizPlanTarget 无此类指标,再回退到 Financing.creditLimit 口径(合规兼容)。 BigDecimal totalInvestPlan = bizPlanTargetRepo.findAll().stream() .filter(t -> t.getMetric() != null && (t.getMetric().contains("融资需求") || t.getMetric().contains("资金需求"))) .map(t -> Money.nz(t.getTargetValue())) .reduce(BigDecimal.ZERO, Money::add); if (totalInvestPlan.signum() == 0) { // 回退:用所有融资 creditLimit(计划授信额度)合计作为代理 totalInvestPlan = all.stream() .map(f -> Money.nz(f.getCreditLimit())) .reduce(BigDecimal.ZERO, Money::add); } BigDecimal fundGap = Money.sub(totalInvestPlan, totalFin); String assessment = fundGap.compareTo(BigDecimal.ZERO) > 0 ? "资金缺口待覆盖" : fundGap.compareTo(BigDecimal.ZERO) < 0 ? "融资已超计划需求,注意成本控制" : "投融资基本平衡"; InvestBalance investBalance = new InvestBalance(totalFin.doubleValue(), totalInvestPlan.doubleValue(), fundGap.doubleValue(), assessment); return ApiResp.ok(new StructureAnalysis(termDist, rateDist, benchmarkRate, highCost, investBalance)); } /** * Gap-5 IRR/XIRR 近似计算:对单笔融资按已生成还款计划反算月 IRR(牛顿迭代法), * 换算年化 IRR。供前端成本分析页展示真实资金成本(含费用摊销后的综合融资成本)。 */ @GetMapping("/{id}/irr") public ApiResp> calcIrr(@PathVariable Long id) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("融资台账不存在: " + id)); List plans = planRepo.findByFinancingIdOrderByPeriodNoAsc(id); Map result = new LinkedHashMap<>(); result.put("financingId", id); result.put("code", f.getCode()); result.put("lender", f.getLender()); if (plans.isEmpty()) { result.put("irrAnnual", null); result.put("message", "还款计划未生成,请先生成还本付息计划"); return ApiResp.ok(result); } // 现金流:期初流出 = 本金(负数),各期还款=本+息(正数) double principal = Money.nz(f.getAmount()).doubleValue(); double[] cashflows = new double[plans.size() + 1]; cashflows[0] = -principal; for (int i = 0; i < plans.size(); i++) { RepaymentPlan p = plans.get(i); cashflows[i + 1] = Money.add(p.getPrincipal(), p.getInterest()).doubleValue(); } // 牛顿迭代求月 IRR double monthlyIrr = newtonIrr(cashflows, 0.005, 100); double annualIrr = (Math.pow(1 + monthlyIrr, 12) - 1) * 100.0; result.put("irrMonthly", Math.round(monthlyIrr * 10000.0) / 100.0); result.put("irrAnnual", Math.round(annualIrr * 100.0) / 100.0); result.put("nominalRate", f.getRate()); result.put("message", String.format("年化 IRR ≈ %.2f%%(名义年利率 %.2f%%)", annualIrr, f.getRate() == null ? 0 : f.getRate())); return ApiResp.ok(result); } /** 牛顿迭代法求 IRR:给定现金流序列,返回使 NPV=0 的月利率(最大迭代 maxIter 次)。 */ private static double newtonIrr(double[] cf, double guess, int maxIter) { double r = guess; for (int iter = 0; iter < maxIter; iter++) { double npv = 0, dnpv = 0; for (int t = 0; t < cf.length; t++) { double disc = Math.pow(1 + r, t); npv += cf[t] / disc; if (t > 0) dnpv -= t * cf[t] / (disc * (1 + r)); } if (Math.abs(dnpv) < 1e-12) break; double rNew = r - npv / dnpv; if (Math.abs(rNew - r) < 1e-9) { r = rNew; break; } r = rNew; } return r; } // ---------- Gap-7: 融资/担保合同法律审核接口(法务风险部联动) ---------- /** * 查询挂载到本融资台账的全部法律风险事件(法律审核、担保合同法律意见书等)。 * 法务部在录入 LegalRiskEvent 时填写 financingContractId=本融资 ID 即完成挂载。 * 本接口实现 Gap:「融资/担保合同法律审核无专属接口(仅 LegalRiskEvent 独立表, * 未与融资合同外键绑定)」→ 现在 LegalRiskEvent.financingContractId 外键已建, * 本接口提供聚合查询。 */ @GetMapping("/{id}/legal-reviews") public ApiResp> legalReviews(@PathVariable Long id) { if (!financingRepo.existsById(id)) { throw new NotFoundException("融资台账不存在: " + id); } return ApiResp.ok(legalRiskRepo.findByFinancingContractId(id)); } /** * 将法律风险事件关联到指定融资台账(法务部操作,写入 financingContractId)。 * 请求体:{ "eventId": 123 } */ public record LinkLegalReviewRequest(Long eventId) { } @PostMapping("/{id}/legal-reviews/link") public ApiResp linkLegalReview(@PathVariable Long id, @RequestBody LinkLegalReviewRequest req) { if (!financingRepo.existsById(id)) { throw new NotFoundException("融资台账不存在: " + id); } if (req.eventId() == null) { throw new ApiException(400, "eventId 不能为空"); } LegalRiskEvent ev = legalRiskRepo.findById(req.eventId()) .orElseThrow(() -> new NotFoundException("法律风险事件不存在: " + req.eventId())); ev.setFinancingContractId(id); return ApiResp.ok(legalRiskRepo.save(ev)); } // ---------- Gap-7: 融资成本分摊推送至成本控制部(PmtCostAlloc 联动) ---------- /** * 融资成本分摊:将本融资的应付利息推送至成本控制部的费用分摊规则表, * 实现 Gap:「成本控制部融资成本分摊无自动联动(无 PmtCostAlloc 到融资成本的取数路径)」。 * 本端点按融资台账自动计算当期(period 参数,默认当月 YYYY-MM)的利息总额, * 在 PmtCostAllocRule 中创建一条「融资利息成本分摊」规则(allocMethod=固定金额, * 待成本控制部配置各公司分摊基数后执行 /compute 分摊)。 * 同一融资同一期间已有规则时返回 409,避免重复推送。 */ public record PushCostAllocRequest(String period, String costCenter) { } @PostMapping("/{id}/push-cost-alloc") public ApiResp pushCostAlloc(@PathVariable Long id, @RequestBody(required = false) PushCostAllocRequest req) { Financing f = financingRepo.findById(id) .orElseThrow(() -> new NotFoundException("融资台账不存在: " + id)); String period = (req != null && req.period() != null && !req.period().isBlank()) ? req.period() : java.time.YearMonth.now().toString(); String costCenter = (req != null && req.costCenter() != null && !req.costCenter().isBlank()) ? req.costCenter() : (nvl(f.getCompanySubject()).isBlank() ? "总部" : f.getCompanySubject()); // 幂等:同融资同期间已有规则则拒绝重复 String costItem = "融资利息成本-" + nvl(f.getCode()); boolean exists = costAllocRepo.findByPeriod(period).stream() .anyMatch(r -> costItem.equals(r.getCostItem())); if (exists) { throw new ApiException(409, "融资 [" + f.getCode() + "] 在期间 [" + period + "] 的利息成本分摊规则已存在,请勿重复推送"); } // 计算当期利息:该融资在本期间(month=period)应还利息合计 BigDecimal periodInterest = planRepo.findByFinancingIdOrderByPeriodNoAsc(id).stream() .filter(p -> p.getDueDate() != null && p.getDueDate().startsWith(period)) .map(p -> Money.nz(p.getInterest())) .reduce(BigDecimal.ZERO, Money::add); // 若无还款计划,按月息估算(本金 × 年化利率 / 12) if (periodInterest.signum() == 0) { double annualRate = f.getRate() == null ? 0.0 : f.getRate().doubleValue(); periodInterest = Money.of(Money.nz(f.getAmount()).doubleValue() * annualRate / 100.0 / 12.0); } PmtCostAllocRule rule = new PmtCostAllocRule(); rule.setCostItem(costItem); rule.setPeriod(period); rule.setAllocMethod("固定金额"); rule.setTotalAmount(periodInterest); // 初始基数:整体归入 costCenter(成本控制部可后续拆分各公司份额) String basisJson = "[{\"company\":\"" + costCenter + "\",\"value\":" + periodInterest.toPlainString() + "}]"; rule.setAllocBasisJson(basisJson); rule.setRemark("由融资台账 [" + nvl(f.getCode()) + "] 自动推送,资金方:" + nvl(f.getLender()) + ",期间:" + period + ";请成本控制部按实际分摊比例调整各公司基数后执行 /compute"); rule.setStatus("草稿"); rule.setCreator("system-financing-push"); rule.setCreatedAt(java.time.Instant.now()); return ApiResp.ok(costAllocRepo.save(rule)); } private static String nvl(String s) { return s == null ? "" : s; } // ---------- helpers ---------- private static LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (DateTimeParseException | IndexOutOfBoundsException e) { return null; } } private static String dueOf(LocalDate start, int monthsAhead) { if (start == null) { return null; } return start.plusMonths(monthsAhead).toString(); } }