Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FinLedgerController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

308 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Map<String, Object>> generalLedger(@RequestParam(required = false) String period) {
List<Account> accounts = accountRepo.findAll();
List<Voucher> posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period);
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> detailLedger(
@RequestParam(required = false) String accountCode,
@RequestParam(required = false) String period) {
List<Voucher> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> trialBalance(@RequestParam(required = false) String period) {
List<Account> accounts = accountRepo.findAll();
List<Voucher> posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period);
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> accountBalance(
@RequestParam(required = false) String category,
@RequestParam(required = false) String period) {
List<Account> accounts = accountRepo.findAll();
if (category != null && !category.isBlank()) {
accounts = accounts.stream().filter(a -> category.equals(a.getCategory())).toList();
}
List<Voucher> posted = filterByPeriod(voucherRepo.findByStatus(Voucher.S_POSTED), period);
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Voucher> filterByPeriod(List<Voucher> vouchers, String period) {
if (period == null || period.isBlank()) return vouchers;
return vouchers.stream()
.filter(v -> v.getVoucherDate() != null
&& v.getVoucherDate().startsWith(period))
.toList();
}
}