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.CrmPettyCash; import com.kaidi.oa.repository.CrmPettyCashRepository; 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.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.time.Instant; import java.time.LocalDate; import java.util.List; import java.util.Map; /** * 办事处备用金/借款管理(市场部·费用与预算控制·备用金闭环)。 *

* 直接闭合审计缺口:「无备用金/借款/核销闭环实体与端点:无法做员工预借款、 * 备用金台账、借支后凭报销冲销核销的资金周转管理」。 *

* 业务动作: * POST /{id}/submit 草稿 → 待审批 * POST /{id}/approve 待审批 → 已批准(approve=true)或 已驳回(approve=false) * POST /{id}/disburse 已批准 → 已借出(拨款) * POST /{id}/verify 已借出 → 核销申请 → 核销完成(传实际金额,自动算余额) * GET /summary 台账汇总(待审批数、在途借款总额、已借未核销总额) */ @RestController @RequestMapping("/api/oa/crm-petty-cash") public class CrmPettyCashController { private final CrmPettyCashRepository repo; public CrmPettyCashController(CrmPettyCashRepository repo) { this.repo = repo; } @GetMapping public ApiResp> list( @RequestParam(required = false) String status, @RequestParam(required = false) String orgUnit, @RequestParam(required = false) String type, @RequestParam(required = false) String applicant) { if (status != null && !status.isBlank()) return ApiResp.ok(repo.findByStatus(status)); if (orgUnit != null && !orgUnit.isBlank()) return ApiResp.ok(repo.findByOrgUnit(orgUnit)); if (type != null && !type.isBlank()) return ApiResp.ok(repo.findByType(type)); if (applicant != null && !applicant.isBlank()) return ApiResp.ok(repo.findByApplicant(applicant)); return ApiResp.ok(repo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id))); } /** GET /summary -> 台账汇总(待审批数、在途借款总额、已借未核销总额)。 */ @GetMapping("/summary") public ApiResp> summary(@RequestParam(required = false) String orgUnit) { List all = orgUnit != null && !orgUnit.isBlank() ? repo.findByOrgUnit(orgUnit) : repo.findAll(); long pendingApproval = all.stream().filter(c -> "待审批".equals(c.getStatus())).count(); double inTransit = all.stream() .filter(c -> "已借出".equals(c.getStatus())) .mapToDouble(c -> Money.nz(c.getPendingAmount()).doubleValue()).sum(); double totalBorrowed = all.stream() .filter(c -> List.of("已批准", "已借出").contains(c.getStatus())) .mapToDouble(c -> Money.nz(c.getAmount()).doubleValue()).sum(); long overdueCount = all.stream() .filter(c -> "已借出".equals(c.getStatus()) && c.getExpectedReturnDate() != null && LocalDate.now().isAfter(LocalDate.parse(c.getExpectedReturnDate()))) .count(); return ApiResp.ok(Map.of( "pendingApproval", pendingApproval, "inTransitAmount", inTransit, "totalBorrowedAmount", totalBorrowed, "overdueCount", overdueCount)); } public record CreateRequest( String type, String applicant, String orgUnit, Double amount, Long opportunityId, Long projectId, String purpose, String expectedReturnDate, String applyDate) { } @PostMapping public ApiResp create(@RequestBody CreateRequest req) { if (req.type() == null || req.type().isBlank()) throw new ApiException(400, "type 不能为空"); if (req.applicant() == null || req.applicant().isBlank()) throw new ApiException(400, "申请人不能为空"); if (req.amount() != null && req.amount() < 0) throw new ApiException(400, "金额不能为负数"); CrmPettyCash c = new CrmPettyCash(); long seq = repo.count() + 1; c.setCode("BYJJ-" + LocalDate.now().getYear() + "-" + String.format("%04d", seq)); c.setType(req.type()); c.setApplicant(req.applicant()); c.setOrgUnit(req.orgUnit()); c.setAmount(Money.of(req.amount())); c.setOpportunityId(req.opportunityId()); c.setProjectId(req.projectId()); c.setPurpose(req.purpose()); c.setExpectedReturnDate(req.expectedReturnDate()); c.setApplyDate(req.applyDate() != null ? req.applyDate() : LocalDate.now().toString()); c.setStatus("草稿"); c.setVerifiedAmount(Money.ZERO); c.setPendingAmount(Money.of(req.amount())); c.setCreatedAt(Instant.now()); return ApiResp.ok(repo.save(c)); } public record UpdateRequest( String type, String applicant, String orgUnit, Double amount, Long opportunityId, Long projectId, String purpose, String expectedReturnDate, String applyDate) { } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody UpdateRequest req) { CrmPettyCash c = repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)); if (!List.of("草稿", "已驳回").contains(c.getStatus())) { throw new ApiException(409, "仅「草稿」或「已驳回」状态可编辑,当前:" + c.getStatus()); } if (req.type() != null) c.setType(req.type()); if (req.applicant() != null) c.setApplicant(req.applicant()); if (req.orgUnit() != null) c.setOrgUnit(req.orgUnit()); if (req.amount() != null) { if (req.amount() < 0) throw new ApiException(400, "金额不能为负数"); c.setAmount(Money.of(req.amount())); c.setPendingAmount(Money.of(req.amount())); } if (req.opportunityId() != null) c.setOpportunityId(req.opportunityId()); if (req.projectId() != null) c.setProjectId(req.projectId()); if (req.purpose() != null) c.setPurpose(req.purpose()); if (req.expectedReturnDate() != null) c.setExpectedReturnDate(req.expectedReturnDate()); if (req.applyDate() != null) c.setApplyDate(req.applyDate()); return ApiResp.ok(repo.save(c)); } /** POST /{id}/submit -> 草稿/已驳回 → 待审批。 */ @PostMapping("/{id}/submit") public ApiResp submit(@PathVariable Long id) { CrmPettyCash c = repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)); if (!List.of("草稿", "已驳回").contains(c.getStatus())) { throw new ApiException(409, "仅「草稿」或「已驳回」可提交,当前:" + c.getStatus()); } if (c.getPurpose() == null || c.getPurpose().isBlank()) { throw new ApiException(400, "请填写用途说明再提交"); } c.setStatus("待审批"); return ApiResp.ok(repo.save(c)); } public record ApproveRequest(Boolean approve, String approver, String comment) { } /** POST /{id}/approve -> 待审批 → 已批准(approve=true)或 已驳回(approve=false)。 */ @PostMapping("/{id}/approve") public ApiResp approve(@PathVariable Long id, @RequestBody ApproveRequest req) { CrmPettyCash c = repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)); if (!"待审批".equals(c.getStatus())) { throw new ApiException(409, "仅「待审批」状态可审批,当前:" + c.getStatus()); } boolean pass = req.approve() != null && req.approve(); c.setStatus(pass ? "已批准" : "已驳回"); c.setApprover(req.approver()); c.setApproveComment(req.comment()); c.setApprovedAt(Instant.now()); return ApiResp.ok(repo.save(c)); } /** POST /{id}/disburse -> 已批准 → 已借出(实际拨款)。 */ @PostMapping("/{id}/disburse") public ApiResp disburse(@PathVariable Long id) { CrmPettyCash c = repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)); if (!"已批准".equals(c.getStatus())) { throw new ApiException(409, "仅「已批准」状态可拨款,当前:" + c.getStatus()); } c.setStatus("已借出"); return ApiResp.ok(repo.save(c)); } public record VerifyRequest(Double verifiedAmount, String verifyDate, String voucherNote) { } /** * POST /{id}/verify -> 已借出 → 核销(传实际报销额,自动算未核销余额)。 * 若 verifiedAmount == amount,则状态变「已核销」,否则留「核销中」允许多次核销。 */ @PostMapping("/{id}/verify") @Transactional public ApiResp verify(@PathVariable Long id, @RequestBody VerifyRequest req) { CrmPettyCash c = repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)); if (!List.of("已借出", "核销中").contains(c.getStatus())) { throw new ApiException(409, "仅「已借出」或「核销中」状态可核销,当前:" + c.getStatus()); } if (req.verifiedAmount() == null || req.verifiedAmount() < 0) { throw new ApiException(400, "核销金额不能为空或负数"); } java.math.BigDecimal addVerify = Money.of(req.verifiedAmount()); java.math.BigDecimal totalVerified = Money.add(Money.nz(c.getVerifiedAmount()), addVerify); if (Money.gt(totalVerified, Money.nz(c.getAmount()))) { throw new ApiException(400, "累计核销额 " + totalVerified + " 超过借款额 " + c.getAmount()); } c.setVerifiedAmount(totalVerified); c.setPendingAmount(Money.add(Money.nz(c.getAmount()), totalVerified.negate())); if (req.verifyDate() != null) c.setVerifyDate(req.verifyDate()); if (req.voucherNote() != null) c.setVoucherNote(req.voucherNote()); boolean fullyVerified = !Money.gt(c.getPendingAmount(), java.math.BigDecimal.ZERO); c.setStatus(fullyVerified ? "已核销" : "核销中"); return ApiResp.ok(repo.save(c)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { CrmPettyCash c = repo.findById(id) .orElseThrow(() -> new NotFoundException("备用金记录不存在: " + id)); if (List.of("已借出", "已核销").contains(c.getStatus())) { throw new ApiException(409, "已借出或已核销记录不可删除"); } repo.deleteById(id); return ApiResp.ok(null); } }