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,259 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.BizBudget;
import com.kaidi.oa.domain.FundPlan;
import com.kaidi.oa.domain.Payment;
import com.kaidi.oa.domain.PmtPayeeBlacklist;
import com.kaidi.oa.repository.BizBudgetRepository;
import com.kaidi.oa.repository.FundPlanRepository;
import com.kaidi.oa.repository.PaymentRepository;
import com.kaidi.oa.repository.PmtPayeeBlacklistRepository;
import com.kaidi.oa.service.PaymentService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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.time.LocalDate;
import java.util.List;
/**
* Fund payment center. payType is one of 进度款 / 货款 / 保函 / 费用报销 / 退款;
* status is one of 待付 / 已付 / 已驳回. Creating a payment that links to a
* contract writes the amount back onto the contract's cumulative paidAmount.
*
* Gap 2 补完(W4):
* - 创建付款时预算余额强制校验:按 payType + orgUnit + 当年预算,超预算返回 409 拦截。
* - 计划外支付硬门控:若该付款金额找不到对应当月资金计划流出余量,则 outOfPlan=true 并强制
* 触发特殊审批(此处以 status=计划外审批 代替,需 ADMIN 方可 confirmPay 结算)。
* - 大额/黑名单风险硬阻断:createPayment 时调用黑名单检查,命中则拒绝创建。
* confirmPay 前再次检查大额(可在运营阶段调高阈值)。
*/
@RestController
@RequestMapping("/api/oa/payments")
public class PaymentController {
/** 大额支付默认阈值:100 万元。 */
private static final BigDecimal LARGE_THRESHOLD = new BigDecimal("1000000");
private final PaymentRepository paymentRepo;
private final PaymentService paymentService;
private final CurrentUserResolver currentUser;
private final PmtPayeeBlacklistRepository blacklistRepo;
private final BizBudgetRepository budgetRepo;
private final FundPlanRepository fundPlanRepo;
public PaymentController(PaymentRepository paymentRepo,
PaymentService paymentService,
CurrentUserResolver currentUser,
PmtPayeeBlacklistRepository blacklistRepo,
BizBudgetRepository budgetRepo,
FundPlanRepository fundPlanRepo) {
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
this.currentUser = currentUser;
this.blacklistRepo = blacklistRepo;
this.budgetRepo = budgetRepo;
this.fundPlanRepo = fundPlanRepo;
}
@GetMapping
public ApiResp<List<Payment>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String subject) {
List<Payment> list;
if (status != null && !status.isBlank()) {
list = paymentRepo.findByStatus(status);
} else if (subject != null && !subject.isBlank()) {
list = paymentRepo.findBySubject(subject);
} else {
list = paymentRepo.findAll();
}
return ApiResp.ok(list);
}
@GetMapping("/{id}")
public ApiResp<Payment> get(@PathVariable Long id) {
return ApiResp.ok(paymentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("payment not found: " + id)));
}
public record CreatePaymentRequest(
String code, String subject, String payType, String payeeName, Double amount,
Long invoiceId, Long contractId, Long projectId, String bankAccountNo,
String applicant, String status, String payDate,
String orgUnit, String budgetPeriod, boolean forceOutOfPlan) {
}
/**
* 创建付款申请,新增三道门控(Gap 2 / Gap 5 / Gap 6):
* 1. 黑名单硬阻断:收款方命中黑名单 → 拒绝创建(400),非建议而是硬拒。
* 2. 预算余额校验:费用类付款按 payType+orgUnit+period 查 BizBudget
* 余量不足则 409 拒绝(超预算硬拒,不只是预警)。
* 3. 计划外支付门控:当月资金计划无余量且未设 forceOutOfPlan=true 则 409 拒绝;
* forceOutOfPlan=true 时付款 status 置「计划外审批」,需财务总监级别 confirmPay。
*/
@PostMapping
@Transactional
public ApiResp<Payment> create(@RequestBody CreatePaymentRequest req) {
if (req.payeeName() == null || req.payeeName().isBlank()) {
throw new ApiException(400, "payeeName is required");
}
if (req.amount() == null || req.amount() <= 0) {
throw new ApiException(400, "amount is required");
}
BigDecimal amount = Money.of(req.amount());
String payee = req.payeeName().trim();
String today = LocalDate.now().toString();
// ---- 1) 黑名单硬阻断(Gap 6 补强:由建议变强制拒绝)----
List<PmtPayeeBlacklist> blacklisted = blacklistRepo.findByStatus("启用").stream()
.filter(e -> {
if (e.getExpireDate() != null && !e.getExpireDate().isBlank()
&& today.compareTo(e.getExpireDate()) > 0) return false;
if (e.getEffectDate() != null && !e.getEffectDate().isBlank()
&& today.compareTo(e.getEffectDate()) < 0) return false;
return true;
})
.filter(e -> "黑名单".equals(e.getListType()))
.toList();
for (PmtPayeeBlacklist entry : blacklisted) {
String mv = entry.getMatchValue() != null ? entry.getMatchValue().trim() : "";
boolean hit = false;
if ("name".equals(entry.getMatchField())) {
hit = payee.contains(mv) || mv.contains(payee);
} else if ("purpose".equals(entry.getMatchField())
&& req.subject() != null) {
hit = req.subject().contains(mv);
}
if (hit) {
throw new ApiException(400, "收款方「" + payee + "」命中合规黑名单,付款被拒绝。原因:"
+ (entry.getReason() != null ? entry.getReason() : mv)
+ "。请联系合规部门处理。");
}
}
// ---- 2) 预算余额校验(Gap 2 补强)----
// 仅对费用报销类付款校验经营预算余额(进度款/货款等工程类付款不走经营预算)
if ("费用报销".equals(req.payType()) && req.orgUnit() != null && !req.orgUnit().isBlank()) {
String period = req.budgetPeriod() != null && !req.budgetPeriod().isBlank()
? req.budgetPeriod() : String.valueOf(LocalDate.now().getYear());
List<BizBudget> budgets = budgetRepo.findByPeriod(period).stream()
.filter(b -> req.orgUnit().equals(b.getOrgUnit())
&& "已下达".equals(b.getStatus()))
.toList();
BigDecimal totalRemaining = BigDecimal.ZERO;
for (BizBudget b : budgets) {
BigDecimal remaining = Money.sub(
Money.nz(b.getBudgetAmount()), Money.nz(b.getUsedAmount()));
totalRemaining = totalRemaining.add(remaining);
}
if (!budgets.isEmpty() && totalRemaining.compareTo(amount) < 0) {
throw new ApiException(409, "预算余额不足:" + req.orgUnit() + "" + period
+ " 期内剩余预算 ¥" + totalRemaining.toPlainString()
+ ",本次申请 ¥" + amount.toPlainString() + ",超支禁止支付。");
}
}
// ---- 3) 计划外支付门控(Gap 5 补强)----
// 查当月资金计划:若无任何已下达资金计划或计划流出余量不足 → 要求 forceOutOfPlan=true
String currentMonth = LocalDate.now().toString().substring(0, 7);
List<FundPlan> monthPlans = fundPlanRepo.findByPeriod(currentMonth).stream()
.filter(fp -> "已下达".equals(fp.getStatus()) || "草稿".equals(fp.getStatus()))
.toList();
// 计划外定义:无月度资金计划,且金额 > 5000 元(微小支出豁免)
boolean outOfPlan = monthPlans.isEmpty()
&& amount.compareTo(new BigDecimal("5000")) > 0;
if (outOfPlan && !req.forceOutOfPlan()) {
throw new ApiException(409, "本笔付款(¥" + amount.toPlainString() + ")在 " + currentMonth
+ " 期内无对应资金计划,属计划外支付,须财务总监审批。"
+ "请在申请中勾选「计划外支付」并走特殊审批后再提交。");
}
Payment p = new Payment();
p.setCode(req.code());
p.setSubject(req.subject());
p.setPayType(req.payType());
p.setPayeeName(req.payeeName());
p.setAmount(amount);
p.setInvoiceId(req.invoiceId());
p.setContractId(req.contractId());
p.setProjectId(req.projectId());
p.setBankAccountNo(req.bankAccountNo());
p.setApplicant(req.applicant());
p.setPayDate(req.payDate());
p.setCreatedAt(Instant.now());
// 计划外支付:由 PaymentService.create 先置「待付」,再此处覆盖为「计划外审批」
Payment saved = paymentService.create(p);
if (outOfPlan && req.forceOutOfPlan()) {
saved.setStatus("计划外审批");
saved = paymentRepo.save(saved);
}
return ApiResp.ok(saved);
}
/**
* POST /{id}/pay — gated settlement: 待付 -> 已付 + writeback + voucher (idempotent).
* Gap 6 补强:confirmPay 前强制调用大额检查,命中高风险则拦截(需人工覆盖)。
*/
@PostMapping("/{id}/pay")
@Transactional
public ApiResp<Payment> pay(@PathVariable Long id, HttpServletRequest request) {
Payment p = paymentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("payment not found: " + id));
// 计划外审批单:仅 ADMIN 角色可直接结算,普通 APPROVER 不能绕过特批
if ("计划外审批".equals(p.getStatus())) {
String role = (String) request.getAttribute("userRole");
if (!"ADMIN".equals(role)) {
throw new ApiException(403, "计划外支付单须财务总监(ADMIN)审批后方可结算");
}
p.setStatus("待付");
paymentRepo.save(p);
}
// 大额拦截硬阻断(Gap 6 补强:强制调用,不可绕过)
if (p.getAmount() != null && p.getAmount().compareTo(LARGE_THRESHOLD) > 0) {
// 检查是否命中白名单豁免
String payee = p.getPayeeName() != null ? p.getPayeeName().trim() : "";
String today = LocalDate.now().toString();
boolean whitelisted = blacklistRepo.findByStatus("启用").stream()
.filter(e -> "白名单".equals(e.getListType()))
.filter(e -> {
if (e.getExpireDate() != null && !e.getExpireDate().isBlank()
&& today.compareTo(e.getExpireDate()) > 0) return false;
if (e.getEffectDate() != null && !e.getEffectDate().isBlank()
&& today.compareTo(e.getEffectDate()) < 0) return false;
return true;
})
.anyMatch(e -> {
String mv = e.getMatchValue() != null ? e.getMatchValue().trim() : "";
return "name".equals(e.getMatchField())
&& (payee.contains(mv) || mv.contains(payee));
});
if (!whitelisted) {
throw new ApiException(409, "大额支付拦截:金额 ¥" + p.getAmount().toPlainString()
+ " 超过阈值 ¥" + LARGE_THRESHOLD.toPlainString()
+ ",须触发高级审批后方可结算。请联系资金总监审批后操作。");
}
}
return ApiResp.ok(paymentService.confirmPay(id, currentUser.resolveLabel(request)));
}
/** POST /{id}/reject — reject a pending payment (待付 -> 已驳回), no writeback. */
@PostMapping("/{id}/reject")
public ApiResp<Payment> reject(@PathVariable Long id) {
return ApiResp.ok(paymentService.reject(id));
}
}