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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,270 @@
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<SafetyInvestment>> 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<SafetyInvestment> 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<SafetyInvestment> 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<SafetyInvestment> 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<Void> 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<CatRow> byCategory) {
}
/** 安全投入汇总:按类别归集计划/使用金额 + 使用率,可按 period 过滤。 */
@GetMapping("/summary")
public ApiResp<InvestSummary> summary(@RequestParam(required = false) String period) {
List<SafetyInvestment> all = (period == null || period.isBlank())
? repo.findAll() : repo.findByPeriod(period);
Map<String, BigDecimal[]> byCat = new LinkedHashMap<>();
Map<String, Long> 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<CatRow> rows = new ArrayList<>();
for (Map.Entry<String, BigDecimal[]> 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<FinanceCheckRow> details, List<String> violations) {
}
/**
* 安全投入与财务联动校验端点(补完 Gap 2 缺口)。
* 逻辑:
* 1. 汇总本期(period)所有安全投入计划额与实际使用额;
* 2. 关联财务预算中 element=安全费 / 管理费 的预算条目,获取 budgetAmount(提取上限基准);
* 3. 按类别校验使用率(已使用/计划≥80% 视为合规),并核查总提取额是否超出预算;
* 4. 汇总违规项,返回可供财务审核的合规报告。
*
* 此端点不改写任何数据,纯读侧聚合。
*/
@GetMapping("/finance-check")
public ApiResp<FinanceCheckResult> financeCheck(@RequestParam(required = false) String period) {
String checkPeriod = (period == null || period.isBlank())
? String.valueOf(java.time.LocalDate.now().getYear()) : period;
// 1. 汇总安全投入
List<SafetyInvestment> investments = repo.findByPeriod(checkPeriod);
if (investments.isEmpty()) {
// 没有按 period 精确匹配时退回所有
investments = repo.findAll();
}
Map<String, BigDecimal[]> 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<Budget> 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<FinanceCheckRow> details = new ArrayList<>();
List<String> violations = new ArrayList<>();
// 法规阈值:使用率 >= 80% 合规(《安全生产费用提取和使用管理办法》)
BigDecimal USE_RATE_THRESHOLD = new BigDecimal("80.0");
for (Map.Entry<String, BigDecimal[]> 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));
}
}