Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/AdminCleaningSupplyController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

257 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AdminCleaningSupply>> 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<AdminCleaningSupply> 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<AdminCleaningSupply> 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<AdminCleaningSupply> 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<Void> delete(@PathVariable Long id) {
if (!supplyRepo.existsById(id)) throw new NotFoundException("清洁物资不存在: " + id);
supplyRepo.deleteById(id);
return ApiResp.ok(null);
}
// ----------------------------------------------------------------
// 库存流水(入库 / 领用)
// ----------------------------------------------------------------
@GetMapping("/{id}/moves")
public ApiResp<List<AdminCleaningSupplyMove>> 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<AdminCleaningSupplyMove> 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<AdminCleaningSupplyMove> 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<AdminCleaningSupplyMove> 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<List<LowStockAlert>> lowStockAlerts() {
List<LowStockAlert> 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; }
}