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,316 @@
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;
}
}
}