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