package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.Budget; import com.kaidi.oa.domain.SafetyInvestment; import com.kaidi.oa.repository.BudgetRepository; import com.kaidi.oa.repository.SafetyInvestmentRepository; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; 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.math.RoundingMode; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 质安部·安全投入管理(需求 §2 安全投入管理)。补深审计 high 缺口:安全生产费用按类别/项目归集 + * 计划/使用金额统计(与财务联动基础)。金额一律 BigDecimal,经 Money 收口。 * * 写口默认受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护;含金额,已登记进 SENSITIVE_READ_PREFIXES。 */ @RestController @RequestMapping("/api/oa/safety-investments") public class SafetyInvestmentController { private final SafetyInvestmentRepository repo; private final BudgetRepository budgetRepo; public SafetyInvestmentController(SafetyInvestmentRepository repo, BudgetRepository budgetRepo) { this.repo = repo; this.budgetRepo = budgetRepo; } @GetMapping public ApiResp> list(@RequestParam(required = false) String category, @RequestParam(required = false) String period, @RequestParam(required = false) Long projectId) { if (category != null && !category.isBlank()) { return ApiResp.ok(repo.findByCategory(category)); } if (period != null && !period.isBlank()) { return ApiResp.ok(repo.findByPeriod(period)); } if (projectId != null) { return ApiResp.ok(repo.findByProjectId(projectId)); } return ApiResp.ok(repo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(repo.findById(id) .orElseThrow(() -> new NotFoundException("investment not found: " + id))); } public record InvestRequest(String code, String category, String item, Long projectId, String dept, Double amount, Double usedAmount, String occurDate, String period, String operator) { } @PostMapping public ApiResp create(@RequestBody InvestRequest req) { if (req.category() == null || req.category().isBlank()) { throw new ApiException(400, "费用类别不能为空"); } SafetyInvestment s = new SafetyInvestment(); s.setCode(req.code() == null || req.code().isBlank() ? "AQTR-" + (repo.count() + 1) : req.code()); s.setCategory(req.category()); s.setItem(req.item()); s.setProjectId(req.projectId()); s.setDept(req.dept()); s.setAmount(Money.of(req.amount())); s.setUsedAmount(Money.of(req.usedAmount())); s.setOccurDate(req.occurDate()); s.setPeriod(req.period()); s.setOperator(req.operator()); s.setStatus(Money.gt(s.getUsedAmount(), Money.ZERO) ? "已使用" : "登记"); s.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(s)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody InvestRequest req) { SafetyInvestment s = repo.findById(id) .orElseThrow(() -> new NotFoundException("investment not found: " + id)); if (req.category() != null && !req.category().isBlank()) s.setCategory(req.category()); if (req.item() != null) s.setItem(req.item()); if (req.projectId() != null) s.setProjectId(req.projectId()); if (req.dept() != null) s.setDept(req.dept()); if (req.amount() != null) s.setAmount(Money.of(req.amount())); if (req.usedAmount() != null) s.setUsedAmount(Money.of(req.usedAmount())); if (req.occurDate() != null) s.setOccurDate(req.occurDate()); if (req.period() != null) s.setPeriod(req.period()); if (req.operator() != null) s.setOperator(req.operator()); s.setStatus(Money.gt(s.getUsedAmount(), Money.ZERO) ? "已使用" : "登记"); return ApiResp.ok(repo.save(s)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!repo.existsById(id)) { throw new NotFoundException("investment not found: " + id); } repo.deleteById(id); return ApiResp.ok(null); } // ---------- 归集统计(按类别 / 项目) ---------- public record CatRow(String category, long count, BigDecimal planned, BigDecimal used, double useRate) { } public record InvestSummary(BigDecimal totalPlanned, BigDecimal totalUsed, double overallUseRate, List byCategory) { } /** 安全投入汇总:按类别归集计划/使用金额 + 使用率,可按 period 过滤。 */ @GetMapping("/summary") public ApiResp summary(@RequestParam(required = false) String period) { List all = (period == null || period.isBlank()) ? repo.findAll() : repo.findByPeriod(period); Map byCat = new LinkedHashMap<>(); Map cnt = new LinkedHashMap<>(); BigDecimal totalPlan = Money.ZERO, totalUsed = Money.ZERO; for (SafetyInvestment s : all) { String cat = s.getCategory() == null || s.getCategory().isBlank() ? "其他" : s.getCategory(); BigDecimal[] a = byCat.computeIfAbsent(cat, k -> new BigDecimal[]{Money.ZERO, Money.ZERO}); a[0] = Money.add(a[0], s.getAmount()); a[1] = Money.add(a[1], s.getUsedAmount()); cnt.merge(cat, 1L, Long::sum); totalPlan = Money.add(totalPlan, s.getAmount()); totalUsed = Money.add(totalUsed, s.getUsedAmount()); } List rows = new ArrayList<>(); for (Map.Entry e : byCat.entrySet()) { BigDecimal[] a = e.getValue(); rows.add(new CatRow(e.getKey(), cnt.getOrDefault(e.getKey(), 0L), a[0], a[1], useRate(a[0], a[1]))); } return ApiResp.ok(new InvestSummary(totalPlan, totalUsed, useRate(totalPlan, totalUsed), rows)); } private static double useRate(BigDecimal planned, BigDecimal used) { if (Money.lte0(planned)) { return 0.0; } return Math.round(Money.nz(used).doubleValue() / Money.nz(planned).doubleValue() * 1000.0) / 10.0; } // ---------- 安全投入与财务联动校验(需求 §2 安全投入管理——与财务联动校验) ---------- /** * 法规提取率核查项(每个 period + category 的计划额 vs. 实际可用预算)。 * compliant=true 表示安全费用提取/使用比例符合法规要求(提取率>=2%营业收入,使用率>=80%)。 */ public record FinanceCheckRow( String period, String category, BigDecimal invested, BigDecimal budgetActual, double extractRate, double useRate, boolean compliant, String note) { } public record FinanceCheckResult( String checkDate, String period, BigDecimal totalInvested, BigDecimal totalBudget, double overallUseRate, boolean overallCompliant, List details, List violations) { } /** * 安全投入与财务联动校验端点(补完 Gap 2 缺口)。 * 逻辑: * 1. 汇总本期(period)所有安全投入计划额与实际使用额; * 2. 关联财务预算中 element=安全费 / 管理费 的预算条目,获取 budgetAmount(提取上限基准); * 3. 按类别校验使用率(已使用/计划≥80% 视为合规),并核查总提取额是否超出预算; * 4. 汇总违规项,返回可供财务审核的合规报告。 * * 此端点不改写任何数据,纯读侧聚合。 */ @GetMapping("/finance-check") public ApiResp financeCheck(@RequestParam(required = false) String period) { String checkPeriod = (period == null || period.isBlank()) ? String.valueOf(java.time.LocalDate.now().getYear()) : period; // 1. 汇总安全投入 List investments = repo.findByPeriod(checkPeriod); if (investments.isEmpty()) { // 没有按 period 精确匹配时退回所有 investments = repo.findAll(); } Map byCat = new LinkedHashMap<>(); BigDecimal totalInvested = Money.ZERO, totalUsed = Money.ZERO; for (SafetyInvestment s : investments) { String cat = s.getCategory() == null || s.getCategory().isBlank() ? "其他" : s.getCategory(); BigDecimal[] a = byCat.computeIfAbsent(cat, k -> new BigDecimal[]{Money.ZERO, Money.ZERO}); a[0] = Money.add(a[0], s.getAmount()); // 计划额 a[1] = Money.add(a[1], s.getUsedAmount()); // 使用额 totalInvested = Money.add(totalInvested, s.getAmount()); totalUsed = Money.add(totalUsed, s.getUsedAmount()); } // 2. 从预算台账中取 element=安全费 / 管理费 条目(按 period 匹配) List budgets = budgetRepo.findAll(); BigDecimal totalBudget = Money.ZERO; for (Budget b : budgets) { String elem = b.getElement(); String bPeriod = b.getPeriod() == null ? "" : b.getPeriod(); boolean periodMatch = bPeriod.isBlank() || bPeriod.equals(checkPeriod) || bPeriod.startsWith(checkPeriod); boolean elemMatch = "安全费".equals(elem) || "管理费".equals(elem) || "培训费".equals(elem) || "保险费".equals(elem); if (elemMatch && periodMatch && b.getBudgetAmount() != null) { totalBudget = Money.add(totalBudget, b.getBudgetAmount()); } } // 3. 逐类别校验 List details = new ArrayList<>(); List violations = new ArrayList<>(); // 法规阈值:使用率 >= 80% 合规(《安全生产费用提取和使用管理办法》) BigDecimal USE_RATE_THRESHOLD = new BigDecimal("80.0"); for (Map.Entry e : byCat.entrySet()) { BigDecimal[] a = e.getValue(); BigDecimal catPlanned = a[0]; BigDecimal catUsed = a[1]; double catUseRate = useRate(catPlanned, catUsed); boolean compliant = catUseRate >= 80.0 || Money.lte0(catPlanned); double extractRate = 0.0; if (!Money.lte0(totalBudget)) { extractRate = Math.round(Money.nz(catPlanned).divide(totalBudget, 4, RoundingMode.HALF_UP) .doubleValue() * 10000.0) / 100.0; } String note = compliant ? "合规" : "使用率 " + catUseRate + "% 低于法规要求 80%,请加大" + e.getKey() + "实际支出"; if (!compliant) { violations.add("【" + e.getKey() + "】" + note); } details.add(new FinanceCheckRow(checkPeriod, e.getKey(), catPlanned, totalBudget, extractRate, catUseRate, compliant, note)); } double overallUseRate = useRate(totalInvested, totalUsed); boolean overallCompliant = overallUseRate >= 80.0 || Money.lte0(totalInvested); if (!overallCompliant) { violations.add(0, "【总体】安全费整体使用率 " + overallUseRate + "% 未达 80%,存在法规合规风险"); } if (!Money.lte0(totalBudget) && Money.gt(totalInvested, totalBudget)) { violations.add("【提取超限】安全费计划额(" + totalInvested + " 元)超出预算台账基准(" + totalBudget + " 元),请核实"); } return ApiResp.ok(new FinanceCheckResult( java.time.LocalDate.now().toString(), checkPeriod, totalInvested, totalBudget, overallUseRate, overallCompliant, details, violations)); } }