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