Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/AdminSupplyController.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

317 lines
14 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.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<AdminSupply>> 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<AdminSupply> 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<AdminSupply> 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<AdminSupply> 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<Void> 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<List<AdminStockMove>> moves(@PathVariable Long id) {
return ApiResp.ok(moveRepo.findBySupplyIdOrderByIdDesc(id));
}
@GetMapping("/moves")
public ApiResp<List<AdminStockMove>> 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<AdminStockMove> 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<AdminStockMove> 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<List<LowStockAlert>> lowStockAlerts() {
List<LowStockAlert> 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<List<ExpiryAlert>> expiryAlerts(@RequestParam(required = false, defaultValue = "30") int days) {
LocalDate today = LocalDate.now();
List<ExpiryAlert> 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<AllocRow> byDept, double grandTotal, String month) {
}
/**
* 物资费用分摊报表:把出库领用按领用部门归集金额(数量×参考单价),可按月份 month=YYYY-MM 过滤。
* 月末由行政推送财务,作为部门物资成本分摊依据。
*/
@GetMapping("/cost/allocation")
public ApiResp<AllocSummary> costAllocation(@RequestParam(required = false) String month) {
Map<String, double[]> byDept = new LinkedHashMap<>(); // [count, qty]
Map<String, BigDecimal> 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<AllocRow> rows = new ArrayList<>();
for (Map.Entry<String, double[]> 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;
}
}
}