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.FinEmpLoan; import com.kaidi.oa.domain.Voucher; import com.kaidi.oa.repository.FinEmpLoanRepository; import com.kaidi.oa.repository.VoucherRepository; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; 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.PutMapping; 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.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 财务部·员工借款管理(缺口 #2)。 * * 审计缺口:借款管理(员工借款申请/还款核销/逾期扣工资)无专属控制器, * 仅 FinAdvances 覆盖预收/预付场景,员工借款未独立建模。 * * 本控制器提供: * GET / —— 列表(按 status/department/loanType 过滤) * GET /{id} —— 单条明细 * POST / —— 新建借款申请(草稿) * PUT /{id} —— 编辑(草稿/已驳回可修改) * DELETE /{id} —— 删除(仅草稿) * POST /{id}/submit —— 提交审批:草稿 → 待审批 * POST /{id}/approve —— 审批通过:待审批 → 已批准,自动生成借款凭证 * POST /{id}/reject —— 驳回:待审批 → 草稿 * POST /{id}/repay —— 还款核销:已批准/还款中,累加还款额,推进状态 * POST /{id}/deduct-salary —— 标记从工资扣除(逾期处理) * GET /overdue-check —— 逾期检查清单(超还款日且未结清) * GET /summary —— 汇总(各状态借款总额/未还余额) * * 调度:每日 09:00 自动检查逾期,超过预计还款日且未结清的置为「逾期」状态。 * * 写口:FINANCE_PREFIXES(/api/oa/fin-emp-loans) 限 ADMIN/APPROVER。 */ @RestController @RequestMapping("/api/oa/fin-emp-loans") public class FinEmpLoanController { private final FinEmpLoanRepository loanRepo; private final VoucherRepository voucherRepo; public FinEmpLoanController(FinEmpLoanRepository loanRepo, VoucherRepository voucherRepo) { this.loanRepo = loanRepo; this.voucherRepo = voucherRepo; } // ===================== CRUD ===================== @GetMapping public ApiResp> list( @RequestParam(required = false) String status, @RequestParam(required = false) String department, @RequestParam(required = false) String loanType, @RequestParam(required = false) String employeeName) { if (status != null && department != null) return ApiResp.ok(loanRepo.findByStatusAndDepartment(status, department)); if (status != null) return ApiResp.ok(loanRepo.findByStatus(status)); if (department != null) return ApiResp.ok(loanRepo.findByDepartment(department)); if (loanType != null) return ApiResp.ok(loanRepo.findByLoanType(loanType)); if (employeeName != null) return ApiResp.ok(loanRepo.findByEmployeeName(employeeName)); return ApiResp.ok(loanRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id))); } public record LoanRequest( String employeeName, String employeeId, String department, String loanType, String purpose, Double loanAmount, String applyDate, String expectedRepayDate, String remark, String operator) {} @PostMapping public ApiResp create(@RequestBody LoanRequest req) { if (req.employeeName() == null || req.employeeName().isBlank()) throw new ApiException(400, "员工姓名(employeeName)不能为空"); if (req.loanAmount() == null || req.loanAmount() <= 0) throw new ApiException(400, "借款金额(loanAmount)必须大于 0"); FinEmpLoan loan = new FinEmpLoan(); loan.setLoanCode("EL-" + System.currentTimeMillis() % 1000000); loan.setEmployeeName(req.employeeName()); loan.setEmployeeId(req.employeeId()); loan.setDepartment(req.department()); loan.setLoanType(req.loanType() == null ? FinEmpLoan.TYPE_OTHER : req.loanType()); loan.setPurpose(req.purpose()); BigDecimal amt = Money.of(req.loanAmount()); loan.setLoanAmount(amt); loan.setRepaidAmount(BigDecimal.ZERO); loan.setRemainAmount(amt); loan.setApplyDate(req.applyDate() != null ? req.applyDate() : LocalDate.now().toString()); loan.setExpectedRepayDate(req.expectedRepayDate()); loan.setDeductFromSalary(Boolean.FALSE); loan.setStatus(FinEmpLoan.S_DRAFT); loan.setRemark(req.remark()); loan.setOperator(req.operator()); loan.setCreatedAt(Instant.now()); loan.setUpdatedAt(Instant.now()); return ApiResp.ok(loanRepo.save(loan)); } @PutMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody LoanRequest req) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (!FinEmpLoan.S_DRAFT.equals(loan.getStatus())) throw new ApiException(409, "仅草稿状态可编辑,当前状态:" + loan.getStatus()); if (req.employeeName() != null) loan.setEmployeeName(req.employeeName()); if (req.employeeId() != null) loan.setEmployeeId(req.employeeId()); if (req.department() != null) loan.setDepartment(req.department()); if (req.loanType() != null) loan.setLoanType(req.loanType()); if (req.purpose() != null) loan.setPurpose(req.purpose()); if (req.loanAmount() != null) { BigDecimal amt = Money.of(req.loanAmount()); loan.setLoanAmount(amt); loan.setRemainAmount(amt.subtract(Money.nz(loan.getRepaidAmount()))); } if (req.applyDate() != null) loan.setApplyDate(req.applyDate()); if (req.expectedRepayDate() != null) loan.setExpectedRepayDate(req.expectedRepayDate()); if (req.remark() != null) loan.setRemark(req.remark()); loan.setUpdatedAt(Instant.now()); return ApiResp.ok(loanRepo.save(loan)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (!FinEmpLoan.S_DRAFT.equals(loan.getStatus())) throw new ApiException(409, "仅草稿状态可删除,当前状态:" + loan.getStatus()); loanRepo.deleteById(id); return ApiResp.ok(null); } // ===================== 状态机 ===================== @PostMapping("/{id}/submit") @Transactional public ApiResp submit(@PathVariable Long id) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (!FinEmpLoan.S_DRAFT.equals(loan.getStatus())) throw new ApiException(409, "仅草稿状态可提交审批,当前状态:" + loan.getStatus()); if (Money.nz(loan.getLoanAmount()).signum() <= 0) throw new ApiException(400, "借款金额必须大于 0"); loan.setStatus(FinEmpLoan.S_PENDING); loan.setUpdatedAt(Instant.now()); return ApiResp.ok(loanRepo.save(loan)); } public record ApproveRequest(String approver, String approvalComment) {} /** 审批通过:待审批 → 已批准,自动生成借款发放凭证。 */ @PostMapping("/{id}/approve") @Transactional public ApiResp> approve(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (!FinEmpLoan.S_PENDING.equals(loan.getStatus())) throw new ApiException(409, "仅待审批状态可审批,当前状态:" + loan.getStatus()); loan.setStatus(FinEmpLoan.S_APPROVED); if (req != null) { loan.setApprover(req.approver()); loan.setApprovalComment(req.approvalComment()); } loan.setApprovalDate(LocalDate.now().toString()); loan.setUpdatedAt(Instant.now()); loanRepo.save(loan); // 自动生成借款发放凭证(借 其他应收款 / 贷 银行存款) Voucher v = new Voucher(); v.setVoucherNo("EL-" + loan.getLoanCode() + "-" + System.currentTimeMillis() % 10000); v.setVoucherDate(LocalDate.now().toString()); v.setSummary("员工借款发放:" + loan.getEmployeeName() + " / " + loan.getLoanType() + " / " + loan.getPurpose()); v.setDebitAccount("1221 其他应收款"); v.setCreditAccount("1002 银行存款"); v.setAmount(Money.nz(loan.getLoanAmount())); v.setStatus(Voucher.S_DRAFT); v.setPreparer(req != null && req.approver() != null ? req.approver() : "系统"); v.setReversed(Boolean.FALSE); v.setIsReversal(Boolean.FALSE); v.setSourceType("emp-loan"); v.setSourceId(loan.getId()); v.setCreatedAt(Instant.now()); Voucher saved = voucherRepo.save(v); loan.setVoucherId(saved.getId()); loanRepo.save(loan); Map result = new LinkedHashMap<>(); result.put("loanId", loan.getId()); result.put("loanCode", loan.getLoanCode()); result.put("status", loan.getStatus()); result.put("voucherId", saved.getId()); result.put("message", "借款审批通过,已生成借款发放凭证 #" + saved.getId()); return ApiResp.ok(result); } public record RejectRequest(String approver, String approvalComment) {} /** 驳回:待审批 → 草稿。 */ @PostMapping("/{id}/reject") @Transactional public ApiResp reject(@PathVariable Long id, @RequestBody RejectRequest req) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (!FinEmpLoan.S_PENDING.equals(loan.getStatus())) throw new ApiException(409, "仅待审批状态可驳回,当前状态:" + loan.getStatus()); if (req.approvalComment() == null || req.approvalComment().isBlank()) throw new ApiException(400, "驳回须填写审批意见(approvalComment)"); loan.setStatus(FinEmpLoan.S_DRAFT); loan.setApprover(req.approver()); loan.setApprovalComment(req.approvalComment()); loan.setApprovalDate(LocalDate.now().toString()); loan.setUpdatedAt(Instant.now()); return ApiResp.ok(loanRepo.save(loan)); } public record RepayRequest(Double repayAmount, String repayDate, String relatedExpenseCode, String operator) {} /** 还款核销:已批准/还款中 → 更新余额,若余额为 0 则置为「已结清」。 */ @PostMapping("/{id}/repay") @Transactional public ApiResp repay(@PathVariable Long id, @RequestBody RepayRequest req) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (!FinEmpLoan.S_APPROVED.equals(loan.getStatus()) && !FinEmpLoan.S_REPAYING.equals(loan.getStatus()) && !FinEmpLoan.S_OVERDUE.equals(loan.getStatus())) throw new ApiException(409, "仅已批准/还款中/逾期状态可还款,当前状态:" + loan.getStatus()); if (req.repayAmount() == null || req.repayAmount() <= 0) throw new ApiException(400, "还款金额(repayAmount)必须大于 0"); BigDecimal repay = Money.of(req.repayAmount()); BigDecimal remain = Money.nz(loan.getRemainAmount()); if (repay.compareTo(remain) > 0) throw new ApiException(400, "还款金额 " + repay + " 超过未还余额 " + remain); BigDecimal newRepaid = Money.nz(loan.getRepaidAmount()).add(repay); BigDecimal newRemain = remain.subtract(repay); loan.setRepaidAmount(newRepaid); loan.setRemainAmount(newRemain); loan.setStatus(newRemain.signum() == 0 ? FinEmpLoan.S_SETTLED : FinEmpLoan.S_REPAYING); if (newRemain.signum() == 0) loan.setSettleDate(req.repayDate() != null ? req.repayDate() : LocalDate.now().toString()); if (req.relatedExpenseCode() != null) loan.setRelatedExpenseCode(req.relatedExpenseCode()); loan.setUpdatedAt(Instant.now()); // 生成还款凭证(借 银行存款 / 贷 其他应收款) Voucher v = new Voucher(); v.setVoucherNo("ELR-" + loan.getLoanCode() + "-" + System.currentTimeMillis() % 10000); v.setVoucherDate(req.repayDate() != null ? req.repayDate() : LocalDate.now().toString()); v.setSummary("员工还款核销:" + loan.getEmployeeName() + " 还款 " + repay + " 元"); v.setDebitAccount("1002 银行存款"); v.setCreditAccount("1221 其他应收款"); v.setAmount(repay); v.setStatus(Voucher.S_DRAFT); v.setPreparer(req.operator() != null ? req.operator() : "系统"); v.setReversed(Boolean.FALSE); v.setIsReversal(Boolean.FALSE); v.setSourceType("emp-loan-repay"); v.setSourceId(loan.getId()); v.setCreatedAt(Instant.now()); voucherRepo.save(v); return ApiResp.ok(loanRepo.save(loan)); } /** 标记从工资代扣:逾期/已批准 → 设置 deductFromSalary=true。 */ @PostMapping("/{id}/deduct-salary") @Transactional public ApiResp deductSalary(@PathVariable Long id) { FinEmpLoan loan = loanRepo.findById(id) .orElseThrow(() -> new NotFoundException("员工借款记录不存在: " + id)); if (FinEmpLoan.S_SETTLED.equals(loan.getStatus())) throw new ApiException(409, "已结清的借款无需从工资扣除"); loan.setDeductFromSalary(Boolean.TRUE); loan.setUpdatedAt(Instant.now()); return ApiResp.ok(loanRepo.save(loan)); } // ===================== 聚合端点 ===================== /** 逾期检查:返回超过预计还款日且未结清的借款清单。 */ @GetMapping("/overdue-check") public ApiResp>> overdueCheck() { List all = loanRepo.findAll(); String today = LocalDate.now().toString(); List> result = new ArrayList<>(); for (FinEmpLoan loan : all) { if (FinEmpLoan.S_SETTLED.equals(loan.getStatus())) continue; String repayDate = loan.getExpectedRepayDate(); if (repayDate == null) continue; if (repayDate.compareTo(today) < 0) { long overdueDays = ChronoUnit.DAYS.between( LocalDate.parse(repayDate), LocalDate.now()); Map row = new LinkedHashMap<>(); row.put("id", loan.getId()); row.put("loanCode", loan.getLoanCode()); row.put("employeeName", loan.getEmployeeName()); row.put("department", loan.getDepartment()); row.put("loanAmount", Money.nz(loan.getLoanAmount())); row.put("remainAmount", Money.nz(loan.getRemainAmount())); row.put("expectedRepayDate", repayDate); row.put("overdueDays", overdueDays); row.put("status", loan.getStatus()); row.put("deductFromSalary", loan.getDeductFromSalary()); result.add(row); } } return ApiResp.ok(result); } /** 汇总:各状态借款金额统计 + 未还余额汇总。 */ @GetMapping("/summary") public ApiResp> summary() { List all = loanRepo.findAll(); BigDecimal totalLoan = BigDecimal.ZERO; BigDecimal totalRepaid = BigDecimal.ZERO; BigDecimal totalRemain = BigDecimal.ZERO; BigDecimal totalOverdue = BigDecimal.ZERO; long overdueCount = 0; for (FinEmpLoan loan : all) { totalLoan = totalLoan.add(Money.nz(loan.getLoanAmount())); totalRepaid = totalRepaid.add(Money.nz(loan.getRepaidAmount())); totalRemain = totalRemain.add(Money.nz(loan.getRemainAmount())); if (FinEmpLoan.S_OVERDUE.equals(loan.getStatus())) { totalOverdue = totalOverdue.add(Money.nz(loan.getRemainAmount())); overdueCount++; } } Map result = new LinkedHashMap<>(); result.put("totalLoanAmount", totalLoan); result.put("totalRepaidAmount", totalRepaid); result.put("totalRemainAmount", totalRemain); result.put("totalOverdueAmount", totalOverdue); result.put("overdueCount", overdueCount); result.put("totalCount", all.size()); return ApiResp.ok(result); } // ===================== 调度 — 每日逾期自动标记 ===================== /** * 每日 09:00 自动检查逾期:超过预计还款日且未结清的记录置为「逾期」状态。 */ @Scheduled(cron = "0 0 9 * * *") @Transactional public void autoMarkOverdue() { String today = LocalDate.now().toString(); List active = loanRepo.findAll().stream() .filter(l -> !FinEmpLoan.S_SETTLED.equals(l.getStatus()) && !FinEmpLoan.S_DRAFT.equals(l.getStatus()) && !FinEmpLoan.S_PENDING.equals(l.getStatus())) .toList(); for (FinEmpLoan loan : active) { String repayDate = loan.getExpectedRepayDate(); if (repayDate != null && repayDate.compareTo(today) < 0 && !FinEmpLoan.S_OVERDUE.equals(loan.getStatus())) { long days = ChronoUnit.DAYS.between(LocalDate.parse(repayDate), LocalDate.now()); loan.setStatus(FinEmpLoan.S_OVERDUE); loan.setOverdueDays((int) days); loan.setUpdatedAt(Instant.now()); loanRepo.save(loan); } } } }