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(@RequestParam(required = false) String status, @RequestParam(required = false) String subject) { List 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 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 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 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 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 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 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 reject(@PathVariable Long id) { return ApiResp.ok(paymentService.reject(id)); } }