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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,223 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.domain.ArApItem;
import com.kaidi.oa.domain.Contract;
import com.kaidi.oa.domain.Payment;
import com.kaidi.oa.domain.Voucher;
import com.kaidi.oa.repository.ArApItemRepository;
import com.kaidi.oa.repository.ContractRepository;
import com.kaidi.oa.repository.PaymentRepository;
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.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;
/**
* 财务多维看板(只读聚合)。把财务四类台账客户端汇总成「合同 / 资金支付 / 往来 / 凭证」多维:
* - 合同维:合同额 / 已付 / 已开票 总额与回款率/开票率;按公司主体(companySubject)分维行;
* - 资金维:付款单按状态(已付/待付)汇总金额与笔数;
* - 往来维:ArApItem 应收/应付的总额、已核销、未核销;
* - 凭证维:Voucher 按状态(草稿/已审核/已过账)的金额与笔数。
*
* 不新建任何实体;结果走 {@link ApiResp} 信封。所有金额一律 {@link BigDecimal} +
* {@link Money}(无裸 double 算钱),仅在出参边界 doubleValue() 落到 JSON。
*
* 该端点跨表读合同额/付款/往来/凭证等机密财务数据,必须登记进 AuthInterceptor 的
* SENSITIVE_READ_PREFIXES(见 sharedFileSnippets),读侧收口到 ADMIN/APPROVER。
*/
@RestController
@RequestMapping("/api/oa/finance-dashboard")
public class FinanceDashboardController {
private final ContractRepository contractRepo;
private final PaymentRepository paymentRepo;
private final ArApItemRepository arApRepo;
private final VoucherRepository voucherRepo;
public FinanceDashboardController(ContractRepository contractRepo,
PaymentRepository paymentRepo,
ArApItemRepository arApRepo,
VoucherRepository voucherRepo) {
this.contractRepo = contractRepo;
this.paymentRepo = paymentRepo;
this.arApRepo = arApRepo;
this.voucherRepo = voucherRepo;
}
// ---------- 合同维 ----------
public record SubjectRow(String subject, double amount, double paid, double invoiced,
double paidRate, double invoicedRate, long count) {
}
public record ContractKpi(double amountTotal, double paidTotal, double invoicedTotal,
double paidRate, double invoicedRate, long count,
List<SubjectRow> bySubject) {
}
// ---------- 资金维 ----------
public record PaymentKpi(double paidTotal, long paidCount, double pendingTotal, long pendingCount,
Map<String, Double> byStatus) {
}
// ---------- 往来维 ----------
public record ArApKpi(double arTotal, double arWrittenOff, double arOutstanding,
double apTotal, double apWrittenOff, double apOutstanding) {
}
// ---------- 凭证维 ----------
public record VoucherKpi(long total, double amountTotal, Map<String, Double> amountByStatus,
Map<String, Long> countByStatus) {
}
public record FinanceDashboard(ContractKpi contract, PaymentKpi payment,
ArApKpi arAp, VoucherKpi voucher) {
}
@GetMapping
public ApiResp<FinanceDashboard> dashboard() {
return ApiResp.ok(new FinanceDashboard(contractKpi(), paymentKpi(), arApKpi(), voucherKpi()));
}
private static final class SubjectAcc {
BigDecimal amount = BigDecimal.ZERO;
BigDecimal paid = BigDecimal.ZERO;
BigDecimal invoiced = BigDecimal.ZERO;
long count = 0;
}
private ContractKpi contractKpi() {
BigDecimal amountTotal = BigDecimal.ZERO;
BigDecimal paidTotal = BigDecimal.ZERO;
BigDecimal invoicedTotal = BigDecimal.ZERO;
long count = 0;
Map<String, SubjectAcc> bySubject = new LinkedHashMap<>();
for (Contract c : contractRepo.findAll()) {
count++;
amountTotal = Money.add(amountTotal, c.getAmount());
paidTotal = Money.add(paidTotal, c.getPaidAmount());
invoicedTotal = Money.add(invoicedTotal, c.getInvoicedAmount());
String subject = c.getCompanySubject() == null || c.getCompanySubject().isBlank()
? "未指定主体" : c.getCompanySubject();
SubjectAcc acc = bySubject.computeIfAbsent(subject, k -> new SubjectAcc());
acc.amount = Money.add(acc.amount, c.getAmount());
acc.paid = Money.add(acc.paid, c.getPaidAmount());
acc.invoiced = Money.add(acc.invoiced, c.getInvoicedAmount());
acc.count++;
}
List<SubjectRow> rows = new ArrayList<>();
for (Map.Entry<String, SubjectAcc> e : bySubject.entrySet()) {
SubjectAcc a = e.getValue();
rows.add(new SubjectRow(e.getKey(), a.amount.doubleValue(), a.paid.doubleValue(),
a.invoiced.doubleValue(), pct(a.paid, a.amount), pct(a.invoiced, a.amount), a.count));
}
return new ContractKpi(amountTotal.doubleValue(), paidTotal.doubleValue(),
invoicedTotal.doubleValue(), pct(paidTotal, amountTotal),
pct(invoicedTotal, amountTotal), count, rows);
}
private PaymentKpi paymentKpi() {
BigDecimal paidTotal = BigDecimal.ZERO;
BigDecimal pendingTotal = BigDecimal.ZERO;
long paidCount = 0, pendingCount = 0;
Map<String, BigDecimal> byStatus = new LinkedHashMap<>();
for (Payment p : paymentRepo.findAll()) {
String status = p.getStatus() == null || p.getStatus().isBlank() ? "未知" : p.getStatus();
byStatus.merge(status, Money.nz(p.getAmount()), Money::add);
if ("已付".equals(status)) {
paidTotal = Money.add(paidTotal, p.getAmount());
paidCount++;
} else if ("待付".equals(status)) {
pendingTotal = Money.add(pendingTotal, p.getAmount());
pendingCount++;
}
}
return new PaymentKpi(paidTotal.doubleValue(), paidCount, pendingTotal.doubleValue(),
pendingCount, toDoubleMap(byStatus));
}
private ArApKpi arApKpi() {
BigDecimal arTotal = BigDecimal.ZERO, arOff = BigDecimal.ZERO, arOut = BigDecimal.ZERO;
BigDecimal apTotal = BigDecimal.ZERO, apOff = BigDecimal.ZERO, apOut = BigDecimal.ZERO;
for (ArApItem it : arApRepo.findAll()) {
boolean ar = ArApItem.T_AR.equals(it.getArApType());
if (ar) {
arTotal = Money.add(arTotal, it.getAmount());
arOff = Money.add(arOff, it.getWrittenOff());
arOut = Money.add(arOut, it.getUnwrittenOff());
} else {
apTotal = Money.add(apTotal, it.getAmount());
apOff = Money.add(apOff, it.getWrittenOff());
apOut = Money.add(apOut, it.getUnwrittenOff());
}
}
return new ArApKpi(arTotal.doubleValue(), arOff.doubleValue(), arOut.doubleValue(),
apTotal.doubleValue(), apOff.doubleValue(), apOut.doubleValue());
}
private VoucherKpi voucherKpi() {
long total = 0;
BigDecimal amountTotal = BigDecimal.ZERO;
Map<String, BigDecimal> amountByStatus = new LinkedHashMap<>();
Map<String, Long> countByStatus = new LinkedHashMap<>();
for (Voucher v : voucherRepo.findAll()) {
total++;
amountTotal = Money.add(amountTotal, v.getAmount());
String status = normalizeVoucherStatus(v.getStatus());
amountByStatus.merge(status, Money.nz(v.getAmount()), Money::add);
countByStatus.merge(status, 1L, Long::sum);
}
return new VoucherKpi(total, amountTotal.doubleValue(), toDoubleMap(amountByStatus), countByStatus);
}
// ---------- helpers ----------
/** 兼容历史旧值:暂存→草稿、已记账→已过账(与 VoucherController.normalizeStatus 同口径)。 */
private static String normalizeVoucherStatus(String raw) {
if (raw == null || raw.isBlank()) {
return Voucher.S_DRAFT;
}
if ("暂存".equals(raw)) {
return Voucher.S_DRAFT;
}
if ("已记账".equals(raw)) {
return Voucher.S_POSTED;
}
return raw;
}
/** a/b 百分比(保留 1 位),b 为 0 返回 0;全程 BigDecimal 不裸 double 算比率。 */
private static double pct(BigDecimal a, BigDecimal b) {
BigDecimal denom = Money.nz(b);
if (denom.signum() == 0) {
return 0;
}
return Money.nz(a).divide(denom, 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(1, RoundingMode.HALF_UP)
.doubleValue();
}
private static Map<String, Double> toDoubleMap(Map<String, BigDecimal> src) {
Map<String, Double> out = new LinkedHashMap<>();
for (Map.Entry<String, BigDecimal> e : src.entrySet()) {
out.put(e.getKey(), e.getValue().doubleValue());
}
return out;
}
}