package com.kaidi.oa.web; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.domain.ComplianceSelfCheckTask; import com.kaidi.oa.domain.Contract; import com.kaidi.oa.domain.ExpenseClaim; import com.kaidi.oa.domain.FixedAsset; import com.kaidi.oa.domain.LitigationCase; import com.kaidi.oa.domain.MfgSalesOrder; import com.kaidi.oa.domain.Payment; import com.kaidi.oa.repository.BankReconciliationRepository; import com.kaidi.oa.repository.ComplianceChecklistRepository; import com.kaidi.oa.repository.ComplianceSelfCheckTaskRepository; import com.kaidi.oa.repository.ContractRepository; import com.kaidi.oa.repository.ExpenseClaimRepository; import com.kaidi.oa.repository.FixedAssetRepository; import com.kaidi.oa.repository.LitigationCaseRepository; import com.kaidi.oa.repository.MfgSalesOrderRepository; 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.RequestParam; 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; /** * 审计跨模块数据聚合接口(内控部·审计监察部·第11模块)。 * * 解决审计缺口:「无系统化审计只读聚合视图(需要从财务凭证/采购订单/合同/生产工单/人事薪酬/银行流水等 * 模块统一取数);结算中心银行流水/票据台账无审计专用只读端点;各业务部门内控自评问卷无专项数据流向审计」。 * * 全部端点仅读,无写副作用,路径 /api/oa/audit-data-aggregate, * 纳入 SENSITIVE_READ_PREFIXES(含财务/银行/合同/合规敏感数据,需 ADMIN/APPROVER)。 * * 端点列表: * - GET /finance-overview 财务总览(付款/合同/报销/凭证统计,含金额趋势); * - GET /contract-list 合同台账只读列表(可按被审计单位/期间过滤); * - GET /payment-list 付款记录只读列表(金额/状态/收款方,可按期间过滤); * - GET /expense-list 费用报销只读列表(部门/类别,可按期间过滤); * - GET /bank-statement 银行流水/对账台账只读清单; * - GET /fixed-asset-risk 固定资产风险清单(已报废/闲置/超龄); * - GET /legal-exposure 法务风险敞口(诉讼案件+合规处罚+黑名单供应商); * - GET /compliance-checklist 内控自评问卷汇总(各部门自评状态/通过率); * - GET /vendor-concentration 供应商集中度分析(付款金额 top-10 收款方占比); * - GET /payee-anomaly 收款方异常线索(同收款方不同银行账号/大额临近签批限额)。 */ @RestController @RequestMapping("/api/oa/audit-data-aggregate") public class AuditDataAggregateController { private final ContractRepository contractRepo; private final PaymentRepository paymentRepo; private final ExpenseClaimRepository expenseRepo; private final FixedAssetRepository fixedAssetRepo; private final VoucherRepository voucherRepo; private final BankReconciliationRepository bankReconRepo; private final LitigationCaseRepository litigationRepo; private final ComplianceChecklistRepository complianceRepo; private final MfgSalesOrderRepository mfgSalesOrderRepo; private final ComplianceSelfCheckTaskRepository selfCheckTaskRepo; public AuditDataAggregateController(ContractRepository contractRepo, PaymentRepository paymentRepo, ExpenseClaimRepository expenseRepo, FixedAssetRepository fixedAssetRepo, VoucherRepository voucherRepo, BankReconciliationRepository bankReconRepo, LitigationCaseRepository litigationRepo, ComplianceChecklistRepository complianceRepo, MfgSalesOrderRepository mfgSalesOrderRepo, ComplianceSelfCheckTaskRepository selfCheckTaskRepo) { this.contractRepo = contractRepo; this.paymentRepo = paymentRepo; this.expenseRepo = expenseRepo; this.fixedAssetRepo = fixedAssetRepo; this.voucherRepo = voucherRepo; this.bankReconRepo = bankReconRepo; this.litigationRepo = litigationRepo; this.complianceRepo = complianceRepo; this.mfgSalesOrderRepo = mfgSalesOrderRepo; this.selfCheckTaskRepo = selfCheckTaskRepo; } /** * 财务总览:付款/合同/报销统计金额合计,供审计底稿取数参考。 */ @GetMapping("/finance-overview") public ApiResp> financeOverview() { List payments = paymentRepo.findAll(); List contracts = contractRepo.findAll(); List expenses = expenseRepo.findAll(); BigDecimal totalPayment = payments.stream() .map(p -> Money.nz(p.getAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal approvedPayment = payments.stream() .filter(p -> "已付款".equals(p.getStatus()) || "已审批".equals(p.getStatus())) .map(p -> Money.nz(p.getAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal totalContractAmount = contracts.stream() .map(c -> Money.nz(c.getAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal totalPaidAmount = contracts.stream() .map(c -> Money.nz(c.getPaidAmount())) .reduce(BigDecimal.ZERO, Money::add); BigDecimal totalExpense = expenses.stream() .filter(e -> "已报销".equals(e.getStatus()) || "已审批".equals(e.getStatus())) .map(e -> Money.nz(e.getAmount())) .reduce(BigDecimal.ZERO, Money::add); // 付款按月聚合趋势。 Map paymentByMonth = new LinkedHashMap<>(); for (Payment p : payments) { String month = p.getPayDate() == null ? "未知" : p.getPayDate().substring(0, Math.min(7, p.getPayDate().length())); paymentByMonth.merge(month, Money.nz(p.getAmount()), Money::add); } List> trend = paymentByMonth.entrySet().stream() .map(e -> Map.of("month", e.getKey(), "amount", e.getValue().doubleValue())) .toList(); return ApiResp.ok(Map.of( "totalPayments", payments.size(), "totalPaymentAmount", totalPayment.doubleValue(), "approvedPaymentAmount", approvedPayment.doubleValue(), "totalContracts", contracts.size(), "totalContractAmount", totalContractAmount.doubleValue(), "totalPaidAmount", totalPaidAmount.doubleValue(), "totalExpenseClaims", expenses.size(), "totalExpenseAmount", totalExpense.doubleValue(), "paymentTrend", trend )); } /** * 合同台账只读列表(审计专用视图,可按被审计单位/合同类型过滤)。 */ @GetMapping("/contract-list") public ApiResp>> contractList( @RequestParam(required = false) String auditee, @RequestParam(required = false) String type, @RequestParam(defaultValue = "200") int limit) { List all = contractRepo.findAll(); return ApiResp.ok(all.stream() .filter(c -> auditee == null || auditee.isBlank() || (c.getPartyA() != null && c.getPartyA().contains(auditee)) || (c.getPartyB() != null && c.getPartyB().contains(auditee))) .filter(c -> type == null || type.isBlank() || type.equals(c.getType())) .limit(limit) .map(c -> { Map m = new LinkedHashMap<>(); m.put("id", c.getId()); m.put("name", c.getName()); m.put("type", c.getType()); m.put("partyA", c.getPartyA()); m.put("partyB", c.getPartyB()); m.put("amount", Money.nz(c.getAmount()).doubleValue()); m.put("paidAmount", Money.nz(c.getPaidAmount()).doubleValue()); m.put("status", c.getStatus()); m.put("signDate", c.getSignDate()); return m; }) .toList()); } /** * 付款记录只读列表(可按收款方/期间/金额下限过滤,默认最近200条)。 */ @GetMapping("/payment-list") public ApiResp>> paymentList( @RequestParam(required = false) String payee, @RequestParam(required = false) String fromDate, @RequestParam(required = false) String toDate, @RequestParam(defaultValue = "0") double minAmount, @RequestParam(defaultValue = "200") int limit) { BigDecimal minAmtBD = Money.of(minAmount); List all = paymentRepo.findAll(); return ApiResp.ok(all.stream() .filter(p -> payee == null || payee.isBlank() || (p.getPayeeName() != null && p.getPayeeName().contains(payee))) .filter(p -> fromDate == null || fromDate.isBlank() || (p.getPayDate() != null && p.getPayDate().compareTo(fromDate) >= 0)) .filter(p -> toDate == null || toDate.isBlank() || (p.getPayDate() != null && p.getPayDate().compareTo(toDate) <= 0)) .filter(p -> Money.nz(p.getAmount()).compareTo(minAmtBD) >= 0) .limit(limit) .map(p -> { Map m = new LinkedHashMap<>(); m.put("id", p.getId()); m.put("code", p.getCode()); m.put("subject", p.getSubject()); m.put("payeeName", p.getPayeeName()); m.put("amount", Money.nz(p.getAmount()).doubleValue()); m.put("payType", p.getPayType()); m.put("status", p.getStatus()); m.put("payDate", p.getPayDate()); return m; }) .toList()); } /** * 费用报销只读列表(按部门/类别/期间过滤)。 */ @GetMapping("/expense-list") public ApiResp>> expenseList( @RequestParam(required = false) String dept, @RequestParam(required = false) String category, @RequestParam(defaultValue = "200") int limit) { List all = expenseRepo.findAll(); return ApiResp.ok(all.stream() .filter(e -> dept == null || dept.isBlank() || dept.equals(e.getDept())) .filter(e -> category == null || category.isBlank() || category.equals(e.getCategory())) .limit(limit) .map(e -> { Map m = new LinkedHashMap<>(); m.put("id", e.getId()); m.put("code", e.getCode()); m.put("applicant", e.getApplicant()); m.put("dept", e.getDept()); m.put("category", e.getCategory()); m.put("amount", Money.nz(e.getAmount()).doubleValue()); m.put("status", e.getStatus()); m.put("expenseDate", e.getExpenseDate()); m.put("summary", e.getSummary()); return m; }) .toList()); } /** * 银行流水/对账台账只读清单(审计专用银行流水取数口)。 */ @GetMapping("/bank-statement") public ApiResp bankStatement(@RequestParam(defaultValue = "200") int limit) { var all = bankReconRepo.findAll(); var list = all.stream().limit(limit) .map(b -> { Map m = new LinkedHashMap<>(); m.put("id", b.getId()); m.put("reconDate", b.getReconcileDate()); m.put("bankBalance", Money.nz(b.getBankBalance()).doubleValue()); m.put("bookBalance", Money.nz(b.getBookBalance()).doubleValue()); m.put("difference", Money.nz(b.getDiff()).doubleValue()); m.put("status", b.getStatus()); return m; }).toList(); return ApiResp.ok(list); } /** * 固定资产风险清单:报废/闲置/净值为负的资产(审计关注点)。 */ @GetMapping("/fixed-asset-risk") public ApiResp>> fixedAssetRisk() { List all = fixedAssetRepo.findAll(); return ApiResp.ok(all.stream() .filter(fa -> "报废".equals(fa.getStatus()) || "闲置".equals(fa.getStatus()) || Money.nz(fa.getNetValue()).compareTo(BigDecimal.ZERO) < 0) .map(fa -> { Map m = new LinkedHashMap<>(); m.put("id", fa.getId()); m.put("name", fa.getName()); m.put("assetNo", fa.getAssetNo()); m.put("category", fa.getCategory()); m.put("status", fa.getStatus()); m.put("originalValue", Money.nz(fa.getOriginalValue()).doubleValue()); m.put("netValue", Money.nz(fa.getNetValue()).doubleValue()); m.put("dept", fa.getDepartment()); return m; }) .toList()); } /** * 法务风险敞口:诉讼案件+合规自评不通过条目。 */ @GetMapping("/legal-exposure") public ApiResp> legalExposure() { List cases = litigationRepo.findAll(); long openCases = cases.stream().filter(c -> !"已结案".equals(c.getStatus())).count(); BigDecimal claimTotal = cases.stream() .map(c -> Money.nz(c.getAmount())) .reduce(BigDecimal.ZERO, Money::add); List> activeCases = cases.stream() .filter(c -> !"已结案".equals(c.getStatus())) .map(c -> { Map m = new LinkedHashMap<>(); m.put("id", c.getId()); m.put("caseNo", c.getCaseNo()); m.put("title", c.getCaseName()); m.put("status", c.getStatus()); m.put("claimAmount", Money.nz(c.getAmount()).doubleValue()); m.put("cause", c.getCause()); return m; }).toList(); return ApiResp.ok(Map.of( "totalCases", cases.size(), "openCases", openCases, "totalClaimAmount", claimTotal.doubleValue(), "activeCases", activeCases)); } /** * 内控自评问卷汇总(各部门自评状态/通过率,联动 ComplianceChecklist)。 */ @GetMapping("/compliance-checklist") public ApiResp complianceChecklist() { var all = complianceRepo.findAll(); // 按部门聚合完成率。 Map> byDept = new LinkedHashMap<>(); for (var item : all) { String dept = item.getResponsibleDept() == null ? "未知" : item.getResponsibleDept(); byDept.computeIfAbsent(dept, k -> new ArrayList<>()).add(item); } List> deptSummary = byDept.entrySet().stream().map(e -> { long total = e.getValue().size(); long passed = e.getValue().stream() .filter(checklist -> "已通过".equals(checklist.getStatus()) || "通过".equals(checklist.getStatus()) || "合规".equals(checklist.getStatus())).count(); double rate = total <= 0 ? 0 : Math.round(passed * 10000d / total) / 100d; return Map.of("dept", e.getKey(), "total", total, "passed", passed, "passRate", rate); }).toList(); return ApiResp.ok(Map.of("total", all.size(), "byDept", deptSummary)); } /** * 供应商/收款方集中度分析:按付款金额聚合 top-10 收款方及其占比(供应商集中度审计)。 */ @GetMapping("/vendor-concentration") public ApiResp vendorConcentration() { List payments = paymentRepo.findAll(); Map byPayee = new LinkedHashMap<>(); BigDecimal total = BigDecimal.ZERO; for (Payment p : payments) { if (p.getPayeeName() == null || p.getPayeeName().isBlank()) continue; byPayee.merge(p.getPayeeName(), Money.nz(p.getAmount()), Money::add); total = Money.add(total, p.getAmount()); } final BigDecimal totalFinal = total; List> top10 = byPayee.entrySet().stream() .sorted((a, b) -> b.getValue().compareTo(a.getValue())) .limit(10) .map(e -> { double pct = totalFinal.compareTo(BigDecimal.ZERO) == 0 ? 0 : Math.round(e.getValue().doubleValue() / totalFinal.doubleValue() * 10000d) / 100d; return Map.of( "payee", e.getKey(), "amount", e.getValue().doubleValue(), "percentage", pct); }).toList(); double top10Pct = top10.stream() .mapToDouble(m -> (Double) m.get("percentage")).sum(); return ApiResp.ok(Map.of("totalPaymentAmount", total.doubleValue(), "top10", top10, "top10Concentration", Math.round(top10Pct * 100d) / 100d)); } /** * 收款方异常线索:大额付款(超 threshold 万元)中可疑的记录(供人工审查)。 */ @GetMapping("/payee-anomaly") public ApiResp payeeAnomaly( @RequestParam(defaultValue = "100000") double threshold) { BigDecimal thresholdBD = Money.of(threshold); List payments = paymentRepo.findAll(); List> suspicious = payments.stream() .filter(p -> Money.nz(p.getAmount()).compareTo(thresholdBD) >= 0) .map(p -> { Map m = new LinkedHashMap<>(); m.put("id", p.getId()); m.put("code", p.getCode()); m.put("payeeName", p.getPayeeName()); m.put("amount", Money.nz(p.getAmount()).doubleValue()); m.put("subject", p.getSubject()); m.put("payDate", p.getPayDate()); m.put("status", p.getStatus()); return m; }) .sorted((a, b) -> Double.compare((Double) b.get("amount"), (Double) a.get("amount"))) .toList(); return ApiResp.ok(Map.of( "threshold", threshold, "count", suspicious.size(), "items", suspicious)); } /** * 销售/经营部 出库单(销售订单)专属只读聚合视图(审计缺口11)。 * 从制造管理中心销售订单取数,支持按客户/阶段/公司主体过滤,返回出库/发货明细清单。 * * @param customer 客户名称(模糊过滤,空=全部)。 * @param salesStage 销售进度阶段(如「发货」/「已验收」,空=全部)。 * @param companySubject 公司主体(空=全部)。 * @param limit 最多返回条数(默认200)。 */ @GetMapping("/sales-order-view") public ApiResp> salesOrderView( @RequestParam(required = false) String customer, @RequestParam(required = false) String salesStage, @RequestParam(required = false) String companySubject, @RequestParam(defaultValue = "200") int limit) { List all = mfgSalesOrderRepo.findAllByOrderByIdDesc(); List filtered = all.stream() .filter(o -> customer == null || customer.isBlank() || (o.getCustomer() != null && o.getCustomer().contains(customer))) .filter(o -> salesStage == null || salesStage.isBlank() || salesStage.equals(o.getSalesStage())) .filter(o -> companySubject == null || companySubject.isBlank() || companySubject.equals(o.getCompanySubject())) .limit(limit) .toList(); BigDecimal totalAmount = filtered.stream() .map(o -> Money.nz(o.getAmount())) .reduce(BigDecimal.ZERO, Money::add); // 按阶段分组统计(方便审计看出库分布)。 Map byStage = new LinkedHashMap<>(); for (MfgSalesOrder o : filtered) { String stage = o.getSalesStage() == null ? "未知" : o.getSalesStage(); byStage.merge(stage, 1L, Long::sum); } List> rows = filtered.stream().map(o -> { Map m = new LinkedHashMap<>(); m.put("id", o.getId()); m.put("orderNo", o.getOrderNo()); m.put("projectCode", o.getProjectCode()); m.put("projectName", o.getProjectName()); m.put("customer", o.getCustomer()); m.put("equipType", o.getEquipType()); m.put("deliveryDate", o.getDeliveryDate()); m.put("amount", Money.nz(o.getAmount()).doubleValue()); m.put("salesStage", o.getSalesStage()); m.put("deliverySite", o.getDeliverySite()); m.put("contractNo", o.getContractNo()); m.put("owner", o.getOwner()); m.put("companySubject", o.getCompanySubject()); return m; }).toList(); Map result = new LinkedHashMap<>(); result.put("count", rows.size()); result.put("totalAmount", totalAmount.doubleValue()); result.put("byStage", byStage); result.put("note", "数据来源:制造管理中心·销售订单(含发货/出库/验收状态),只读审计视图"); result.put("items", rows); return ApiResp.ok(result); } /** * 票据台账(商业汇票/银票)专用审计只读端点(审计缺口11)。 * 从付款记录中过滤票据类付款类型(商业汇票/银行承兑汇票/商票/银票等), * 返回票据台账清单+金额汇总,供审计人员取证。 * * @param billType 票据类型关键词(如「汇票」/「承兑」,空=全部票据类付款)。 * @param fromDate 付款日期起(YYYY-MM-DD,空=不限)。 * @param toDate 付款日期止(YYYY-MM-DD,空=不限)。 * @param limit 最多返回条数(默认200)。 */ @GetMapping("/bill-ledger") public ApiResp> billLedger( @RequestParam(required = false) String billType, @RequestParam(required = false) String fromDate, @RequestParam(required = false) String toDate, @RequestParam(defaultValue = "200") int limit) { List all = paymentRepo.findAll(); // 票据类付款类型关键词列表(覆盖常见票据名称)。 final java.util.Set BILL_KEYWORDS = new java.util.LinkedHashSet<>( java.util.Arrays.asList("汇票", "承兑", "票据", "商票", "银票", "银行承兑", "商业承兑")); List bills = all.stream() .filter(p -> { String pt = p.getPayType() == null ? "" : p.getPayType(); String subject = p.getSubject() == null ? "" : p.getSubject(); boolean isBill = BILL_KEYWORDS.stream().anyMatch( kw -> pt.contains(kw) || subject.contains(kw)); if (!isBill) return false; if (billType != null && !billType.isBlank()) { isBill = pt.contains(billType) || subject.contains(billType); } return isBill; }) .filter(p -> fromDate == null || fromDate.isBlank() || (p.getPayDate() != null && p.getPayDate().compareTo(fromDate) >= 0)) .filter(p -> toDate == null || toDate.isBlank() || (p.getPayDate() != null && p.getPayDate().compareTo(toDate) <= 0)) .limit(limit) .toList(); BigDecimal totalBillAmount = bills.stream() .map(p -> Money.nz(p.getAmount())) .reduce(BigDecimal.ZERO, Money::add); Map byType = new LinkedHashMap<>(); for (Payment p : bills) { String pt = p.getPayType() == null ? "其他票据" : p.getPayType(); byType.merge(pt, Money.nz(p.getAmount()), Money::add); } Map byTypeDouble = new LinkedHashMap<>(); for (Map.Entry e : byType.entrySet()) { byTypeDouble.put(e.getKey(), e.getValue().doubleValue()); } List> rows = bills.stream().map(p -> { Map m = new LinkedHashMap<>(); m.put("id", p.getId()); m.put("code", p.getCode()); m.put("payeeName", p.getPayeeName()); m.put("amount", Money.nz(p.getAmount()).doubleValue()); m.put("payType", p.getPayType()); m.put("subject", p.getSubject()); m.put("payDate", p.getPayDate()); m.put("status", p.getStatus()); return m; }).toList(); Map result = new LinkedHashMap<>(); result.put("count", rows.size()); result.put("totalBillAmount", totalBillAmount.doubleValue()); result.put("byBillType", byTypeDouble); result.put("note", "从付款记录中聚合含「汇票/承兑/票据/商票/银票」关键词的记录,作为票据台账审计只读视图"); result.put("items", rows); return ApiResp.ok(result); } /** * 内控自评答卷明细(按部门)——供审计人员取证(审计缺口11)。 * 当前 /compliance-checklist 仅返回状态统计,本端点返回各部门自评答卷填报详情, * 包含每条任务的填报结果(selfCheckResult)、提交日期、复核意见等。 * * @param dept 责任部门(精确或空=全部)。 * @param status 任务状态(空=全部)。 * @param limit 最多返回条数(默认200)。 */ @GetMapping("/self-check-task-detail") public ApiResp> selfCheckTaskDetail( @RequestParam(required = false) String dept, @RequestParam(required = false) String status, @RequestParam(defaultValue = "200") int limit) { List all = selfCheckTaskRepo.findAll(); List filtered = all.stream() .filter(t -> dept == null || dept.isBlank() || dept.equals(t.getResponsibleDept())) .filter(t -> status == null || status.isBlank() || status.equals(t.getStatus())) .limit(limit) .toList(); // 按部门聚合状态分布(用于审计取证汇总)。 Map> deptStatusMap = new LinkedHashMap<>(); for (ComplianceSelfCheckTask t : filtered) { String d = t.getResponsibleDept() == null ? "未知" : t.getResponsibleDept(); String st = t.getStatus() == null ? "未知" : t.getStatus(); deptStatusMap.computeIfAbsent(d, k -> new LinkedHashMap<>()) .merge(st, 1L, Long::sum); } List> rows = filtered.stream().map(t -> { Map m = new LinkedHashMap<>(); m.put("id", t.getId()); m.put("taskNo", t.getTaskNo()); m.put("checklistTitle", t.getChecklistTitle()); m.put("period", t.getPeriod()); m.put("responsibleDept", t.getResponsibleDept()); m.put("assignee", t.getAssignee()); m.put("dueDate", t.getDueDate()); m.put("status", t.getStatus()); m.put("selfCheckResult", t.getSelfCheckResult()); m.put("submitDate", t.getSubmitDate()); m.put("reviewer", t.getReviewer()); m.put("reviewComment", t.getReviewComment()); m.put("reviewDate", t.getReviewDate()); return m; }).toList(); long submittedCount = filtered.stream() .filter(t -> !"待填报".equals(t.getStatus()) && !"填报中".equals(t.getStatus())).count(); long closedCount = filtered.stream() .filter(t -> "已闭环".equals(t.getStatus())).count(); Map result = new LinkedHashMap<>(); result.put("count", rows.size()); result.put("submittedCount", submittedCount); result.put("closedCount", closedCount); result.put("deptStatusSummary", deptStatusMap); result.put("note", "内控自评答卷明细(含填报结果/复核意见),供审计人员按部门取证"); result.put("items", rows); return ApiResp.ok(result); } }