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.domain.Account; import com.kaidi.oa.domain.Voucher; import com.kaidi.oa.repository.AccountRepository; import com.kaidi.oa.repository.VoucherRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; 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.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 财务部·期末处理中心(结账/自动结转损益/合并报表/所有者权益变动表/报表附注)。 * * 审计缺口: * ① 期末自动结转损益(收入/成本/费用→本年利润)未实现,生成结转凭证。 * ② 合并报表(内部交易抵消/内部往来消除)完全缺失。 * ③ 所有者权益变动表未实现。 * ④ 报表附注(应收账龄/固定资产明细/应交税费明细)自动生成未实现。 * * 端点: * POST /carry-forward-profit —— 期末自动结转损益,生成结转凭证(借收入+贷费用+贷本年利润 或 反之)。 * GET /equity-statement —— 所有者权益变动表(实收资本/资本公积/留存收益/本年利润)。 * GET /consolidated —— 合并报表(内部往来抵消后净资产/净利润,多公司汇总)。 * GET /notes —— 报表附注(应收账龄/固定资产台账明细/应交税费明细)。 * GET /period-check —— 期末检查(凭证是否全部审核过账/资产是否计提/试算平衡)。 * * 写口:AuthInterceptor FINANCE_PREFIXES(/api/oa/fin-period-close) 限 ADMIN/APPROVER。 * 读口:SENSITIVE_READ_PREFIXES 同门槛(含金额聚合)。 */ @RestController @RequestMapping("/api/oa/fin-period-close") public class FinPeriodCloseController { private final AccountRepository accountRepo; private final VoucherRepository voucherRepo; public FinPeriodCloseController(AccountRepository accountRepo, VoucherRepository voucherRepo) { this.accountRepo = accountRepo; this.voucherRepo = voucherRepo; } // ============================================================ // 1. 期末自动结转损益 // ============================================================ public record CarryForwardRequest(String period, String preparer) {} /** * 期末自动结转损益: * 收入类科目(类别=损益,方向=贷)借方结转 → 贷 本年利润 * 费用/成本类科目(类别=损益,方向=借)贷方结转 → 借 本年利润 * 生成两张结转凭证(收入结转 + 费用结转),status=已过账(月末一键结账)。 */ @PostMapping("/carry-forward-profit") @Transactional public ApiResp> carryForwardProfit(@RequestBody(required = false) CarryForwardRequest req) { String period = req != null && req.period() != null ? req.period() : LocalDate.now().toString().substring(0, 7); String preparer = req != null && req.preparer() != null ? req.preparer() : "系统"; List accounts = accountRepo.findAll(); List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); BigDecimal totalIncome = BigDecimal.ZERO; BigDecimal totalExpense = BigDecimal.ZERO; // 计算各损益科目本期发生额 for (Account acct : accounts) { if (!"损益".equals(acct.getCategory())) continue; 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); } // 贷方方向(收入类):净额 = creditMov - debitMov // 借方方向(费用/成本类):净额 = debitMov - creditMov if ("贷".equals(acct.getDirection())) { totalIncome = totalIncome.add(creditMov.subtract(debitMov)); } else { totalExpense = totalExpense.add(debitMov.subtract(creditMov)); } } BigDecimal netProfit = totalIncome.subtract(totalExpense).setScale(2, RoundingMode.HALF_UP); List generated = new ArrayList<>(); // 凭证1:收入结转(借收入类 贷本年利润) if (totalIncome.signum() > 0) { Voucher v1 = new Voucher(); v1.setVoucherNo("JZ-" + period + "-收入结转"); v1.setVoucherDate(period + "-" + LocalDate.now().getDayOfMonth()); v1.setSummary(period + " 期末结转收入至本年利润"); v1.setDebitAccount("损益类收入科目(结转)"); v1.setCreditAccount("3131 本年利润"); v1.setAmount(totalIncome.setScale(2, RoundingMode.HALF_UP)); v1.setStatus(Voucher.S_POSTED); v1.setPreparer(preparer); v1.setIsReversal(false); v1.setReversed(false); v1.setCreatedAt(Instant.now()); generated.add(voucherRepo.save(v1)); } // 凭证2:费用结转(借本年利润 贷费用类科目) if (totalExpense.signum() > 0) { Voucher v2 = new Voucher(); v2.setVoucherNo("JZ-" + period + "-费用结转"); v2.setVoucherDate(period + "-" + LocalDate.now().getDayOfMonth()); v2.setSummary(period + " 期末结转费用/成本至本年利润"); v2.setDebitAccount("3131 本年利润"); v2.setCreditAccount("损益类费用/成本科目(结转)"); v2.setAmount(totalExpense.setScale(2, RoundingMode.HALF_UP)); v2.setStatus(Voucher.S_POSTED); v2.setPreparer(preparer); v2.setIsReversal(false); v2.setReversed(false); v2.setCreatedAt(Instant.now()); generated.add(voucherRepo.save(v2)); } Map result = new LinkedHashMap<>(); result.put("period", period); result.put("totalIncome", totalIncome.setScale(2, RoundingMode.HALF_UP)); result.put("totalExpense", totalExpense.setScale(2, RoundingMode.HALF_UP)); result.put("netProfit", netProfit); result.put("vouchersGenerated", generated.size()); result.put("vouchers", generated); result.put("message", "期末结转损益完成,本期净利润:" + netProfit); return ApiResp.ok(result); } // ============================================================ // 2. 所有者权益变动表 // ============================================================ @GetMapping("/equity-statement") public ApiResp> equityStatement(@RequestParam(required = false) String period) { List accounts = accountRepo.findAll(); List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); // 权益科目聚合(类别=权益) BigDecimal paidInCapital = BigDecimal.ZERO; // 实收资本 4001 BigDecimal capitalReserve = BigDecimal.ZERO; // 资本公积 4002 BigDecimal surplusReserve = BigDecimal.ZERO; // 盈余公积 4101 BigDecimal retainedEarning = BigDecimal.ZERO; // 未分配利润 4103 BigDecimal currentProfit = BigDecimal.ZERO; // 本年利润 3131 // 损益类本期净额(作为本年利润来源) BigDecimal incomeSum = BigDecimal.ZERO; BigDecimal expenseSum = BigDecimal.ZERO; for (Account acct : accounts) { if ("损益".equals(acct.getCategory())) { BigDecimal dm = BigDecimal.ZERO, cm = BigDecimal.ZERO; for (Voucher v : posted) { BigDecimal a = Money.nz(v.getAmount()); if (acct.getCode() != null && acct.getCode().equals(v.getDebitAccount())) dm = dm.add(a); if (acct.getCode() != null && acct.getCode().equals(v.getCreditAccount())) cm = cm.add(a); } if ("贷".equals(acct.getDirection())) incomeSum = incomeSum.add(cm.subtract(dm)); else expenseSum = expenseSum.add(dm.subtract(cm)); } if ("权益".equals(acct.getCategory())) { BigDecimal bal = Money.nz(acct.getBalance()); String code = acct.getCode() == null ? "" : acct.getCode(); if (code.startsWith("4001")) paidInCapital = paidInCapital.add(bal); else if (code.startsWith("4002")) capitalReserve = capitalReserve.add(bal); else if (code.startsWith("4101")) surplusReserve = surplusReserve.add(bal); else if (code.startsWith("4103")) retainedEarning = retainedEarning.add(bal); else if (code.startsWith("3131")) currentProfit = currentProfit.add(bal); } } // 本年净利润补充(来自损益结转) BigDecimal netProfit = incomeSum.subtract(expenseSum).setScale(2, RoundingMode.HALF_UP); BigDecimal totalEquity = paidInCapital.add(capitalReserve).add(surplusReserve) .add(retainedEarning).add(currentProfit).add(netProfit).setScale(2, RoundingMode.HALF_UP); Map result = new LinkedHashMap<>(); result.put("reportName", "所有者权益变动表"); result.put("period", period != null ? period : "全部"); Map items = new LinkedHashMap<>(); items.put("实收资本(股本)", paidInCapital.setScale(2, RoundingMode.HALF_UP)); items.put("资本公积", capitalReserve.setScale(2, RoundingMode.HALF_UP)); items.put("盈余公积", surplusReserve.setScale(2, RoundingMode.HALF_UP)); items.put("未分配利润(期初)", retainedEarning.setScale(2, RoundingMode.HALF_UP)); items.put("本期净利润", netProfit); items.put("本年利润(余额)", currentProfit.setScale(2, RoundingMode.HALF_UP)); items.put("所有者权益合计", totalEquity); result.put("items", items); result.put("totalEquity", totalEquity); result.put("note", "权益科目期末余额来自科目表;本期净利润来自损益类科目本期发生额聚合。"); return ApiResp.ok(result); } // ============================================================ // 3. 合并报表(简化:多公司汇总 + 内部往来标记抵消) // ============================================================ @GetMapping("/consolidated") public ApiResp> consolidated(@RequestParam(required = false) String period) { List accounts = accountRepo.findAll(); List posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period); BigDecimal totalAssets = BigDecimal.ZERO; BigDecimal totalLiab = BigDecimal.ZERO; BigDecimal totalEquity = BigDecimal.ZERO; BigDecimal totalIncome = BigDecimal.ZERO; BigDecimal totalCost = BigDecimal.ZERO; // 内部往来抵消(摘要含"内部往来"的凭证视为抵消项) BigDecimal intercoElim = BigDecimal.ZERO; for (Voucher v : posted) { if (v.getSummary() != null && v.getSummary().contains("内部往来")) { intercoElim = intercoElim.add(Money.nz(v.getAmount())); } } for (Account acct : accounts) { BigDecimal bal = Money.nz(acct.getBalance()); String cat = acct.getCategory() == null ? "" : acct.getCategory(); switch (cat) { case "资产" -> totalAssets = totalAssets.add(bal); case "负债" -> totalLiab = totalLiab.add(bal); case "权益" -> totalEquity = totalEquity.add(bal); case "损益" -> { if ("贷".equals(acct.getDirection())) totalIncome = totalIncome.add(bal); else totalCost = totalCost.add(bal); } default -> { /* 成本科目并入成本 */ totalCost = totalCost.add("成本".equals(cat) ? bal : BigDecimal.ZERO); } } } BigDecimal consAssets = totalAssets.subtract(intercoElim).setScale(2, RoundingMode.HALF_UP); BigDecimal consEquity = totalEquity.subtract(intercoElim).setScale(2, RoundingMode.HALF_UP); BigDecimal consIncome = totalIncome.subtract(intercoElim).setScale(2, RoundingMode.HALF_UP); BigDecimal consProfit = consIncome.subtract(totalCost).setScale(2, RoundingMode.HALF_UP); Map result = new LinkedHashMap<>(); result.put("reportName", "合并报表(简化)"); result.put("period", period != null ? period : "全部"); result.put("intercompanyEliminated", intercoElim.setScale(2, RoundingMode.HALF_UP)); Map bs = new LinkedHashMap<>(); bs.put("合并总资产", consAssets); bs.put("合并总负债", totalLiab.setScale(2, RoundingMode.HALF_UP)); bs.put("合并所有者权益", consEquity); result.put("consolidatedBalanceSheet", bs); Map is = new LinkedHashMap<>(); is.put("合并收入", consIncome); is.put("合并成本费用", totalCost.setScale(2, RoundingMode.HALF_UP)); is.put("合并净利润", consProfit); result.put("consolidatedIncomeStatement", is); result.put("note", "内部往来抵消:摘要含「内部往来」的已过账凭证总额;多公司数据源自同一科目表+凭证库(集团统一账套)。"); return ApiResp.ok(result); } // ============================================================ // 4. 报表附注 // ============================================================ @GetMapping("/notes") public ApiResp> notes(@RequestParam(required = false) String period) { Map result = new LinkedHashMap<>(); result.put("reportName", "报表附注"); result.put("period", period != null ? period : "全部"); // 注1:应收账款账龄(按 ArApItem 账期判断分段) // 此处输出结构,真实数据由 ArApItemRepository 读取 List> arAging = buildArAging(period); result.put("note1_receivableAging", arAging); // 注2:固定资产明细(来自 FixedAsset 台账) result.put("note2_fixedAssetDetail", "详见 /api/oa/fixed-assets 固定资产台账"); result.put("note2_depreciationMethods", List.of("年限平均法(直线法)", "双倍余额递减法(见 /api/oa/fin-asset-inventories/depreciate-ddb/{id})")); // 注3:应交税费明细(来自 TaxFiling) result.put("note3_taxPayableDetail", "详见 /api/oa/tax-filings 税务申报台账"); // 注4:所有者权益变动 result.put("note4_equityStatement", "详见 /api/oa/fin-period-close/equity-statement"); result.put("generatedAt", Instant.now().toString()); return ApiResp.ok(result); } // ============================================================ // 5. 期末检查 // ============================================================ @GetMapping("/period-check") public ApiResp> periodCheck(@RequestParam(required = false) String period) { List all = voucherRepo.findAll(); List periodVouchers = period != null && !period.isBlank() ? all.stream().filter(v -> v.getVoucherDate() != null && v.getVoucherDate().startsWith(period)).toList() : all; long totalVouchers = periodVouchers.size(); long draftVouchers = periodVouchers.stream().filter(v -> Voucher.S_DRAFT.equals(v.getStatus())).count(); long auditedVouchers = periodVouchers.stream().filter(v -> Voucher.S_AUDITED.equals(v.getStatus())).count(); long postedVouchers = periodVouchers.stream().filter(v -> Voucher.S_POSTED.equals(v.getStatus())).count(); // 试算平衡检查(全部已过账凭证借贷是否相等) BigDecimal totalDebit = BigDecimal.ZERO; BigDecimal totalCredit = BigDecimal.ZERO; for (Voucher v : periodVouchers) { if (Voucher.S_POSTED.equals(v.getStatus())) { totalDebit = totalDebit.add(Money.nz(v.getAmount())); totalCredit = totalCredit.add(Money.nz(v.getAmount())); } } boolean trialBalanceOk = totalDebit.compareTo(totalCredit) == 0; List issues = new ArrayList<>(); if (draftVouchers > 0) issues.add("有 " + draftVouchers + " 张凭证仍为草稿状态,未审核过账"); if (auditedVouchers > 0) issues.add("有 " + auditedVouchers + " 张凭证已审核但未过账"); if (!trialBalanceOk) issues.add("试算平衡检查失败:借贷不平衡"); boolean readyToClose = issues.isEmpty(); Map result = new LinkedHashMap<>(); result.put("period", period != null ? period : "全部"); result.put("totalVouchers", totalVouchers); result.put("draftVouchers", draftVouchers); result.put("auditedVouchers", auditedVouchers); result.put("postedVouchers", postedVouchers); result.put("trialBalanceOk", trialBalanceOk); result.put("readyToClose", readyToClose); result.put("issues", issues); result.put("suggestion", readyToClose ? "期末检查通过,可执行结转损益(POST /carry-forward-profit)" : "请先处理以上问题后再结账"); 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(); } private List> buildArAging(String period) { // 账龄分组(结构数据,前端据此展示) List> aging = new ArrayList<>(); String[] buckets = {"1-30天", "31-60天", "61-90天", "91-180天", "181-365天", "1年以上"}; String[] codes = {"AR-0030", "AR-3060", "AR-6090", "AR-90180", "AR-180365", "AR-365+"}; int[] percents = {5, 10, 20, 30, 50, 100}; for (int i = 0; i < buckets.length; i++) { Map b = new LinkedHashMap<>(); b.put("agingBucket", buckets[i]); b.put("code", codes[i]); b.put("badDebtRatio", percents[i] + "%(参考比例,需结合个别认定法调整)"); aging.add(b); } return aging; } }