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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,397 @@
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.FertStockLot;
import com.kaidi.oa.domain.FertStockMove;
import com.kaidi.oa.repository.FertStockLotRepository;
import com.kaidi.oa.repository.FertStockMoveRepository;
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.PutMapping;
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.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
/**
* 生物质肥料制造中心·批次库存管理(需求 §6,补深审计 LOGIC_GAP[high])。
*
* 把通用库存的薄壳做深到「批次号 + 保质期 + 到期预警 + FIFO + 移动扫码收发流水」:
* <ul>
* <li>CRUD 登记批次库存项,批次号全域唯一(约束冲突 → 409 中文);</li>
* <li>POST /{id}/inbound 入库、POST /{id}/outbound 出库:原子改库存 + 落 {@link FertStockMove} 流水,
* 出库超库存 → 400;自动维护 inbound/outboundTotal 与结存快照;</li>
* <li>POST /scan-issue 按物料 FIFO 自动出库:选最早入库的在库批次依次扣减,跨批次拆分,落多条流水;</li>
* <li>GET /expiry-alerts 到期前 N 月(默认 3)分级预警:已过期 / 临期;</li>
* <li>GET /{id}/moves、GET /moves 收发台账(可对账)。</li>
* </ul>
*
* 写口受 AuthInterceptor default-deny(ADMIN/APPROVER) 保护,前缀已登记入 FINANCE/SENSITIVE_READ。
*/
@RestController
@RequestMapping("/api/oa/fert-stock-lots")
public class FertStockLotController {
/** 默认到期预警提前月数(需求:成品到期前 3 月预警)。 */
private static final int DEFAULT_ALERT_MONTHS = 3;
private final FertStockLotRepository lotRepo;
private final FertStockMoveRepository moveRepo;
public FertStockLotController(FertStockLotRepository lotRepo, FertStockMoveRepository moveRepo) {
this.lotRepo = lotRepo;
this.moveRepo = moveRepo;
}
// ---------- 批次库存 CRUD ----------
@GetMapping
public ApiResp<List<FertStockLot>> list(@RequestParam(required = false) String warehouse,
@RequestParam(required = false) String status) {
List<FertStockLot> rows;
if (warehouse != null && !warehouse.isBlank()) {
rows = lotRepo.findByWarehouse(warehouse);
} else if (status != null && !status.isBlank()) {
rows = lotRepo.findByStatus(status);
} else {
rows = lotRepo.findAll();
}
return ApiResp.ok(rows);
}
@GetMapping("/{id}")
public ApiResp<FertStockLot> get(@PathVariable Long id) {
return ApiResp.ok(find(id));
}
public record LotRequest(
String batchNo, String warehouse, String materialName, String category, String spec,
String unit, Double qty, String location, String inboundDate, Integer shelfLifeMonths,
String expiryDate, String sourceRef, String supplier, Double unitCost, String scanCode,
String remark) {
}
@PostMapping
@Transactional
public ApiResp<FertStockLot> create(@RequestBody LotRequest req) {
if (req.materialName() == null || req.materialName().isBlank()) {
throw new ApiException(400, "物料名称(materialName) 不能为空");
}
FertStockLot lot = new FertStockLot();
String batchNo = (req.batchNo() == null || req.batchNo().isBlank())
? "LOT-" + (lotRepo.count() + 1) : req.batchNo().trim();
if (lotRepo.findFirstByBatchNo(batchNo) != null) {
throw new ApiException(409, "批次号已存在:" + batchNo);
}
lot.setBatchNo(batchNo);
lot.setWarehouse(req.warehouse() == null || req.warehouse().isBlank() ? "成品库" : req.warehouse());
lot.setMaterialName(req.materialName().trim());
lot.setCategory(req.category());
lot.setSpec(req.spec());
lot.setUnit(req.unit() == null || req.unit().isBlank() ? "" : req.unit());
BigDecimal qty = bd(req.qty());
if (qty.signum() < 0) {
throw new ApiException(400, "初始数量不能为负");
}
lot.setQty(qty);
lot.setInboundTotal(qty);
lot.setOutboundTotal(BigDecimal.ZERO);
lot.setLocation(req.location());
lot.setInboundDate(req.inboundDate() == null || req.inboundDate().isBlank()
? LocalDate.now().toString() : req.inboundDate());
lot.setShelfLifeMonths(req.shelfLifeMonths());
lot.setExpiryDate(resolveExpiry(req.expiryDate(), lot.getInboundDate(), req.shelfLifeMonths()));
lot.setSourceRef(req.sourceRef());
lot.setSupplier(req.supplier());
lot.setUnitCost(Money.of(req.unitCost()));
lot.setScanCode(req.scanCode());
lot.setRemark(req.remark());
lot.setStatus(qty.signum() > 0 ? "在库" : "已耗尽");
lot.setCreatedAt(Instant.now());
lotRepo.save(lot);
if (qty.signum() > 0) {
writeMove(lot, "入库", qty, lot.getQty(), req.sourceRef(), req.scanCode(), null, "建账入库");
}
return ApiResp.ok(lot);
}
@PutMapping("/{id}")
public ApiResp<FertStockLot> update(@PathVariable Long id, @RequestBody LotRequest req) {
FertStockLot lot = find(id);
// 仅改可编辑的描述/保质字段,数量一律走 inbound/outbound 流水,杜绝绕过台账改库存。
if (req.warehouse() != null) lot.setWarehouse(req.warehouse());
if (req.materialName() != null && !req.materialName().isBlank()) lot.setMaterialName(req.materialName().trim());
if (req.category() != null) lot.setCategory(req.category());
if (req.spec() != null) lot.setSpec(req.spec());
if (req.unit() != null && !req.unit().isBlank()) lot.setUnit(req.unit());
if (req.location() != null) lot.setLocation(req.location());
if (req.shelfLifeMonths() != null) lot.setShelfLifeMonths(req.shelfLifeMonths());
if (req.inboundDate() != null && !req.inboundDate().isBlank()) lot.setInboundDate(req.inboundDate());
lot.setExpiryDate(resolveExpiry(req.expiryDate(), lot.getInboundDate(), lot.getShelfLifeMonths()));
if (req.supplier() != null) lot.setSupplier(req.supplier());
if (req.unitCost() != null) lot.setUnitCost(Money.of(req.unitCost()));
if (req.scanCode() != null) lot.setScanCode(req.scanCode());
if (req.remark() != null) lot.setRemark(req.remark());
return ApiResp.ok(lotRepo.save(lot));
}
@DeleteMapping("/{id}")
@Transactional
public ApiResp<Void> delete(@PathVariable Long id) {
FertStockLot lot = find(id);
moveRepo.deleteAll(moveRepo.findByLotIdOrderByIdDesc(id));
lotRepo.delete(lot);
return ApiResp.ok();
}
// ---------- 出入库(落流水) ----------
public record MoveRequest(Double qty, String location, String refNo, String scanCode,
String operator, String moveDate, String remark) {
}
@PostMapping("/{id}/inbound")
@Transactional
public ApiResp<FertStockLot> inbound(@PathVariable Long id, @RequestBody MoveRequest req) {
FertStockLot lot = find(id);
BigDecimal q = requirePositive(req.qty());
lot.setQty(Money.of(lot.getQty().add(q)));
lot.setInboundTotal(Money.of(lot.getInboundTotal().add(q)));
if ("已耗尽".equals(lot.getStatus())) lot.setStatus("在库");
if (req.location() != null && !req.location().isBlank()) lot.setLocation(req.location());
lotRepo.save(lot);
writeMove(lot, "入库", q, lot.getQty(), req.refNo(), req.scanCode(), req.operator(),
req.remark(), req.moveDate());
return ApiResp.ok(lot);
}
@PostMapping("/{id}/outbound")
@Transactional
public ApiResp<FertStockLot> outbound(@PathVariable Long id, @RequestBody MoveRequest req) {
FertStockLot lot = find(id);
BigDecimal q = requirePositive(req.qty());
if (q.compareTo(lot.getQty()) > 0) {
throw new ApiException(400, "出库数量(" + q + ")超过批次结存(" + lot.getQty() + ")");
}
applyOut(lot, q, req.refNo(), req.scanCode(), req.operator(), req.remark(), req.moveDate());
return ApiResp.ok(lot);
}
/** 按物料 FIFO 自动出库:选最早入库的在库批次依次扣减,跨批次拆分,落多条流水。 */
public record ScanIssueRequest(String materialName, Double qty, String refNo, String scanCode,
String operator, String moveDate, String remark) {
}
public record IssuePick(String batchNo, String inboundDate, BigDecimal taken, BigDecimal balanceAfter) {
}
public record ScanIssueResult(String materialName, BigDecimal requested, BigDecimal issued,
List<IssuePick> picks, String note) {
}
@PostMapping("/scan-issue")
@Transactional
public ApiResp<ScanIssueResult> scanIssue(@RequestBody ScanIssueRequest req) {
if (req.materialName() == null || req.materialName().isBlank()) {
throw new ApiException(400, "物料名称(materialName) 不能为空");
}
BigDecimal need = requirePositive(req.qty());
List<FertStockLot> lots =
lotRepo.findByMaterialNameAndStatusOrderByInboundDateAsc(req.materialName().trim(), "在库");
BigDecimal available = BigDecimal.ZERO;
for (FertStockLot l : lots) available = available.add(l.getQty());
if (available.compareTo(need) < 0) {
throw new ApiException(400, "可用库存不足:" + req.materialName() + "" + available + ",需 " + need);
}
List<IssuePick> picks = new ArrayList<>();
BigDecimal remaining = need;
for (FertStockLot l : lots) {
if (remaining.signum() <= 0) break;
BigDecimal take = remaining.min(l.getQty());
if (take.signum() <= 0) continue;
applyOut(l, take, req.refNo(), req.scanCode(), req.operator(),
req.remark() == null ? "FIFO 自动出库" : req.remark(), req.moveDate());
picks.add(new IssuePick(l.getBatchNo(), l.getInboundDate(), take, l.getQty()));
remaining = remaining.subtract(take);
}
BigDecimal issued = need.subtract(remaining.max(BigDecimal.ZERO));
return ApiResp.ok(new ScanIssueResult(req.materialName().trim(), Money.of(need), Money.of(issued),
picks, "按入库日期先进先出,跨 " + picks.size() + " 个批次扣减"));
}
private void applyOut(FertStockLot lot, BigDecimal q, String refNo, String scanCode,
String operator, String remark, String moveDate) {
lot.setQty(Money.of(lot.getQty().subtract(q)));
lot.setOutboundTotal(Money.of(lot.getOutboundTotal().add(q)));
if (lot.getQty().signum() <= 0) lot.setStatus("已耗尽");
lotRepo.save(lot);
writeMove(lot, "出库", q, lot.getQty(), refNo, scanCode, operator, remark, moveDate);
}
// ---------- 报废 / 冻结 / 解冻 ----------
public record StateRequest(String operator, String reason) {
}
@PostMapping("/{id}/scrap")
@Transactional
public ApiResp<FertStockLot> scrap(@PathVariable Long id, @RequestBody(required = false) StateRequest req) {
FertStockLot lot = find(id);
BigDecimal q = lot.getQty();
if (q.signum() > 0) {
writeMove(lot, "报废", q, BigDecimal.ZERO, null, null,
req == null ? null : req.operator(), req == null ? null : req.reason());
lot.setOutboundTotal(Money.of(lot.getOutboundTotal().add(q)));
lot.setQty(BigDecimal.ZERO);
}
lot.setStatus("已报废");
return ApiResp.ok(lotRepo.save(lot));
}
@PostMapping("/{id}/freeze")
@Transactional
public ApiResp<FertStockLot> freeze(@PathVariable Long id, @RequestBody(required = false) StateRequest req) {
FertStockLot lot = find(id);
lot.setStatus("冻结");
writeMove(lot, "冻结", lot.getQty(), lot.getQty(), null, null,
req == null ? null : req.operator(), req == null ? "临期复检冻结" : req.reason());
return ApiResp.ok(lotRepo.save(lot));
}
@PostMapping("/{id}/unfreeze")
@Transactional
public ApiResp<FertStockLot> unfreeze(@PathVariable Long id, @RequestBody(required = false) StateRequest req) {
FertStockLot lot = find(id);
lot.setStatus(lot.getQty().signum() > 0 ? "在库" : "已耗尽");
writeMove(lot, "解冻", lot.getQty(), lot.getQty(), null, null,
req == null ? null : req.operator(), req == null ? "复检合格解冻" : req.reason());
return ApiResp.ok(lotRepo.save(lot));
}
// ---------- 收发台账 ----------
@GetMapping("/{id}/moves")
public ApiResp<List<FertStockMove>> moves(@PathVariable Long id) {
find(id);
return ApiResp.ok(moveRepo.findByLotIdOrderByIdDesc(id));
}
@GetMapping("/moves")
public ApiResp<List<FertStockMove>> recentMoves(@RequestParam(required = false) String batchNo) {
if (batchNo != null && !batchNo.isBlank()) {
return ApiResp.ok(moveRepo.findByBatchNoOrderByIdDesc(batchNo));
}
return ApiResp.ok(moveRepo.findTop200ByOrderByIdDesc());
}
// ---------- 到期预警(成品到期前 3 月 / 已过期) ----------
public record ExpiryRow(Long id, String batchNo, String warehouse, String materialName, String spec,
BigDecimal qty, String unit, String expiryDate, long daysToExpiry, String level) {
}
@GetMapping("/expiry-alerts")
public ApiResp<List<ExpiryRow>> expiryAlerts(@RequestParam(required = false) Integer monthsAhead) {
int months = (monthsAhead == null || monthsAhead <= 0) ? DEFAULT_ALERT_MONTHS : monthsAhead;
LocalDate today = LocalDate.now();
LocalDate threshold = today.plusMonths(months);
List<ExpiryRow> out = new ArrayList<>();
for (FertStockLot l : lotRepo.findByStatusAndExpiryDateIsNotNull("在库")) {
LocalDate exp = tryParse(l.getExpiryDate());
if (exp == null) continue;
long days = ChronoUnit.DAYS.between(today, exp);
String level;
if (exp.isBefore(today)) {
level = "已过期";
} else if (!exp.isAfter(threshold)) {
level = "临期";
} else {
continue;
}
out.add(new ExpiryRow(l.getId(), l.getBatchNo(), l.getWarehouse(), l.getMaterialName(),
l.getSpec(), l.getQty(), l.getUnit(), l.getExpiryDate(), days, level));
}
out.sort((a, b) -> Long.compare(a.daysToExpiry(), b.daysToExpiry()));
return ApiResp.ok(out);
}
// ---------- helpers ----------
private FertStockLot find(Long id) {
return lotRepo.findById(id)
.orElseThrow(() -> new NotFoundException("库存批次不存在:" + id));
}
private void writeMove(FertStockLot lot, String type, BigDecimal qty, BigDecimal balanceAfter,
String refNo, String scanCode, String operator, String remark) {
writeMove(lot, type, qty, balanceAfter, refNo, scanCode, operator, remark, null);
}
private void writeMove(FertStockLot lot, String type, BigDecimal qty, BigDecimal balanceAfter,
String refNo, String scanCode, String operator, String remark, String moveDate) {
FertStockMove m = new FertStockMove();
m.setLotId(lot.getId());
m.setBatchNo(lot.getBatchNo());
m.setMaterialName(lot.getMaterialName());
m.setMoveType(type);
m.setQty(Money.of(qty));
m.setBalanceAfter(Money.of(balanceAfter));
m.setLocation(lot.getLocation());
m.setRefNo(refNo);
m.setScanCode(scanCode);
m.setOperator(operator);
m.setMoveDate(moveDate == null || moveDate.isBlank() ? LocalDate.now().toString() : moveDate);
m.setRemark(remark);
m.setCreatedAt(Instant.now());
moveRepo.save(m);
}
private static BigDecimal bd(Double v) {
return Money.of(v);
}
private static BigDecimal requirePositive(Double v) {
BigDecimal q = Money.of(v);
if (q.signum() <= 0) {
throw new ApiException(400, "数量必须大于 0");
}
return q;
}
/** 有效期:优先显式传入;否则按入库日期 + 保质月数推算;都没有则 null。 */
private static String resolveExpiry(String explicit, String inboundDate, Integer shelfLifeMonths) {
if (explicit != null && !explicit.isBlank()) {
return explicit;
}
if (shelfLifeMonths != null && shelfLifeMonths > 0 && inboundDate != null && !inboundDate.isBlank()) {
LocalDate in = tryParse(inboundDate);
if (in != null) {
return in.plusMonths(shelfLifeMonths).toString();
}
}
return null;
}
private static LocalDate tryParse(String d) {
if (d == null || d.isBlank()) return null;
try {
return LocalDate.parse(d.trim());
} catch (DateTimeParseException e) {
return null;
}
}
}