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.IpAsset; import com.kaidi.oa.domain.IpBudget; import com.kaidi.oa.domain.IpFee; import com.kaidi.oa.repository.IpAssetRepository; import com.kaidi.oa.repository.IpBudgetRepository; import com.kaidi.oa.repository.IpFeeRepository; import org.springframework.transaction.annotation.Transactional; 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.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 知识产权域专用预算编制与执行监控(知识产权部,需求功能4 + 功能11)。 * * 补深审计缺口(4. 费用与预算控制 / 11. 预算与计划管理 — PARTIAL): * - IP 专用预算编制:按年度/季度 + 业务类目(申请/维持/诉讼/培训/其他)编制; * - 缴费自动扣减 IP 预算:知识产权缴费(IpFee)按费用类型映射到预算类目,占用预算 used, * 超支返回 409「超支预警」(可 force 强占);缴费记录可释放 release; * - 自动汇总各类目 + 历史年度对比(summary); * - 含被引用次数维度的投入产出分析(roi):投入金额 vs 授权数量 vs 被引用次数。 * * 与现有 IpAssetController.payFee 解耦:缴费仍由 IpAssetController 生成付款单并回写 totalCost; * 本控制器提供 /occupy 端点供「确认缴费扣减预算」这一独立业务动作调用,做到加法式不破坏既有链。 * * 写口含金额,收 ADMIN/APPROVER(FINANCE_PREFIXES);读口含财务明细,收敏感读门槛 * (SENSITIVE_READ_PREFIXES)——见 sharedFileSnippets。 */ @RestController @RequestMapping("/api/oa/ip-budgets") public class IpBudgetController { private final IpBudgetRepository budgetRepo; private final IpFeeRepository feeRepo; private final IpAssetRepository assetRepo; public IpBudgetController(IpBudgetRepository budgetRepo, IpFeeRepository feeRepo, IpAssetRepository assetRepo) { this.budgetRepo = budgetRepo; this.feeRepo = feeRepo; this.assetRepo = assetRepo; } private static final List CATEGORIES = List.of("申请", "维持", "诉讼", "培训", "其他"); /** 费用类型 → 预算类目映射(缴费自动扣减时用)。 */ private static String feeTypeToCategory(String feeType) { if (feeType == null) { return "其他"; } return switch (feeType) { case "申请费", "代理费", "翻译费", "加急费" -> "申请"; case "年费", "维持费" -> "维持"; case "复审费", "诉讼费", "无效宣告费" -> "诉讼"; default -> "其他"; }; } // ---------- 台账 CRUD ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) Integer fiscalYear, @RequestParam(required = false) String category) { if (fiscalYear != null && category != null && !category.isBlank()) { return ApiResp.ok(budgetRepo.findByFiscalYearAndCategory(fiscalYear, category)); } if (fiscalYear != null) { return ApiResp.ok(budgetRepo.findByFiscalYear(fiscalYear)); } if (category != null && !category.isBlank()) { return ApiResp.ok(budgetRepo.findByCategory(category)); } return ApiResp.ok(budgetRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(find(id)); } public record BudgetRequest(Integer fiscalYear, Integer quarter, String category, String costCenter, Double amount, String owner, String remark) { } @PostMapping @Transactional public ApiResp create(@RequestBody BudgetRequest req) { if (req.fiscalYear() == null) { throw new ApiException(400, "预算年度不能为空"); } String cat = normalizeCategory(req.category()); IpBudget b = new IpBudget(); b.setFiscalYear(req.fiscalYear()); b.setQuarter(req.quarter()); b.setCategory(cat); b.setCostCenter(req.costCenter()); b.setAmount(Money.of(req.amount())); b.setUsed(Money.ZERO); b.setStatus("编制中"); b.setOwner(req.owner()); b.setRemark(req.remark()); b.setCreatedAt(Instant.now()); b.setUpdatedAt(Instant.now()); return ApiResp.ok(budgetRepo.save(b)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody BudgetRequest req) { IpBudget b = find(id); if ("已关闭".equals(b.getStatus())) { throw new ApiException(409, "已关闭的预算不可修改"); } if (req.fiscalYear() != null) b.setFiscalYear(req.fiscalYear()); if (req.quarter() != null) b.setQuarter(req.quarter()); if (req.category() != null && !req.category().isBlank()) b.setCategory(normalizeCategory(req.category())); if (req.costCenter() != null) b.setCostCenter(req.costCenter()); if (req.amount() != null) b.setAmount(Money.of(req.amount())); if (req.owner() != null) b.setOwner(req.owner()); if (req.remark() != null) b.setRemark(req.remark()); b.setUpdatedAt(Instant.now()); return ApiResp.ok(budgetRepo.save(b)); } public record StatusRequest(String status) { } /** 下达 / 关闭预算(编制中 → 已下达 → 已关闭)。 */ @PostMapping("/{id}/status") @Transactional public ApiResp setStatus(@PathVariable Long id, @RequestBody StatusRequest req) { IpBudget b = find(id); String to = req.status() == null ? "" : req.status().trim(); if (!List.of("编制中", "已下达", "已关闭").contains(to)) { throw new ApiException(400, "未知状态:" + to); } b.setStatus(to); b.setUpdatedAt(Instant.now()); return ApiResp.ok(budgetRepo.save(b)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { IpBudget b = find(id); if (Money.gt(b.getUsed(), Money.ZERO)) { throw new ApiException(409, "该预算已有占用(已用 " + Money.nz(b.getUsed()).toPlainString() + "),不可删除"); } budgetRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 缴费自动扣减预算(闭环) ---------- public record OccupyRequest(Long feeId, Integer fiscalYear, boolean force) { } /** * 知识产权缴费扣减预算:按费用类型映射到预算类目,在对应年度类目预算上占用(used += 费用金额)。 * 超支(used > amount)默认返回 409 超支预警;force=true 时强占放行(仍记账,便于事后追预算)。 * 缴费记录会回写其占用信息,幂等:同一 feeId 重复占用直接拒绝。 */ @PostMapping("/occupy") @Transactional public ApiResp occupy(@RequestBody OccupyRequest req) { if (req.feeId() == null) { throw new ApiException(400, "缺少费用记录 feeId"); } IpFee f = feeRepo.findById(req.feeId()) .orElseThrow(() -> new NotFoundException("费用记录不存在:" + req.feeId())); if (f.getBudgetId() != null) { throw new ApiException(409, "该费用已扣减预算 #" + f.getBudgetId() + ",请勿重复"); } String category = feeTypeToCategory(f.getFeeType()); int year = req.fiscalYear() != null ? req.fiscalYear() : yearOf(f.getDueDate()); List candidates = budgetRepo.findByFiscalYearAndCategory(year, category); if (candidates.isEmpty()) { throw new ApiException(409, "未找到 " + year + " 年度「" + category + "」类知识产权预算,请先编制预算或指定 fiscalYear"); } IpBudget b = candidates.get(0); BigDecimal amt = Money.nz(f.getAmount()); BigDecimal newUsed = Money.add(b.getUsed(), amt); if (Money.gt(newUsed, b.getAmount()) && !req.force()) { throw new ApiException(409, "超支预警:「" + category + "」预算 " + b.getAmount().toPlainString() + ",本次占用后已用 " + newUsed.toPlainString() + ",超出 " + Money.sub(newUsed, b.getAmount()).toPlainString() + "。如需强制占用请带 force=true"); } b.setUsed(newUsed); b.setUpdatedAt(Instant.now()); budgetRepo.save(b); f.setBudgetId(b.getId()); feeRepo.save(f); return ApiResp.ok(b); } /** 释放某费用记录占用的预算(撤销缴费/纠错)。 */ @PostMapping("/release") @Transactional public ApiResp release(@RequestBody OccupyRequest req) { if (req.feeId() == null) { throw new ApiException(400, "缺少费用记录 feeId"); } IpFee f = feeRepo.findById(req.feeId()) .orElseThrow(() -> new NotFoundException("费用记录不存在:" + req.feeId())); if (f.getBudgetId() == null) { throw new ApiException(409, "该费用未占用任何预算"); } IpBudget b = find(f.getBudgetId()); b.setUsed(Money.sub(b.getUsed(), Money.nz(f.getAmount()))); if (Money.lte0(b.getUsed())) { b.setUsed(Money.ZERO); } b.setUpdatedAt(Instant.now()); budgetRepo.save(b); f.setBudgetId(null); feeRepo.save(f); return ApiResp.ok(b); } // ---------- 执行汇总 + 历史年度对比 ---------- public record CategoryLine(String category, double budget, double used, double balance, double rate, boolean overBudget) { } public record YearSummary(int fiscalYear, double totalBudget, double totalUsed, double totalBalance, double rate, List byCategory) { } /** 某年度预算执行汇总:按类目自动汇总 budget/used/balance/占用率 + 超支标记。 */ @GetMapping("/summary") public ApiResp summary(@RequestParam int fiscalYear) { return ApiResp.ok(summarize(fiscalYear)); } public record YearComparison(List years) { } /** 历史年度对比:给定若干年度(默认近 3 年含当年)的执行汇总并列。 */ @GetMapping("/compare") public ApiResp compare(@RequestParam(required = false) Integer from, @RequestParam(required = false) Integer to) { int end = to != null ? to : java.time.Year.now().getValue(); int start = from != null ? from : end - 2; if (start > end) { int t = start; start = end; end = t; } if (end - start > 10) { start = end - 10; } List out = new ArrayList<>(); for (int y = start; y <= end; y++) { out.add(summarize(y)); } return ApiResp.ok(new YearComparison(out)); } private YearSummary summarize(int fiscalYear) { Map budgetByCat = new LinkedHashMap<>(); Map usedByCat = new LinkedHashMap<>(); for (String c : CATEGORIES) { budgetByCat.put(c, Money.ZERO); usedByCat.put(c, Money.ZERO); } for (IpBudget b : budgetRepo.findByFiscalYear(fiscalYear)) { String c = budgetByCat.containsKey(b.getCategory()) ? b.getCategory() : "其他"; budgetByCat.merge(c, Money.nz(b.getAmount()), Money::add); usedByCat.merge(c, Money.nz(b.getUsed()), Money::add); } List lines = new ArrayList<>(); BigDecimal totalB = Money.ZERO; BigDecimal totalU = Money.ZERO; for (String c : CATEGORIES) { BigDecimal bud = budgetByCat.get(c); BigDecimal used = usedByCat.get(c); totalB = Money.add(totalB, bud); totalU = Money.add(totalU, used); double rate = Money.lte0(bud) ? 0d : used.divide(bud, 4, java.math.RoundingMode.HALF_UP).doubleValue(); lines.add(new CategoryLine(c, bud.doubleValue(), used.doubleValue(), Money.sub(bud, used).doubleValue(), rate, Money.gt(used, bud))); } double totalRate = Money.lte0(totalB) ? 0d : totalU.divide(totalB, 4, java.math.RoundingMode.HALF_UP).doubleValue(); return new YearSummary(fiscalYear, totalB.doubleValue(), totalU.doubleValue(), Money.sub(totalB, totalU).doubleValue(), totalRate, lines); } // ---------- 投入产出分析(含被引用次数维度,需求功能4) ---------- public record RoiLine(String productLine, double invest, int grantedCount, int totalCount, int citedCount, double costPerGrant) { } /** * 知识产权投入产出分析:按产品线归集 投入金额(IpAsset.totalCost) vs 授权数量 vs 被引用次数, * 并算单件授权成本(投入/授权数)。被引用次数取自 IpAsset.citedCount(外部检索/竞争对手对比回写)。 */ @GetMapping("/roi") public ApiResp> roi() { Map investByLine = new LinkedHashMap<>(); Map grantedByLine = new LinkedHashMap<>(); Map totalByLine = new LinkedHashMap<>(); Map citedByLine = new LinkedHashMap<>(); for (IpAsset a : assetRepo.findAll()) { String line = a.getProductLine() == null || a.getProductLine().isBlank() ? "未分类" : a.getProductLine(); investByLine.merge(line, Money.nz(a.getTotalCost()), Money::add); totalByLine.merge(line, 1, Integer::sum); grantedByLine.merge(line, "授权维持".equals(a.getLegalStatus()) ? 1 : 0, Integer::sum); citedByLine.merge(line, a.getCitedCount() == null ? 0 : a.getCitedCount(), Integer::sum); } List out = new ArrayList<>(); for (String line : investByLine.keySet()) { BigDecimal invest = investByLine.get(line); int granted = grantedByLine.getOrDefault(line, 0); double costPerGrant = granted == 0 ? 0d : invest.divide(BigDecimal.valueOf(granted), 2, java.math.RoundingMode.HALF_UP).doubleValue(); out.add(new RoiLine(line, invest.doubleValue(), granted, totalByLine.getOrDefault(line, 0), citedByLine.getOrDefault(line, 0), costPerGrant)); } out.sort((x, y) -> Double.compare(y.invest(), x.invest())); return ApiResp.ok(out); } // ---------- helpers ---------- private IpBudget find(Long id) { return budgetRepo.findById(id) .orElseThrow(() -> new NotFoundException("知识产权预算不存在:" + id)); } private String normalizeCategory(String c) { if (c == null || c.isBlank()) { return "其他"; } String t = c.trim(); return CATEGORIES.contains(t) ? t : "其他"; } private int yearOf(String date) { if (date != null && date.length() >= 4) { try { return Integer.parseInt(date.substring(0, 4)); } catch (NumberFormatException ignored) { // fall through } } return java.time.Year.now().getValue(); } }