1027 lines
53 KiB
Java
1027 lines
53 KiB
Java
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<Financing>> 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<Financing> 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<Financing> 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<Financing> 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<Void> 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<List<RepaymentPlan>> repayments(@PathVariable Long id) {
|
||
return ApiResp.ok(planRepo.findByFinancingIdOrderByPeriodNoAsc(id));
|
||
}
|
||
|
||
/**
|
||
* 按融资的还款方式 / 期限 / 利率自动生成还本付息计划(重算时先清旧计划)。
|
||
* 已有任意期次「已还」时拒绝重算,避免抹掉还款历史。
|
||
*/
|
||
@PostMapping("/{id}/schedule")
|
||
public ApiResp<List<RepaymentPlan>> schedule(@PathVariable Long id) {
|
||
Financing f = financingRepo.findById(id)
|
||
.orElseThrow(() -> new NotFoundException("financing not found: " + id));
|
||
List<RepaymentPlan> 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<RepaymentPlan> 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<RepaymentPlan> buildPlans(Financing f, int term, double annualRate,
|
||
BigDecimal principalTotal, String method, LocalDate start) {
|
||
List<RepaymentPlan> 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<RepaymentPlan> 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<RepaymentPlan> 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<List<MaturityAlert>> maturityAlerts(@RequestParam(required = false, defaultValue = "30") int days) {
|
||
LocalDate today = LocalDate.now();
|
||
List<MaturityAlert> 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<CostRow> rows, double totalPrincipal, double totalInterest,
|
||
double weightedCostRate) {
|
||
}
|
||
|
||
/**
|
||
* 融资成本分析:逐笔汇总本金、应还利息合计、综合成本率(利息合计/本金),并给加权综合成本率。
|
||
* (XIRR 精确内部收益率列为后续,先用利息/本金口径满足成本对比与决策支持。)
|
||
*/
|
||
@GetMapping("/cost/summary")
|
||
public ApiResp<CostSummary> costSummary() {
|
||
Map<Long, BigDecimal> interestByFin = new LinkedHashMap<>();
|
||
for (RepaymentPlan p : planRepo.findAll()) {
|
||
interestByFin.merge(p.getFinancingId(), Money.nz(p.getInterest()), Money::add);
|
||
}
|
||
List<CostRow> 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<InterestSummaryRow> rows,
|
||
double grandPrincipal, double grandInterestPlan,
|
||
double grandInterestPaid, double grandContractFee,
|
||
double grandComprehensiveCost) {
|
||
}
|
||
|
||
/**
|
||
* Gap-3: 综合融资成本利息汇总端点。
|
||
* 按融资从还款计划表自动汇总:计划利息合计、已还利息(已还期次)、未还利息(未还期次);
|
||
* 综合成本 = 计划利息合计;综合成本率 = 计划利息合计 / 本金。
|
||
* 补足「payRepayment()仅创建待付单未自动汇集利息至成本分析」的缺口:
|
||
* 前端可凭此端点展示逐笔融资的完整利息归集与综合成本,无需手动组合 /financings + /repayments。
|
||
*/
|
||
@GetMapping("/cost/interest-summary")
|
||
public ApiResp<InterestSummaryResult> interestSummary() {
|
||
// 按融资聚合还款计划利息:[0]=计划利息合计, [1]=已还利息合计
|
||
Map<Long, BigDecimal[]> 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<InterestSummaryRow> 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<DebtLedgerRow> 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<DebtLedgerSummary> debtLedger() {
|
||
LocalDate today = LocalDate.now();
|
||
// 按融资汇总已还本金(从还款计划)
|
||
Map<Long, BigDecimal> paidPrincipalByFin = new LinkedHashMap<>();
|
||
for (RepaymentPlan p : planRepo.findAll()) {
|
||
if ("已还".equals(p.getStatus())) {
|
||
paidPrincipalByFin.merge(p.getFinancingId(), Money.nz(p.getPrincipal()), Money::add);
|
||
}
|
||
}
|
||
|
||
List<DebtLedgerRow> 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,融资状态推进到「审批中」。
|
||
* 同一融资只能提交一次(已有关联实例且状态非驳回时拒绝重复提交)。
|
||
* <p>
|
||
* 【Gap-2 授信充足性校验】提交前系统自动校验授信额度剩余是否足以覆盖本次融资金额:
|
||
* 用 lender 名称在 PmtBankRelation 档案中查找对应机构的 totalCreditLimit,
|
||
* 减去同一机构当前所有非已结清/已驳回融资的 amount 合计(含本笔),
|
||
* 若余量 < 0 则拒绝提交,给出明确提示,要求先调整授信或融资金额。
|
||
*/
|
||
@PostMapping("/{id}/submit-approval")
|
||
public ApiResp<FormInstance> 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<PmtBankRelation> 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<CreditCheckResult> creditCheck(@PathVariable Long id) {
|
||
Financing f = financingRepo.findById(id)
|
||
.orElseThrow(() -> new NotFoundException("融资台账不存在: " + id));
|
||
String lenderName = nvl(f.getLender());
|
||
Optional<PmtBankRelation> 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<Map<String, Object>> 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<String, Object> 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<TermBucket> termDistribution,
|
||
List<RateBucket> rateDistribution,
|
||
double benchmarkRate,
|
||
List<Map<String, Object>> 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> structureAnalysis(
|
||
@RequestParam(required = false, defaultValue = "4.5") double benchmarkRate) {
|
||
LocalDate today = LocalDate.now();
|
||
List<Financing> all = financingRepo.findAll();
|
||
|
||
// --- 期限结构:按 endDate 计算剩余月数分桶 ---
|
||
Map<String, long[]> 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<TermBucket> termDist = termMap.entrySet().stream()
|
||
.map(e -> new TermBucket(e.getKey(), e.getValue()[0], e.getValue()[1] / 100.0))
|
||
.toList();
|
||
|
||
// --- 利率分布:按年化利率区间分桶 ---
|
||
Map<String, long[]> 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<RateBucket> 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<Map<String, Object>> highCost = all.stream()
|
||
.filter(f -> !List.of("已结清", "已驳回").contains(nvl(f.getStatus())))
|
||
.filter(f -> f.getRate() != null && f.getRate().doubleValue() > benchmarkRate)
|
||
.map(f -> {
|
||
Map<String, Object> 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<Map<String, Object>> calcIrr(@PathVariable Long id) {
|
||
Financing f = financingRepo.findById(id)
|
||
.orElseThrow(() -> new NotFoundException("融资台账不存在: " + id));
|
||
List<RepaymentPlan> plans = planRepo.findByFinancingIdOrderByPeriodNoAsc(id);
|
||
Map<String, Object> 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<List<LegalRiskEvent>> 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<LegalRiskEvent> 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<PmtCostAllocRule> 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();
|
||
}
|
||
}
|