恢复点(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>
271 lines
13 KiB
Java
271 lines
13 KiB
Java
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));
|
|
}
|
|
}
|