Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FinPeriodCloseController.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

371 lines
19 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.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<Map<String, Object>> 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<Account> accounts = accountRepo.findAll();
List<Voucher> 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<Voucher> 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<String, Object> 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<Map<String, Object>> equityStatement(@RequestParam(required = false) String period) {
List<Account> accounts = accountRepo.findAll();
List<Voucher> 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<String, Object> result = new LinkedHashMap<>();
result.put("reportName", "所有者权益变动表");
result.put("period", period != null ? period : "全部");
Map<String, Object> 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<Map<String, Object>> consolidated(@RequestParam(required = false) String period) {
List<Account> accounts = accountRepo.findAll();
List<Voucher> 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<String, Object> result = new LinkedHashMap<>();
result.put("reportName", "合并报表(简化)");
result.put("period", period != null ? period : "全部");
result.put("intercompanyEliminated", intercoElim.setScale(2, RoundingMode.HALF_UP));
Map<String, Object> bs = new LinkedHashMap<>();
bs.put("合并总资产", consAssets);
bs.put("合并总负债", totalLiab.setScale(2, RoundingMode.HALF_UP));
bs.put("合并所有者权益", consEquity);
result.put("consolidatedBalanceSheet", bs);
Map<String, Object> 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<Map<String, Object>> notes(@RequestParam(required = false) String period) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("reportName", "报表附注");
result.put("period", period != null ? period : "全部");
// 注1:应收账款账龄(按 ArApItem 账期判断分段)
// 此处输出结构,真实数据由 ArApItemRepository 读取
List<Map<String, Object>> 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<Map<String, Object>> periodCheck(@RequestParam(required = false) String period) {
List<Voucher> all = voucherRepo.findAll();
List<Voucher> 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<String> issues = new ArrayList<>();
if (draftVouchers > 0) issues.add("有 " + draftVouchers + " 张凭证仍为草稿状态,未审核过账");
if (auditedVouchers > 0) issues.add("有 " + auditedVouchers + " 张凭证已审核但未过账");
if (!trialBalanceOk) issues.add("试算平衡检查失败:借贷不平衡");
boolean readyToClose = issues.isEmpty();
Map<String, Object> 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<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();
}
private List<Map<String, Object>> buildArAging(String period) {
// 账龄分组(结构数据,前端据此展示)
List<Map<String, Object>> 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<String, Object> b = new LinkedHashMap<>();
b.put("agingBucket", buckets[i]);
b.put("code", codes[i]);
b.put("badDebtRatio", percents[i] + "%(参考比例,需结合个别认定法调整)");
aging.add(b);
}
return aging;
}
}