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.AdminCleaningSchedule; import com.kaidi.oa.domain.AdminCleaningSupply; import com.kaidi.oa.domain.AdminCleaningSupplyMove; import com.kaidi.oa.repository.AdminCleaningScheduleRepository; import com.kaidi.oa.repository.AdminCleaningSupplyMoveRepository; import com.kaidi.oa.repository.AdminCleaningSupplyRepository; 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.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; /** * 行政/办公室·保洁清洁物资专属管理(补深「保洁与绿化·清洁物资管理」PARTIAL 缺口,GAP 6, low)。 * * 独立于 AdminSupplyController(通用物资),聚焦保洁域: * - 清洁物资台账 CRUD(清洁剂/工具/垃圾桶等) * - 入库 / 领用流水(级联维护 onHand) * - 低库存预警 * - 由保洁排班联动触发领用(POST /{supplyId}/requisition-from-schedule?scheduleId=xxx) * * URL: /api/oa/admin-cleaning-supplies */ @RestController @RequestMapping("/api/oa/admin-cleaning-supplies") public class AdminCleaningSupplyController { private final AdminCleaningSupplyRepository supplyRepo; private final AdminCleaningSupplyMoveRepository moveRepo; private final AdminCleaningScheduleRepository scheduleRepo; public AdminCleaningSupplyController(AdminCleaningSupplyRepository supplyRepo, AdminCleaningSupplyMoveRepository moveRepo, AdminCleaningScheduleRepository scheduleRepo) { this.supplyRepo = supplyRepo; this.moveRepo = moveRepo; this.scheduleRepo = scheduleRepo; } // ---------------------------------------------------------------- // 台账 CRUD // ---------------------------------------------------------------- @GetMapping public ApiResp> list( @RequestParam(required = false) String supplyCategory, @RequestParam(required = false) String supplyStatus) { if (supplyCategory != null && !supplyCategory.isBlank()) { return ApiResp.ok(supplyRepo.findBySupplyCategory(supplyCategory)); } if (supplyStatus != null && !supplyStatus.isBlank()) { return ApiResp.ok(supplyRepo.findBySupplyStatus(supplyStatus)); } return ApiResp.ok(supplyRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("清洁物资不存在: " + id))); } public record SupplyRequest( String supplyName, String supplyCategory, String spec, String unitName, Double refPrice, Double onHand, Double safetyStock, String location, String supplyStatus) {} @PostMapping @Transactional public ApiResp create(@RequestBody SupplyRequest req) { if (req.supplyName() == null || req.supplyName().isBlank()) { throw new ApiException(400, "物料名称(supplyName)不能为空"); } AdminCleaningSupply s = new AdminCleaningSupply(); long seq = supplyRepo.count() + 1; s.setSupplyCode("CS-" + String.format("%04d", seq)); s.setSupplyName(req.supplyName().trim()); s.setSupplyCategory(req.supplyCategory() == null ? "清洁剂" : req.supplyCategory()); s.setSpec(req.spec()); s.setUnitName(req.unitName() == null ? "个" : req.unitName()); s.setRefPrice(req.refPrice() == null ? BigDecimal.ZERO : Money.of(req.refPrice())); s.setOnHand(req.onHand() == null ? 0d : req.onHand()); s.setSafetyStock(req.safetyStock() == null ? 0d : req.safetyStock()); s.setLocation(req.location()); s.setSupplyStatus(req.supplyStatus() == null ? "启用" : req.supplyStatus()); s.setCreatedAt(Instant.now()); s.setUpdatedAt(Instant.now()); return ApiResp.ok(supplyRepo.save(s)); } @PatchMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody SupplyRequest req) { AdminCleaningSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("清洁物资不存在: " + id)); if (req.supplyName() != null && !req.supplyName().isBlank()) s.setSupplyName(req.supplyName().trim()); if (req.supplyCategory() != null) s.setSupplyCategory(req.supplyCategory()); if (req.spec() != null) s.setSpec(req.spec()); if (req.unitName() != null) s.setUnitName(req.unitName()); if (req.refPrice() != null) s.setRefPrice(Money.of(req.refPrice())); if (req.safetyStock() != null) s.setSafetyStock(req.safetyStock()); if (req.location() != null) s.setLocation(req.location()); if (req.supplyStatus() != null) s.setSupplyStatus(req.supplyStatus()); s.setUpdatedAt(Instant.now()); return ApiResp.ok(supplyRepo.save(s)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { if (!supplyRepo.existsById(id)) throw new NotFoundException("清洁物资不存在: " + id); supplyRepo.deleteById(id); return ApiResp.ok(null); } // ---------------------------------------------------------------- // 库存流水(入库 / 领用) // ---------------------------------------------------------------- @GetMapping("/{id}/moves") public ApiResp> moves(@PathVariable Long id) { return ApiResp.ok(moveRepo.findByCleaningSupplyIdOrderByCreatedAtDesc(id)); } public record MoveRequest(Double qty, String handler, String source, String moveDate, String remark) {} /** 入库:qty > 0,级联 onHand += qty。 */ @PostMapping("/{id}/inbound") @Transactional public ApiResp inbound(@PathVariable Long id, @RequestBody MoveRequest req) { AdminCleaningSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("清洁物资不存在: " + id)); double qty = req.qty() == null ? 0 : req.qty(); if (qty <= 0) throw new ApiException(400, "入库数量必须大于 0"); AdminCleaningSupplyMove m = buildMove(s, "入库", qty, req, null); AdminCleaningSupplyMove saved = moveRepo.save(m); s.setOnHand(nz(s.getOnHand()) + qty); s.setUpdatedAt(Instant.now()); supplyRepo.save(s); return ApiResp.ok(saved); } /** 领用出库:qty > 0,库存不足则拒绝,级联 onHand -= qty。 */ @PostMapping("/{id}/requisition") @Transactional public ApiResp requisition(@PathVariable Long id, @RequestBody MoveRequest req) { AdminCleaningSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("清洁物资不存在: " + id)); double qty = req.qty() == null ? 0 : req.qty(); if (qty <= 0) throw new ApiException(400, "领用数量必须大于 0"); if (nz(s.getOnHand()) < qty) { throw new ApiException(409, "库存不足:当前 " + nz(s.getOnHand()) + ",申领 " + qty); } AdminCleaningSupplyMove m = buildMove(s, "领用", qty, req, null); AdminCleaningSupplyMove saved = moveRepo.save(m); s.setOnHand(nz(s.getOnHand()) - qty); s.setUpdatedAt(Instant.now()); supplyRepo.save(s); return ApiResp.ok(saved); } /** * 由保洁排班联动触发领用:传入 scheduleId 自动关联排班。 * 保洁人员完成签到后,系统自动记录本次作业对应的清洁物资领用。 */ @PostMapping("/{id}/requisition-from-schedule") @Transactional public ApiResp requisitionFromSchedule( @PathVariable Long id, @RequestParam Long scheduleId, @RequestBody MoveRequest req) { AdminCleaningSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("清洁物资不存在: " + id)); // 校验排班存在 AdminCleaningSchedule sched = scheduleRepo.findById(scheduleId) .orElseThrow(() -> new NotFoundException("保洁排班不存在: " + scheduleId)); double qty = req.qty() == null ? 1 : req.qty(); if (qty <= 0) throw new ApiException(400, "领用数量必须大于 0"); if (nz(s.getOnHand()) < qty) { throw new ApiException(409, "库存不足:当前 " + nz(s.getOnHand()) + ",申领 " + qty); } String handler = req.handler() == null || req.handler().isBlank() ? sched.getCleaner() : req.handler(); String source = req.source() == null || req.source().isBlank() ? sched.getArea() : req.source(); MoveRequest effectiveReq = new MoveRequest(qty, handler, source, req.moveDate(), req.remark()); AdminCleaningSupplyMove m = buildMove(s, "领用", qty, effectiveReq, scheduleId); AdminCleaningSupplyMove saved = moveRepo.save(m); s.setOnHand(nz(s.getOnHand()) - qty); s.setUpdatedAt(Instant.now()); supplyRepo.save(s); return ApiResp.ok(saved); } // ---------------------------------------------------------------- // 低库存预警 // ---------------------------------------------------------------- public record LowStockAlert(Long supplyId, String supplyCode, String supplyName, String supplyCategory, String unitName, double onHand, double safetyStock, double shortage) {} /** 低库存预警:结存 < 安全库存(且安全库存 > 0),按缺口降序。 */ @GetMapping("/alerts/low-stock") public ApiResp> lowStockAlerts() { List out = new ArrayList<>(); for (AdminCleaningSupply s : supplyRepo.findBySupplyStatus("启用")) { double onHand = nz(s.getOnHand()); double safety = nz(s.getSafetyStock()); if (safety > 0 && onHand < safety) { out.add(new LowStockAlert(s.getId(), s.getSupplyCode(), s.getSupplyName(), s.getSupplyCategory(), s.getUnitName(), onHand, safety, safety - onHand)); } } out.sort((a, b) -> Double.compare(b.shortage(), a.shortage())); return ApiResp.ok(out); } // ---------------------------------------------------------------- // 工具 // ---------------------------------------------------------------- private AdminCleaningSupplyMove buildMove(AdminCleaningSupply s, String direction, double qty, MoveRequest req, Long scheduleId) { AdminCleaningSupplyMove m = new AdminCleaningSupplyMove(); m.setCleaningSupplyId(s.getId()); m.setSupplyName(s.getSupplyName()); m.setMoveDirection(direction); m.setMoveQty(qty); m.setHandler(req.handler()); m.setSource(req.source()); m.setScheduleId(scheduleId); m.setMoveDate(req.moveDate() == null || req.moveDate().isBlank() ? LocalDate.now().toString() : req.moveDate()); m.setRemark(req.remark()); m.setCreatedAt(Instant.now()); return m; } private static double nz(Double v) { return v == null ? 0d : v; } }