恢复点(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>
308 lines
15 KiB
Java
308 lines
15 KiB
Java
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();
|
||
}
|
||
}
|