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,235 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.Reagent;
import com.kaidi.oa.repository.ReagentRepository;
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.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* 实验室·试剂耗材与库存管理。试剂/耗材档案 CRUD + 出入库(inbound 入库增量、outbound 领用扣减,
* 领用可关联实验任务)+ 低库存/过期预警。库存量为数量(非金额),用 double 承接。
*
* 状态随出入库/有效期自动维护:库存 <= 0 → 停用前仍标低库存;<= minStock → 低库存;
* 过期 → 已过期。写口默认受 AuthInterceptor 的 default-deny(ADMIN/APPROVER) 保护。
*/
@RestController
@RequestMapping("/api/oa/reagents")
public class ReagentController {
private final ReagentRepository reagentRepo;
public ReagentController(ReagentRepository reagentRepo) {
this.reagentRepo = reagentRepo;
}
@GetMapping
public ApiResp<List<Reagent>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String category) {
if (status != null && !status.isBlank()) {
return ApiResp.ok(reagentRepo.findByStatus(status));
}
if (category != null && !category.isBlank()) {
return ApiResp.ok(reagentRepo.findByCategory(category));
}
return ApiResp.ok(reagentRepo.findAll());
}
@GetMapping("/{id}")
public ApiResp<Reagent> get(@PathVariable Long id) {
return ApiResp.ok(reagentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("reagent not found: " + id)));
}
public record ReagentRequest(
String code, String name, String category, String casNo, String spec,
String batchNo, String expiryDate, Double stockQty, String unit, Double minStock,
String storageCondition, String supplier, String sdsFile, String status) {
}
@PostMapping
public ApiResp<Reagent> create(@RequestBody ReagentRequest req) {
if (req.name() == null || req.name().isBlank()) {
throw new ApiException(400, "名称(name) 不能为空");
}
Reagent r = new Reagent();
r.setCode(req.code() == null || req.code().isBlank()
? "SJ-" + (reagentRepo.count() + 1) : req.code());
r.setName(req.name());
r.setCategory(req.category());
r.setCasNo(req.casNo());
r.setSpec(req.spec());
r.setBatchNo(req.batchNo());
r.setExpiryDate(req.expiryDate());
r.setStockQty(req.stockQty() == null ? 0.0 : req.stockQty());
r.setUnit(req.unit());
r.setMinStock(req.minStock() == null ? 0.0 : req.minStock());
r.setStorageCondition(req.storageCondition());
r.setSupplier(req.supplier());
r.setSdsFile(req.sdsFile());
r.setStatus(req.status() == null || req.status().isBlank() ? deriveStatus(r) : req.status());
r.setCreatedAt(Instant.now());
return ApiResp.ok(reagentRepo.save(r));
}
@PatchMapping("/{id}")
public ApiResp<Reagent> update(@PathVariable Long id, @RequestBody ReagentRequest req) {
Reagent r = reagentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("reagent not found: " + id));
if (req.code() != null && !req.code().isBlank()) r.setCode(req.code());
if (req.name() != null) {
if (req.name().isBlank()) {
throw new ApiException(400, "名称(name) 不能为空");
}
r.setName(req.name());
}
if (req.category() != null) r.setCategory(req.category());
if (req.casNo() != null) r.setCasNo(req.casNo());
if (req.spec() != null) r.setSpec(req.spec());
if (req.batchNo() != null) r.setBatchNo(req.batchNo());
if (req.expiryDate() != null) r.setExpiryDate(req.expiryDate());
if (req.stockQty() != null) r.setStockQty(req.stockQty());
if (req.unit() != null) r.setUnit(req.unit());
if (req.minStock() != null) r.setMinStock(req.minStock());
if (req.storageCondition() != null) r.setStorageCondition(req.storageCondition());
if (req.supplier() != null) r.setSupplier(req.supplier());
if (req.sdsFile() != null) r.setSdsFile(req.sdsFile());
if (req.status() != null && !req.status().isBlank()) {
r.setStatus(req.status());
} else {
r.setStatus(deriveStatus(r));
}
return ApiResp.ok(reagentRepo.save(r));
}
@DeleteMapping("/{id}")
public ApiResp<Void> delete(@PathVariable Long id) {
if (!reagentRepo.existsById(id)) {
throw new NotFoundException("reagent not found: " + id);
}
reagentRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---------- 出入库 ----------
public record StockMoveRequest(Double qty, String relatedTask, String operator) {
}
/** 入库:按 qty 增加库存量,自动刷新状态(数量为正)。 */
@PostMapping("/{id}/inbound")
@Transactional
public ApiResp<Reagent> inbound(@PathVariable Long id, @RequestBody StockMoveRequest req) {
Reagent r = reagentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("reagent not found: " + id));
double qty = req.qty() == null ? 0 : req.qty();
if (qty <= 0) {
throw new ApiException(400, "入库数量必须大于 0");
}
r.setStockQty(nz(r.getStockQty()) + qty);
r.setStatus(deriveStatus(r));
return ApiResp.ok(reagentRepo.save(r));
}
/** 领用出库:按 qty 扣减库存量(领用可关联实验任务),不足则 409,自动刷新状态。 */
@PostMapping("/{id}/outbound")
@Transactional
public ApiResp<Reagent> outbound(@PathVariable Long id, @RequestBody StockMoveRequest req) {
Reagent r = reagentRepo.findById(id)
.orElseThrow(() -> new NotFoundException("reagent not found: " + id));
double qty = req.qty() == null ? 0 : req.qty();
if (qty <= 0) {
throw new ApiException(400, "领用数量必须大于 0");
}
double remain = nz(r.getStockQty()) - qty;
if (remain < 0) {
throw new ApiException(409, "库存不足:当前库存 " + nz(r.getStockQty()) + ",领用 " + qty);
}
r.setStockQty(remain);
r.setStatus(deriveStatus(r));
return ApiResp.ok(reagentRepo.save(r));
}
// ---------- 预警 ----------
public record AlertRow(Long id, String code, String name, String category, Double stockQty,
Double minStock, String unit, String expiryDate, long daysToExpiry,
String level) {
}
/**
* 低库存 + 过期预警:库存 <= minStock 记低库存;有效期落在 [今天, 今天+days] 记临期、已过则逾期。
* level:已过期 / 临期(<=days天) / 低库存 / 缺货(=0)。
*/
@GetMapping("/alerts")
public ApiResp<List<AlertRow>> alerts(@RequestParam(required = false, defaultValue = "30") int days) {
LocalDate today = LocalDate.now();
List<AlertRow> out = new ArrayList<>();
for (Reagent r : reagentRepo.findAll()) {
LocalDate exp = parseDateOrNull(r.getExpiryDate());
long daysToExp = exp == null ? Long.MAX_VALUE
: java.time.temporal.ChronoUnit.DAYS.between(today, exp);
double stock = nz(r.getStockQty());
double min = nz(r.getMinStock());
boolean expired = exp != null && daysToExp < 0;
boolean nearExpiry = exp != null && daysToExp >= 0 && daysToExp <= days;
boolean out0 = stock <= 0;
boolean low = stock <= min && min > 0;
if (!expired && !nearExpiry && !out0 && !low) {
continue;
}
String level = expired ? "已过期" : out0 ? "缺货" : nearExpiry ? "临期" : "低库存";
out.add(new AlertRow(r.getId(), r.getCode(), r.getName(), r.getCategory(),
stock, min, r.getUnit(), r.getExpiryDate(),
daysToExp == Long.MAX_VALUE ? -1 : daysToExp, level));
}
return ApiResp.ok(out);
}
// ---------- helpers ----------
/** 状态派生:已过期 > 缺货/低库存 > 正常。手填 status 时不覆盖。 */
private static String deriveStatus(Reagent r) {
LocalDate exp = parseDateOrNull(r.getExpiryDate());
if (exp != null && exp.isBefore(LocalDate.now())) {
return "已过期";
}
double stock = nz(r.getStockQty());
double min = nz(r.getMinStock());
if (stock <= min && min > 0) {
return "低库存";
}
return "正常";
}
private static double nz(Double v) {
return v == null ? 0.0 : 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 (DateTimeParseException | IndexOutOfBoundsException e) {
return null;
}
}
}