package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.ArApItem; import com.kaidi.oa.domain.Contract; import com.kaidi.oa.domain.Payment; import com.kaidi.oa.domain.Voucher; import com.kaidi.oa.repository.ArApItemRepository; import com.kaidi.oa.repository.ContractRepository; import com.kaidi.oa.repository.PaymentRepository; import com.kaidi.oa.repository.VoucherRepository; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 财务多维看板(只读聚合)。把财务四类台账客户端汇总成「合同 / 资金支付 / 往来 / 凭证」多维: * - 合同维:合同额 / 已付 / 已开票 总额与回款率/开票率;按公司主体(companySubject)分维行; * - 资金维:付款单按状态(已付/待付)汇总金额与笔数; * - 往来维:ArApItem 应收/应付的总额、已核销、未核销; * - 凭证维:Voucher 按状态(草稿/已审核/已过账)的金额与笔数。 * * 不新建任何实体;结果走 {@link ApiResp} 信封。所有金额一律 {@link BigDecimal} + * {@link Money}(无裸 double 算钱),仅在出参边界 doubleValue() 落到 JSON。 * * 该端点跨表读合同额/付款/往来/凭证等机密财务数据,必须登记进 AuthInterceptor 的 * SENSITIVE_READ_PREFIXES(见 sharedFileSnippets),读侧收口到 ADMIN/APPROVER。 */ @RestController @RequestMapping("/api/oa/finance-dashboard") public class FinanceDashboardController { private final ContractRepository contractRepo; private final PaymentRepository paymentRepo; private final ArApItemRepository arApRepo; private final VoucherRepository voucherRepo; public FinanceDashboardController(ContractRepository contractRepo, PaymentRepository paymentRepo, ArApItemRepository arApRepo, VoucherRepository voucherRepo) { this.contractRepo = contractRepo; this.paymentRepo = paymentRepo; this.arApRepo = arApRepo; this.voucherRepo = voucherRepo; } // ---------- 合同维 ---------- public record SubjectRow(String subject, double amount, double paid, double invoiced, double paidRate, double invoicedRate, long count) { } public record ContractKpi(double amountTotal, double paidTotal, double invoicedTotal, double paidRate, double invoicedRate, long count, List bySubject) { } // ---------- 资金维 ---------- public record PaymentKpi(double paidTotal, long paidCount, double pendingTotal, long pendingCount, Map byStatus) { } // ---------- 往来维 ---------- public record ArApKpi(double arTotal, double arWrittenOff, double arOutstanding, double apTotal, double apWrittenOff, double apOutstanding) { } // ---------- 凭证维 ---------- public record VoucherKpi(long total, double amountTotal, Map amountByStatus, Map countByStatus) { } public record FinanceDashboard(ContractKpi contract, PaymentKpi payment, ArApKpi arAp, VoucherKpi voucher) { } @GetMapping public ApiResp dashboard() { return ApiResp.ok(new FinanceDashboard(contractKpi(), paymentKpi(), arApKpi(), voucherKpi())); } private static final class SubjectAcc { BigDecimal amount = BigDecimal.ZERO; BigDecimal paid = BigDecimal.ZERO; BigDecimal invoiced = BigDecimal.ZERO; long count = 0; } private ContractKpi contractKpi() { BigDecimal amountTotal = BigDecimal.ZERO; BigDecimal paidTotal = BigDecimal.ZERO; BigDecimal invoicedTotal = BigDecimal.ZERO; long count = 0; Map bySubject = new LinkedHashMap<>(); for (Contract c : contractRepo.findAll()) { count++; amountTotal = Money.add(amountTotal, c.getAmount()); paidTotal = Money.add(paidTotal, c.getPaidAmount()); invoicedTotal = Money.add(invoicedTotal, c.getInvoicedAmount()); String subject = c.getCompanySubject() == null || c.getCompanySubject().isBlank() ? "未指定主体" : c.getCompanySubject(); SubjectAcc acc = bySubject.computeIfAbsent(subject, k -> new SubjectAcc()); acc.amount = Money.add(acc.amount, c.getAmount()); acc.paid = Money.add(acc.paid, c.getPaidAmount()); acc.invoiced = Money.add(acc.invoiced, c.getInvoicedAmount()); acc.count++; } List rows = new ArrayList<>(); for (Map.Entry e : bySubject.entrySet()) { SubjectAcc a = e.getValue(); rows.add(new SubjectRow(e.getKey(), a.amount.doubleValue(), a.paid.doubleValue(), a.invoiced.doubleValue(), pct(a.paid, a.amount), pct(a.invoiced, a.amount), a.count)); } return new ContractKpi(amountTotal.doubleValue(), paidTotal.doubleValue(), invoicedTotal.doubleValue(), pct(paidTotal, amountTotal), pct(invoicedTotal, amountTotal), count, rows); } private PaymentKpi paymentKpi() { BigDecimal paidTotal = BigDecimal.ZERO; BigDecimal pendingTotal = BigDecimal.ZERO; long paidCount = 0, pendingCount = 0; Map byStatus = new LinkedHashMap<>(); for (Payment p : paymentRepo.findAll()) { String status = p.getStatus() == null || p.getStatus().isBlank() ? "未知" : p.getStatus(); byStatus.merge(status, Money.nz(p.getAmount()), Money::add); if ("已付".equals(status)) { paidTotal = Money.add(paidTotal, p.getAmount()); paidCount++; } else if ("待付".equals(status)) { pendingTotal = Money.add(pendingTotal, p.getAmount()); pendingCount++; } } return new PaymentKpi(paidTotal.doubleValue(), paidCount, pendingTotal.doubleValue(), pendingCount, toDoubleMap(byStatus)); } private ArApKpi arApKpi() { BigDecimal arTotal = BigDecimal.ZERO, arOff = BigDecimal.ZERO, arOut = BigDecimal.ZERO; BigDecimal apTotal = BigDecimal.ZERO, apOff = BigDecimal.ZERO, apOut = BigDecimal.ZERO; for (ArApItem it : arApRepo.findAll()) { boolean ar = ArApItem.T_AR.equals(it.getArApType()); if (ar) { arTotal = Money.add(arTotal, it.getAmount()); arOff = Money.add(arOff, it.getWrittenOff()); arOut = Money.add(arOut, it.getUnwrittenOff()); } else { apTotal = Money.add(apTotal, it.getAmount()); apOff = Money.add(apOff, it.getWrittenOff()); apOut = Money.add(apOut, it.getUnwrittenOff()); } } return new ArApKpi(arTotal.doubleValue(), arOff.doubleValue(), arOut.doubleValue(), apTotal.doubleValue(), apOff.doubleValue(), apOut.doubleValue()); } private VoucherKpi voucherKpi() { long total = 0; BigDecimal amountTotal = BigDecimal.ZERO; Map amountByStatus = new LinkedHashMap<>(); Map countByStatus = new LinkedHashMap<>(); for (Voucher v : voucherRepo.findAll()) { total++; amountTotal = Money.add(amountTotal, v.getAmount()); String status = normalizeVoucherStatus(v.getStatus()); amountByStatus.merge(status, Money.nz(v.getAmount()), Money::add); countByStatus.merge(status, 1L, Long::sum); } return new VoucherKpi(total, amountTotal.doubleValue(), toDoubleMap(amountByStatus), countByStatus); } // ---------- helpers ---------- /** 兼容历史旧值:暂存→草稿、已记账→已过账(与 VoucherController.normalizeStatus 同口径)。 */ private static String normalizeVoucherStatus(String raw) { if (raw == null || raw.isBlank()) { return Voucher.S_DRAFT; } if ("暂存".equals(raw)) { return Voucher.S_DRAFT; } if ("已记账".equals(raw)) { return Voucher.S_POSTED; } return raw; } /** a/b 百分比(保留 1 位),b 为 0 返回 0;全程 BigDecimal 不裸 double 算比率。 */ private static double pct(BigDecimal a, BigDecimal b) { BigDecimal denom = Money.nz(b); if (denom.signum() == 0) { return 0; } return Money.nz(a).divide(denom, 4, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)) .setScale(1, RoundingMode.HALF_UP) .doubleValue(); } private static Map toDoubleMap(Map src) { Map out = new LinkedHashMap<>(); for (Map.Entry e : src.entrySet()) { out.put(e.getKey(), e.getValue().doubleValue()); } return out; } }