Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/IpBudgetController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

376 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/APPROVERFINANCE_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<String> 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<IpBudget>> 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<IpBudget> 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<IpBudget> 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<IpBudget> 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<IpBudget> 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<Void> 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<IpBudget> 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<IpBudget> 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<IpBudget> 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<CategoryLine> byCategory) {
}
/** 某年度预算执行汇总:按类目自动汇总 budget/used/balance/占用率 + 超支标记。 */
@GetMapping("/summary")
public ApiResp<YearSummary> summary(@RequestParam int fiscalYear) {
return ApiResp.ok(summarize(fiscalYear));
}
public record YearComparison(List<YearSummary> years) {
}
/** 历史年度对比:给定若干年度(默认近 3 年含当年)的执行汇总并列。 */
@GetMapping("/compare")
public ApiResp<YearComparison> 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<YearSummary> 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<String, BigDecimal> budgetByCat = new LinkedHashMap<>();
Map<String, BigDecimal> 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<CategoryLine> 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<List<RoiLine>> roi() {
Map<String, BigDecimal> investByLine = new LinkedHashMap<>();
Map<String, Integer> grantedByLine = new LinkedHashMap<>();
Map<String, Integer> totalByLine = new LinkedHashMap<>();
Map<String, Integer> 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<RoiLine> 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();
}
}