package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.Account; import com.kaidi.oa.domain.Voucher; import com.kaidi.oa.repository.AccountRepository; 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.RequestParam; 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; /** * 财务部·账簿查询聚合(总账 / 明细账 / 科目余额表 / 多栏账 / 试算平衡表)。 * * 审计缺口:账簿查询(总账/明细账/科目余额表/多栏账)仅有余额字段无专用端点; * 试算平衡表(借贷双方合计相等校验)未实现。 * * 端点: * GET /general-ledger —— 总账(按会计科目汇总全部已过账凭证借贷合计,结合科目余额)。 * GET /detail-ledger —— 明细账(逐笔已过账凭证明细,支持 accountCode 过滤)。 * GET /trial-balance —— 试算平衡表(各科目期初余额/本期借方/本期贷方/期末余额, * 并给出借贷总计是否平衡 balanced=true/false)。 * GET /multi-column —— 多栏账(按科目聚合,每列一个摘要类别)。 * * 读口:AuthInterceptor SENSITIVE_READ_PREFIXES(/api/oa/fin-ledger) 限 ADMIN/APPROVER。 */ @RestController @RequestMapping("/api/oa/fin-ledger") public class FinLedgerController { private final AccountRepository accountRepo; private final VoucherRepository voucherRepo; public FinLedgerController(AccountRepository accountRepo, VoucherRepository voucherRepo) { this.accountRepo = accountRepo; this.voucherRepo = voucherRepo; } // ============================================================ // 1. 总账(General Ledger) // ============================================================ /** * 总账:每个科目汇总全部已过账凭证的借/贷合计,与科目表期末余额对照。 * 支持 period 前缀过滤(格式 yyyy-MM):只含对应月的已过账凭证。 */ @GetMapping("/general-ledger") public ApiResp> generalLedger(@RequestParam(required = false) String period) { List accounts = accountRepo.findAll(); List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); List> rows = new ArrayList<>(); BigDecimal totalDebitMov = BigDecimal.ZERO; BigDecimal totalCreditMov = BigDecimal.ZERO; BigDecimal totalBalance = BigDecimal.ZERO; for (Account acct : accounts) { // 凭证中借方/贷方科目含此科目代码则计入 BigDecimal debitMov = BigDecimal.ZERO; BigDecimal creditMov = BigDecimal.ZERO; for (Voucher v : posted) { BigDecimal amt = Money.nz(v.getAmount()); if (acct.getCode() != null && acct.getCode().equals(v.getDebitAccount())) { debitMov = debitMov.add(amt); } if (acct.getCode() != null && acct.getCode().equals(v.getCreditAccount())) { creditMov = creditMov.add(amt); } } BigDecimal balance = Money.nz(acct.getBalance()); Map row = new LinkedHashMap<>(); row.put("code", acct.getCode()); row.put("name", acct.getName()); row.put("category", acct.getCategory()); row.put("direction", acct.getDirection()); row.put("openingBalance", balance); row.put("debitMovement", debitMov); row.put("creditMovement", creditMov); // 期末余额:借方方向科目 = 期初 + 借方发生 - 贷方发生;贷方方向相反 BigDecimal closing; if ("贷".equals(acct.getDirection())) { closing = balance.subtract(debitMov).add(creditMov); } else { closing = balance.add(debitMov).subtract(creditMov); } row.put("closingBalance", closing.setScale(2, RoundingMode.HALF_UP)); rows.add(row); totalDebitMov = totalDebitMov.add(debitMov); totalCreditMov = totalCreditMov.add(creditMov); totalBalance = totalBalance.add(balance); } Map result = new LinkedHashMap<>(); result.put("reportName", "总账"); result.put("period", period != null ? period : "全部"); result.put("rows", rows); result.put("totalDebitMovement", totalDebitMov.setScale(2, RoundingMode.HALF_UP)); result.put("totalCreditMovement", totalCreditMov.setScale(2, RoundingMode.HALF_UP)); return ApiResp.ok(result); } // ============================================================ // 2. 明细账(Detail Ledger) // ============================================================ /** * 明细账:已过账凭证逐笔明细,支持 accountCode 过滤(含此科目的凭证)。 * 支持 period 前缀过滤。 */ @GetMapping("/detail-ledger") public ApiResp> detailLedger( @RequestParam(required = false) String accountCode, @RequestParam(required = false) String period) { List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); if (accountCode != null && !accountCode.isBlank()) { posted = posted.stream().filter(v -> accountCode.equals(v.getDebitAccount()) || accountCode.equals(v.getCreditAccount()) ).toList(); } List> rows = new ArrayList<>(); BigDecimal runningBalance = BigDecimal.ZERO; for (Voucher v : posted) { BigDecimal amt = Money.nz(v.getAmount()); boolean isDebit = accountCode == null || accountCode.equals(v.getDebitAccount()); if (isDebit) runningBalance = runningBalance.add(amt); else runningBalance = runningBalance.subtract(amt); Map row = new LinkedHashMap<>(); row.put("voucherId", v.getId()); row.put("voucherNo", v.getVoucherNo()); row.put("voucherDate", v.getVoucherDate()); row.put("summary", v.getSummary()); row.put("debitAccount", v.getDebitAccount()); row.put("creditAccount", v.getCreditAccount()); row.put("debitAmount", isDebit ? amt : BigDecimal.ZERO); row.put("creditAmount", isDebit ? BigDecimal.ZERO : amt); row.put("runningBalance", runningBalance.setScale(2, RoundingMode.HALF_UP)); row.put("isReversal", v.getIsReversal()); rows.add(row); } Map result = new LinkedHashMap<>(); result.put("reportName", "明细账"); result.put("accountCode", accountCode); result.put("period", period != null ? period : "全部"); result.put("rows", rows); result.put("totalEntries", rows.size()); return ApiResp.ok(result); } // ============================================================ // 3. 试算平衡表(Trial Balance) // ============================================================ /** * 试算平衡表:各科目期初余额 + 本期借贷发生,计算期末余额, * 汇总借方合计 = 贷方合计(balanced 布尔值)。 */ @GetMapping("/trial-balance") public ApiResp> trialBalance(@RequestParam(required = false) String period) { List accounts = accountRepo.findAll(); List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); List> rows = new ArrayList<>(); BigDecimal totalOpeningDebit = BigDecimal.ZERO; BigDecimal totalOpeningCredit = BigDecimal.ZERO; BigDecimal totalPeriodDebit = BigDecimal.ZERO; BigDecimal totalPeriodCredit = BigDecimal.ZERO; BigDecimal totalClosingDebit = BigDecimal.ZERO; BigDecimal totalClosingCredit = BigDecimal.ZERO; for (Account acct : accounts) { BigDecimal debitMov = BigDecimal.ZERO; BigDecimal creditMov = BigDecimal.ZERO; for (Voucher v : posted) { BigDecimal amt = Money.nz(v.getAmount()); if (acct.getCode() != null && acct.getCode().equals(v.getDebitAccount())) { debitMov = debitMov.add(amt); } if (acct.getCode() != null && acct.getCode().equals(v.getCreditAccount())) { creditMov = creditMov.add(amt); } } BigDecimal opening = Money.nz(acct.getBalance()); boolean isDebitDir = !"贷".equals(acct.getDirection()); BigDecimal openingDebit = isDebitDir ? opening : BigDecimal.ZERO; BigDecimal openingCredit = isDebitDir ? BigDecimal.ZERO : opening; BigDecimal closing = isDebitDir ? opening.add(debitMov).subtract(creditMov) : opening.subtract(debitMov).add(creditMov); closing = closing.setScale(2, RoundingMode.HALF_UP); BigDecimal closingDebit = isDebitDir && closing.signum() >= 0 ? closing : BigDecimal.ZERO; BigDecimal closingCredit = !isDebitDir && closing.signum() >= 0 ? closing : BigDecimal.ZERO; Map row = new LinkedHashMap<>(); row.put("code", acct.getCode()); row.put("name", acct.getName()); row.put("openingDebit", openingDebit.setScale(2, RoundingMode.HALF_UP)); row.put("openingCredit", openingCredit.setScale(2, RoundingMode.HALF_UP)); row.put("periodDebit", debitMov.setScale(2, RoundingMode.HALF_UP)); row.put("periodCredit", creditMov.setScale(2, RoundingMode.HALF_UP)); row.put("closingDebit", closingDebit.setScale(2, RoundingMode.HALF_UP)); row.put("closingCredit", closingCredit.setScale(2, RoundingMode.HALF_UP)); rows.add(row); totalOpeningDebit = totalOpeningDebit.add(openingDebit); totalOpeningCredit = totalOpeningCredit.add(openingCredit); totalPeriodDebit = totalPeriodDebit.add(debitMov); totalPeriodCredit = totalPeriodCredit.add(creditMov); totalClosingDebit = totalClosingDebit.add(closingDebit); totalClosingCredit = totalClosingCredit.add(closingCredit); } boolean balanced = totalPeriodDebit.compareTo(totalPeriodCredit) == 0; Map result = new LinkedHashMap<>(); result.put("reportName", "试算平衡表"); result.put("period", period != null ? period : "全部"); result.put("rows", rows); result.put("totalOpeningDebit", totalOpeningDebit.setScale(2, RoundingMode.HALF_UP)); result.put("totalOpeningCredit", totalOpeningCredit.setScale(2, RoundingMode.HALF_UP)); result.put("totalPeriodDebit", totalPeriodDebit.setScale(2, RoundingMode.HALF_UP)); result.put("totalPeriodCredit", totalPeriodCredit.setScale(2, RoundingMode.HALF_UP)); result.put("totalClosingDebit", totalClosingDebit.setScale(2, RoundingMode.HALF_UP)); result.put("totalClosingCredit", totalClosingCredit.setScale(2, RoundingMode.HALF_UP)); result.put("balanced", balanced); result.put("balanceDiff", totalPeriodDebit.subtract(totalPeriodCredit).setScale(2, RoundingMode.HALF_UP)); result.put("balanceNote", balanced ? "借贷平衡,试算通过" : "借贷不平衡,差额 " + totalPeriodDebit.subtract(totalPeriodCredit).setScale(2, RoundingMode.HALF_UP)); return ApiResp.ok(result); } // ============================================================ // 4. 科目余额表(Account Balance Summary) // ============================================================ @GetMapping("/account-balance") public ApiResp> accountBalance( @RequestParam(required = false) String category, @RequestParam(required = false) String period) { List accounts = accountRepo.findAll(); if (category != null && !category.isBlank()) { accounts = accounts.stream().filter(a -> category.equals(a.getCategory())).toList(); } List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); List> rows = new ArrayList<>(); BigDecimal total = BigDecimal.ZERO; for (Account acct : accounts) { BigDecimal debitMov = BigDecimal.ZERO; BigDecimal creditMov = BigDecimal.ZERO; for (Voucher v : posted) { BigDecimal amt = Money.nz(v.getAmount()); if (acct.getCode() != null && acct.getCode().equals(v.getDebitAccount())) debitMov = debitMov.add(amt); if (acct.getCode() != null && acct.getCode().equals(v.getCreditAccount())) creditMov = creditMov.add(amt); } BigDecimal opening = Money.nz(acct.getBalance()); boolean isDebitDir = !"贷".equals(acct.getDirection()); BigDecimal closing = isDebitDir ? opening.add(debitMov).subtract(creditMov) : opening.subtract(debitMov).add(creditMov); closing = closing.setScale(2, RoundingMode.HALF_UP); Map row = new LinkedHashMap<>(); row.put("code", acct.getCode()); row.put("name", acct.getName()); row.put("category", acct.getCategory()); row.put("level", acct.getLevel()); row.put("direction", acct.getDirection()); row.put("openingBalance", opening.setScale(2, RoundingMode.HALF_UP)); row.put("periodDebit", debitMov.setScale(2, RoundingMode.HALF_UP)); row.put("periodCredit", creditMov.setScale(2, RoundingMode.HALF_UP)); row.put("closingBalance", closing); rows.add(row); total = total.add(closing.abs()); } Map result = new LinkedHashMap<>(); result.put("reportName", "科目余额表"); result.put("period", period != null ? period : "全部"); result.put("category", category != null ? category : "全部"); result.put("rows", rows); result.put("totalAbsBalance", total.setScale(2, RoundingMode.HALF_UP)); return ApiResp.ok(result); } // ============================================================ // helpers // ============================================================ private List filterByPeriod(List vouchers, String period) { if (period == null || period.isBlank()) return vouchers; return vouchers.stream() .filter(v -> v.getVoucherDate() != null && v.getVoucherDate().startsWith(period)) .toList(); } }