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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user