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.AdminQuota; import com.kaidi.oa.domain.AdminRequisition; import com.kaidi.oa.domain.AdminStockMove; import com.kaidi.oa.domain.AdminSupply; import com.kaidi.oa.repository.AdminQuotaRepository; import com.kaidi.oa.repository.AdminRequisitionRepository; import com.kaidi.oa.repository.AdminStockMoveRepository; import com.kaidi.oa.repository.AdminSupplyRepository; import com.kaidi.oa.service.AdminQuotaService; 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.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; /** * 行政/办公室·物资领用申请(领用定额控制 + 超定额审批工作流)。 * * 状态机:提交时按 {@link AdminQuotaService} 自动判定是否超定额—— * - 未超定额 → 直接「已通过」; * - 超定额 → 进入「待审批」需特别审批,approve 通过 / reject 驳回。 * 「已通过」单可 issue 出库:级联生成 {@link AdminStockMove} 出库流水、扣减物资结存量、 * 把领用单置「已出库」并回填出库流水 id(出库金额=数量×参考单价,归集领用部门做费用分摊)。 * * 定额/物资写读含成本归集口径,已登记进 FINANCE_PREFIXES + SENSITIVE_READ_PREFIXES。 */ @RestController @RequestMapping("/api/oa/admin-requisitions") public class AdminRequisitionController { private final AdminRequisitionRepository reqRepo; private final AdminSupplyRepository supplyRepo; private final AdminStockMoveRepository moveRepo; private final AdminQuotaRepository quotaRepo; private final AdminQuotaService quotaService; public AdminRequisitionController(AdminRequisitionRepository reqRepo, AdminSupplyRepository supplyRepo, AdminStockMoveRepository moveRepo, AdminQuotaRepository quotaRepo, AdminQuotaService quotaService) { this.reqRepo = reqRepo; this.supplyRepo = supplyRepo; this.moveRepo = moveRepo; this.quotaRepo = quotaRepo; this.quotaService = quotaService; } // ---------- 领用申请单 ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) String status) { if (status != null && !status.isBlank()) { return ApiResp.ok(reqRepo.findByStatusOrderByIdDesc(status)); } return ApiResp.ok(reqRepo.findAllByOrderByIdDesc()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(reqRepo.findById(id) .orElseThrow(() -> new NotFoundException("requisition not found: " + id))); } public record RequisitionRequest( Long supplyId, Double qty, String applicant, String dept, String post, String purpose, String applyDate) { } /** * 提交领用申请:自动按定额判超定额并决定初始状态(未超→已通过 / 超→待审批)。 */ @PostMapping @Transactional public ApiResp create(@RequestBody RequisitionRequest req) { if (req.supplyId() == null) { throw new ApiException(400, "请选择领用物资(supplyId)"); } if (req.applicant() == null || req.applicant().isBlank()) { throw new ApiException(400, "申请人(applicant) 不能为空"); } double qty = req.qty() == null ? 0 : req.qty(); if (qty <= 0) { throw new ApiException(400, "领用数量必须大于 0"); } AdminSupply s = supplyRepo.findById(req.supplyId()) .orElseThrow(() -> new NotFoundException("supply not found: " + req.supplyId())); AdminQuotaService.QuotaEval eval = quotaService.evaluate(req.supplyId(), req.applicant(), req.dept(), req.post(), qty); AdminRequisition r = new AdminRequisition(); r.setCode("LY-" + (reqRepo.count() + 1)); r.setSupplyId(s.getId()); r.setSupplyName(s.getName()); r.setUnit(s.getUnit()); r.setQty(qty); r.setApplicant(req.applicant()); r.setDept(req.dept() == null || req.dept().isBlank() ? s.getDefaultDept() : req.dept()); r.setPost(req.post()); r.setPurpose(req.purpose()); r.setOverQuota(eval.overQuota()); r.setMonthUsed(eval.monthUsedIncl()); r.setQuotaLimit(eval.quotaLimit()); r.setApplyDate(req.applyDate() == null || req.applyDate().isBlank() ? LocalDate.now().toString() : req.applyDate()); // 未超定额直接通过,超定额转特别审批。 r.setStatus(eval.overQuota() ? "待审批" : "已通过"); r.setCreatedAt(Instant.now()); return ApiResp.ok(reqRepo.save(r)); } public record ApproveRequest(String approver, String comment) { } /** 超定额特别审批通过:仅「待审批」可通过。 */ @PostMapping("/{id}/approve") @Transactional public ApiResp approve(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) { AdminRequisition r = reqRepo.findById(id) .orElseThrow(() -> new NotFoundException("requisition not found: " + id)); if (!"待审批".equals(r.getStatus())) { throw new ApiException(409, "仅「待审批」的领用单可审批,当前为:" + r.getStatus()); } r.setStatus("已通过"); if (req != null) { r.setApprover(req.approver()); r.setApproveComment(req.comment()); } return ApiResp.ok(reqRepo.save(r)); } /** 超定额特别审批驳回:仅「待审批」可驳回。 */ @PostMapping("/{id}/reject") @Transactional public ApiResp reject(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) { AdminRequisition r = reqRepo.findById(id) .orElseThrow(() -> new NotFoundException("requisition not found: " + id)); if (!"待审批".equals(r.getStatus())) { throw new ApiException(409, "仅「待审批」的领用单可驳回,当前为:" + r.getStatus()); } r.setStatus("已驳回"); if (req != null) { r.setApprover(req.approver()); r.setApproveComment(req.comment()); } return ApiResp.ok(reqRepo.save(r)); } /** * 出库领用:仅「已通过」可出库。级联生成出库流水、扣减物资结存量、置「已出库」并回填流水 id。 * 库存不足时拒绝。 */ @PostMapping("/{id}/issue") @Transactional public ApiResp issue(@PathVariable Long id) { AdminRequisition r = reqRepo.findById(id) .orElseThrow(() -> new NotFoundException("requisition not found: " + id)); if (!"已通过".equals(r.getStatus())) { throw new ApiException(409, "仅「已通过」的领用单可出库,当前为:" + r.getStatus()); } AdminSupply s = supplyRepo.findById(r.getSupplyId()) .orElseThrow(() -> new NotFoundException("supply not found: " + r.getSupplyId())); double qty = r.getQty() == null ? 0 : r.getQty(); double onHand = s.getOnHand() == null ? 0 : s.getOnHand(); if (onHand < qty) { throw new ApiException(409, "库存不足:当前结存 " + onHand + ",申请出库 " + qty); } AdminStockMove m = new AdminStockMove(); m.setSupplyId(s.getId()); m.setSupplyCode(s.getCode()); m.setSupplyName(s.getName()); m.setDirection("出库"); m.setQty(qty); m.setAmount(Money.of(BigDecimal.valueOf(qty).multiply(Money.nz(s.getRefPrice())))); m.setDept(r.getDept()); m.setHandler(r.getApplicant()); m.setMoveDate(LocalDate.now().toString()); m.setRequisitionId(r.getId()); m.setRemark("领用单 " + r.getCode() + " 出库"); m.setCreatedAt(Instant.now()); AdminStockMove savedMove = moveRepo.save(m); s.setOnHand(onHand - qty); supplyRepo.save(s); r.setStatus("已出库"); r.setStockMoveId(savedMove.getId()); return ApiResp.ok(reqRepo.save(r)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { AdminRequisition r = reqRepo.findById(id) .orElseThrow(() -> new NotFoundException("requisition not found: " + id)); if ("已出库".equals(r.getStatus())) { throw new ApiException(409, "已出库的领用单不可删除"); } reqRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 领用定额配置 ---------- @GetMapping("/quotas") public ApiResp> quotas() { return ApiResp.ok(quotaRepo.findAll()); } public record QuotaRequest(Long supplyId, String post, String dept, Double monthlyLimit, String remark) { } @PostMapping("/quotas") public ApiResp createQuota(@RequestBody QuotaRequest req) { if (req.supplyId() == null) { throw new ApiException(400, "请选择定额物资(supplyId)"); } AdminSupply s = supplyRepo.findById(req.supplyId()) .orElseThrow(() -> new NotFoundException("supply not found: " + req.supplyId())); AdminQuota q = new AdminQuota(); q.setSupplyId(s.getId()); q.setSupplyName(s.getName()); q.setPost(req.post()); q.setDept(req.dept()); q.setMonthlyLimit(req.monthlyLimit() == null ? 0d : req.monthlyLimit()); q.setRemark(req.remark()); q.setCreatedAt(Instant.now()); return ApiResp.ok(quotaRepo.save(q)); } @DeleteMapping("/quotas/{id}") public ApiResp deleteQuota(@PathVariable Long id) { if (!quotaRepo.existsById(id)) { throw new NotFoundException("quota not found: " + id); } quotaRepo.deleteById(id); return ApiResp.ok(null); } }