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,147 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.RdExpense;
import com.kaidi.oa.domain.RdProject;
import com.kaidi.oa.repository.RdExpenseRepository;
import com.kaidi.oa.repository.RdProjectRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 研发费用归集·证据链(数据中心·自动取数)。按 rdProjectId 聚合该研发项目的全部研发费用,
* 列出每一笔费用 + 其来源单据引用(凭证号/发票/工时单 voucher),并按费用类型形成证据链汇总
* (项目 / 费用类型 / 金额合计 / 笔数),让「研发立项-费用-凭证」证据链一眼可查、可自动核对。
*
* 纯只读聚合:参考 {@code FundPoolController}/{@code EvidenceController} 跨表聚合不建实体,
* 金额一律走 {@link Money}BigDecimal)。该端点端出研发费用金额+凭证明细,属机密财务读,
* 仅 GET,读口须经 AuthInterceptor 敏感读门槛——见 sharedFileSnippets 将
* {@code /api/oa/rd-expense-traces} 登记进 SENSITIVE_READ_PREFIXES。
*/
@RestController
@RequestMapping("/api/oa/rd-expense-traces")
public class RdExpenseTraceController {
private final RdExpenseRepository rdExpenseRepo;
private final RdProjectRepository rdProjectRepo;
public RdExpenseTraceController(RdExpenseRepository rdExpenseRepo,
RdProjectRepository rdProjectRepo) {
this.rdExpenseRepo = rdExpenseRepo;
this.rdProjectRepo = rdProjectRepo;
}
/** 单笔费用的证据行(费用明细 + 来源单据引用)。 */
public record EvidenceLine(Long expenseId, String category, String description,
double amount, String occurDate, String voucher,
String status, String recorder) {
}
/** 按费用类型的汇总行(费用类型 / 金额合计 / 笔数)。 */
public record CategorySummary(String category, double amount, int count) {
}
/** 研发项目维度的项目引用。 */
public record ProjectRef(Long id, String name, String code) {
}
/** 一个研发项目的证据链:项目引用 + 逐笔证据行 + 按类型汇总 + 金额合计 + 总笔数。 */
public record ExpenseTrace(ProjectRef project, List<EvidenceLine> lines,
List<CategorySummary> byCategory, double totalAmount, int lineCount) {
}
/** 跨全部研发项目的证据链总览(看板用)。 */
public record TraceOverview(List<ExpenseTrace> traces, double totalAmount, int lineCount) {
}
private static final class CatAcc {
BigDecimal amount = BigDecimal.ZERO;
int count = 0;
}
/**
* 单个研发项目的费用证据链。
* GET /api/oa/rd-expense-traces/{rdProjectId}
*/
@GetMapping("/{rdProjectId}")
public ApiResp<ExpenseTrace> trace(@PathVariable Long rdProjectId) {
RdProject project = rdProjectRepo.findById(rdProjectId)
.orElseThrow(() -> new NotFoundException("研发项目不存在:" + rdProjectId));
ExpenseTrace trace = buildTrace(new ProjectRef(project.getId(), project.getName(),
project.getCode()), rdExpenseRepo.findByRdProjectId(rdProjectId));
return ApiResp.ok(trace);
}
/**
* 全部研发项目的费用证据链总览(看板)。逐项目聚合,附跨项目金额合计与总笔数。
* GET /api/oa/rd-expense-traces
*/
@GetMapping
public ApiResp<TraceOverview> overview() {
// 先把费用按项目分桶(插入序固定行位)。
Map<Long, List<RdExpense>> byProject = new LinkedHashMap<>();
for (RdExpense e : rdExpenseRepo.findAll()) {
byProject.computeIfAbsent(e.getRdProjectId(), k -> new ArrayList<>()).add(e);
}
// 项目名/编码以 RdProject 主档为准;费用上的 rdProjectName 仅作回退(项目已删时)。
Map<Long, RdProject> projects = new LinkedHashMap<>();
for (RdProject p : rdProjectRepo.findAll()) {
projects.put(p.getId(), p);
}
List<ExpenseTrace> traces = new ArrayList<>();
BigDecimal grandTotal = BigDecimal.ZERO;
int grandLines = 0;
for (Map.Entry<Long, List<RdExpense>> entry : byProject.entrySet()) {
Long pid = entry.getKey();
List<RdExpense> expenses = entry.getValue();
ProjectRef ref;
RdProject p = pid == null ? null : projects.get(pid);
if (p != null) {
ref = new ProjectRef(p.getId(), p.getName(), p.getCode());
} else {
String fallbackName = expenses.isEmpty() ? null : expenses.get(0).getRdProjectName();
ref = new ProjectRef(pid, fallbackName, null);
}
ExpenseTrace trace = buildTrace(ref, expenses);
traces.add(trace);
grandTotal = Money.add(grandTotal, BigDecimal.valueOf(trace.totalAmount()));
grandLines += trace.lineCount();
}
return ApiResp.ok(new TraceOverview(traces, grandTotal.doubleValue(), grandLines));
}
/** 把一组费用折叠成一个项目的证据链(逐行 + 按类型汇总 + 合计)。 */
private ExpenseTrace buildTrace(ProjectRef ref, List<RdExpense> expenses) {
List<EvidenceLine> lines = new ArrayList<>();
Map<String, CatAcc> byCategory = new LinkedHashMap<>();
BigDecimal total = BigDecimal.ZERO;
for (RdExpense e : expenses) {
lines.add(new EvidenceLine(e.getId(), e.getCategory(), e.getDescription(),
Money.nz(e.getAmount()).doubleValue(), e.getOccurDate(), e.getVoucher(),
e.getStatus(), e.getRecorder()));
String cat = e.getCategory() == null || e.getCategory().isBlank() ? "未分类" : e.getCategory();
CatAcc acc = byCategory.computeIfAbsent(cat, k -> new CatAcc());
acc.amount = Money.add(acc.amount, e.getAmount());
acc.count++;
total = Money.add(total, e.getAmount());
}
List<CategorySummary> summaries = new ArrayList<>();
for (Map.Entry<String, CatAcc> c : byCategory.entrySet()) {
summaries.add(new CategorySummary(c.getKey(), c.getValue().amount.doubleValue(),
c.getValue().count));
}
return new ExpenseTrace(ref, lines, summaries, total.doubleValue(), lines.size());
}
}