恢复点(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>
371 lines
19 KiB
Java
371 lines
19 KiB
Java
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;
|
||
}
|
||
}
|