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 lines, List byCategory, double totalAmount, int lineCount) { } /** 跨全部研发项目的证据链总览(看板用)。 */ public record TraceOverview(List 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 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 overview() { // 先把费用按项目分桶(插入序固定行位)。 Map> byProject = new LinkedHashMap<>(); for (RdExpense e : rdExpenseRepo.findAll()) { byProject.computeIfAbsent(e.getRdProjectId(), k -> new ArrayList<>()).add(e); } // 项目名/编码以 RdProject 主档为准;费用上的 rdProjectName 仅作回退(项目已删时)。 Map projects = new LinkedHashMap<>(); for (RdProject p : rdProjectRepo.findAll()) { projects.put(p.getId(), p); } List traces = new ArrayList<>(); BigDecimal grandTotal = BigDecimal.ZERO; int grandLines = 0; for (Map.Entry> entry : byProject.entrySet()) { Long pid = entry.getKey(); List 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 expenses) { List lines = new ArrayList<>(); Map 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 summaries = new ArrayList<>(); for (Map.Entry 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()); } }