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>
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user