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.AdminStockMove; import com.kaidi.oa.domain.AdminSupply; import com.kaidi.oa.repository.AdminStockMoveRepository; import com.kaidi.oa.repository.AdminSupplyRepository; 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.LinkedHashMap; import java.util.List; import java.util.Map; /** * 行政/办公室·非生产性物资管理。覆盖物资分类档案(含安全库存/结存)、入库登记、出库领用, * 入库/出库时级联自动维护物资结存量 onHand;低库存预警(结存低于安全库存)、批次有效期预警; * 费用分摊聚合(按领用部门/项目归集出库金额,月末分摊报表口径)。 * * 写口含金额(入库金额)与库存调整,读侧含参考单价/库存金额,已登记进 FINANCE_PREFIXES + * SENSITIVE_READ_PREFIXES(/api/oa/admin-supplies),统一收口 ADMIN/APPROVER。 */ @RestController @RequestMapping("/api/oa/admin-supplies") public class AdminSupplyController { private final AdminSupplyRepository supplyRepo; private final AdminStockMoveRepository moveRepo; public AdminSupplyController(AdminSupplyRepository supplyRepo, AdminStockMoveRepository moveRepo) { this.supplyRepo = supplyRepo; this.moveRepo = moveRepo; } // ---------- 物资档案 CRUD ---------- @GetMapping public ApiResp> list(@RequestParam(required = false) String category, @RequestParam(required = false) String status) { if (category != null && !category.isBlank()) { return ApiResp.ok(supplyRepo.findByCategory(category)); } if (status != null && !status.isBlank()) { return ApiResp.ok(supplyRepo.findByStatus(status)); } return ApiResp.ok(supplyRepo.findAll()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("supply not found: " + id))); } public record SupplyRequest( String code, String name, String category, String spec, String unit, Double refPrice, Double safetyStock, Double onHand, Boolean batchManaged, String defaultDept, String status) { } @PostMapping public ApiResp create(@RequestBody SupplyRequest req) { if (req.name() == null || req.name().isBlank()) { throw new ApiException(400, "物资名称(name) 不能为空"); } AdminSupply s = new AdminSupply(); s.setCode(req.code() == null || req.code().isBlank() ? "WZ-" + (supplyRepo.count() + 1) : req.code()); s.setName(req.name()); s.setCategory(req.category() == null || req.category().isBlank() ? "办公用品" : req.category()); s.setSpec(req.spec()); s.setUnit(req.unit() == null || req.unit().isBlank() ? "个" : req.unit()); s.setRefPrice(Money.of(req.refPrice())); s.setSafetyStock(req.safetyStock() == null ? 0d : req.safetyStock()); s.setOnHand(req.onHand() == null ? 0d : req.onHand()); s.setBatchManaged(Boolean.TRUE.equals(req.batchManaged())); s.setDefaultDept(req.defaultDept()); s.setStatus(req.status() == null || req.status().isBlank() ? "启用" : req.status()); s.setCreatedAt(Instant.now()); return ApiResp.ok(supplyRepo.save(s)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody SupplyRequest req) { AdminSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("supply not found: " + id)); if (req.name() != null && !req.name().isBlank()) s.setName(req.name()); if (req.category() != null && !req.category().isBlank()) s.setCategory(req.category()); if (req.spec() != null) s.setSpec(req.spec()); if (req.unit() != null && !req.unit().isBlank()) s.setUnit(req.unit()); if (req.refPrice() != null) s.setRefPrice(Money.of(req.refPrice())); if (req.safetyStock() != null) s.setSafetyStock(req.safetyStock()); // onHand 不允许在档案编辑里直改,只能由入库/出库流水维护,避免库存被随手改飞。 if (req.batchManaged() != null) s.setBatchManaged(req.batchManaged()); if (req.defaultDept() != null) s.setDefaultDept(req.defaultDept()); if (req.status() != null && !req.status().isBlank()) s.setStatus(req.status()); return ApiResp.ok(supplyRepo.save(s)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { if (!supplyRepo.existsById(id)) { throw new NotFoundException("supply not found: " + id); } supplyRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 库存流水(入库 / 出库) ---------- @GetMapping("/{id}/moves") public ApiResp> moves(@PathVariable Long id) { return ApiResp.ok(moveRepo.findBySupplyIdOrderByIdDesc(id)); } @GetMapping("/moves") public ApiResp> allMoves(@RequestParam(required = false) String direction) { if (direction != null && !direction.isBlank()) { return ApiResp.ok(moveRepo.findByDirection(direction)); } return ApiResp.ok(moveRepo.findAll()); } public record StockMoveRequest( Double qty, Double amount, String supplier, String dept, String project, String handler, String batchNo, String expireDate, String moveDate, String remark) { } /** * 入库登记:数量+金额+供应商,级联结存量 onHand += qty。 */ @PostMapping("/{id}/inbound") @Transactional public ApiResp inbound(@PathVariable Long id, @RequestBody StockMoveRequest req) { AdminSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("supply not found: " + id)); double qty = req.qty() == null ? 0 : req.qty(); if (qty <= 0) { throw new ApiException(400, "入库数量必须大于 0"); } AdminStockMove m = baseMove(s, "入库", qty, req); m.setAmount(Money.of(req.amount())); m.setSupplier(req.supplier()); AdminStockMove saved = moveRepo.save(m); s.setOnHand(nz(s.getOnHand()) + qty); supplyRepo.save(s); return ApiResp.ok(saved); } /** * 出库领用:级联结存量 onHand -= qty;库存不足时拒绝(不允许出成负库存)。 * 出库归集领用部门/项目,供月末费用分摊聚合。 */ @PostMapping("/{id}/outbound") @Transactional public ApiResp outbound(@PathVariable Long id, @RequestBody StockMoveRequest req) { AdminSupply s = supplyRepo.findById(id) .orElseThrow(() -> new NotFoundException("supply not found: " + 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); } AdminStockMove m = baseMove(s, "出库", qty, req); // 出库金额 = 数量 × 参考单价(成本归集口径)。 m.setAmount(Money.of(BigDecimal.valueOf(qty).multiply(Money.nz(s.getRefPrice())))); m.setDept(req.dept() == null || req.dept().isBlank() ? s.getDefaultDept() : req.dept()); m.setProject(req.project()); AdminStockMove saved = moveRepo.save(m); s.setOnHand(nz(s.getOnHand()) - qty); supplyRepo.save(s); return ApiResp.ok(saved); } private AdminStockMove baseMove(AdminSupply s, String direction, double qty, StockMoveRequest req) { AdminStockMove m = new AdminStockMove(); m.setSupplyId(s.getId()); m.setSupplyCode(s.getCode()); m.setSupplyName(s.getName()); m.setDirection(direction); m.setQty(qty); m.setHandler(req.handler()); m.setBatchNo(req.batchNo()); m.setExpireDate(req.expireDate()); m.setMoveDate(req.moveDate() == null || req.moveDate().isBlank() ? LocalDate.now().toString() : req.moveDate()); m.setRemark(req.remark()); m.setCreatedAt(Instant.now()); return m; } // ---------- 低库存 / 批次有效期 预警 ---------- public record LowStockAlert(Long supplyId, String code, String name, String category, String unit, double onHand, double safetyStock, double shortage) { } /** 低库存预警:结存量低于安全库存(且安全库存>0)的物资,按缺口降序。 */ @GetMapping("/alerts/low-stock") public ApiResp> lowStockAlerts() { List out = new ArrayList<>(); for (AdminSupply s : supplyRepo.findAll()) { double onHand = nz(s.getOnHand()); double safety = nz(s.getSafetyStock()); if (safety > 0 && onHand < safety) { out.add(new LowStockAlert(s.getId(), s.getCode(), s.getName(), s.getCategory(), s.getUnit(), onHand, safety, safety - onHand)); } } out.sort((a, b) -> Double.compare(b.shortage(), a.shortage())); return ApiResp.ok(out); } public record ExpiryAlert(Long moveId, Long supplyId, String supplyName, String batchNo, String expireDate, long daysToExpire, String level) { } /** 批次有效期预警:入库流水里 expireDate 落在 [今天-逾期, 今天+days] 的批次。 */ @GetMapping("/alerts/expiry") public ApiResp> expiryAlerts(@RequestParam(required = false, defaultValue = "30") int days) { LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (AdminStockMove m : moveRepo.findByDirection("入库")) { LocalDate exp = parseDateOrNull(m.getExpireDate()); if (exp == null) { continue; } long daysTo = java.time.temporal.ChronoUnit.DAYS.between(today, exp); if (daysTo > days) { continue; } String level = daysTo < 0 ? "已过期" : daysTo <= 7 ? "紧急" : daysTo <= 15 ? "临近" : "关注"; out.add(new ExpiryAlert(m.getId(), m.getSupplyId(), m.getSupplyName(), m.getBatchNo(), m.getExpireDate(), daysTo, level)); } out.sort((a, b) -> Long.compare(a.daysToExpire(), b.daysToExpire())); return ApiResp.ok(out); } // ---------- 费用分摊(按部门/项目归集出库金额) ---------- public record AllocRow(String dept, long moveCount, double totalQty, double totalAmount) { } public record AllocSummary(List byDept, double grandTotal, String month) { } /** * 物资费用分摊报表:把出库领用按领用部门归集金额(数量×参考单价),可按月份 month=YYYY-MM 过滤。 * 月末由行政推送财务,作为部门物资成本分摊依据。 */ @GetMapping("/cost/allocation") public ApiResp costAllocation(@RequestParam(required = false) String month) { Map byDept = new LinkedHashMap<>(); // [count, qty] Map amtByDept = new LinkedHashMap<>(); BigDecimal grand = BigDecimal.ZERO; for (AdminStockMove m : moveRepo.findByDirection("出库")) { if (month != null && !month.isBlank()) { String md = m.getMoveDate() == null ? "" : m.getMoveDate(); if (!md.startsWith(month)) { continue; } } String dept = m.getDept() == null || m.getDept().isBlank() ? "未归集" : m.getDept(); double[] agg = byDept.computeIfAbsent(dept, k -> new double[2]); agg[0] += 1; agg[1] += nz(m.getQty()); amtByDept.merge(dept, Money.nz(m.getAmount()), Money::add); grand = Money.add(grand, m.getAmount()); } List rows = new ArrayList<>(); for (Map.Entry e : byDept.entrySet()) { double[] agg = e.getValue(); rows.add(new AllocRow(e.getKey(), (long) agg[0], agg[1], amtByDept.getOrDefault(e.getKey(), BigDecimal.ZERO).doubleValue())); } rows.sort((a, b) -> Double.compare(b.totalAmount(), a.totalAmount())); return ApiResp.ok(new AllocSummary(rows, grand.doubleValue(), month == null ? "全部" : month)); } // ---------- helpers ---------- private static double nz(Double v) { return v == null ? 0d : v; } private static LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (RuntimeException e) { return null; } } }