恢复点(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>
374 lines
18 KiB
Java
374 lines
18 KiB
Java
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.MfgOutsource;
|
|
import com.kaidi.oa.domain.MfgPurchaseOrder;
|
|
import com.kaidi.oa.repository.MfgOutsourceRepository;
|
|
import com.kaidi.oa.repository.MfgPurchaseOrderRepository;
|
|
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.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* 制造管理中心 / 环保设备制造中心 —— 采购与供应商管理(需求 §4,原审计判 LOGIC_GAP 高)。
|
|
*
|
|
* 补深审计缺口 GAP#3(高):无采购申请/PO 实体,MRP→采购申请不自动,PO 不关联项目/任务号。
|
|
* 本控制器覆盖:
|
|
* <ul>
|
|
* <li>采购订单 CRUD(关联项目编号/生产工单号,成本归集);</li>
|
|
* <li><b>状态机</b>:草拟 → 待审批 → 已审批 → 已下单 → 到货待检 → 部分到货 → 已完成 /已取消;</li>
|
|
* <li><b>/approve</b> 审批动作(含拒绝理由 409 拦截);</li>
|
|
* <li><b>/arrive</b> 到货登记(实际到货量 arrivedQty,自动判断 部分到货/已完成,联动触发 IQC 单号);</li>
|
|
* <li><b>/cancel</b> 取消(仅 草拟/待审批/已审批 可取消);</li>
|
|
* <li><b>/board</b> 采购状态看板(各状态数量+金额);</li>
|
|
* <li>外协管理 /outsources/* :外协任务 CRUD + 发件→检验→合格入库闭环。</li>
|
|
* </ul>
|
|
* 金额一律 {@link Money}/BigDecimal;读写均已登记进 AuthInterceptor 门禁(sharedFileSnippets)。
|
|
*/
|
|
@RestController
|
|
@RequestMapping("/api/oa/mfg-purchase-orders")
|
|
public class MfgPurchaseOrderController {
|
|
|
|
public static final List<String> STAGES = List.of(
|
|
"草拟", "待审批", "已审批", "已下单", "到货待检", "部分到货", "已完成", "已取消");
|
|
|
|
private final MfgPurchaseOrderRepository poRepo;
|
|
private final MfgOutsourceRepository outsourceRepo;
|
|
|
|
public MfgPurchaseOrderController(MfgPurchaseOrderRepository poRepo,
|
|
MfgOutsourceRepository outsourceRepo) {
|
|
this.poRepo = poRepo;
|
|
this.outsourceRepo = outsourceRepo;
|
|
}
|
|
|
|
// ---------- 采购订单 CRUD ----------
|
|
|
|
@GetMapping
|
|
public ApiResp<List<MfgPurchaseOrder>> list(@RequestParam(required = false) String status,
|
|
@RequestParam(required = false) String projectCode,
|
|
@RequestParam(required = false) String supplier,
|
|
@RequestParam(required = false) String poType) {
|
|
if (status != null && !status.isBlank()) return ApiResp.ok(poRepo.findByStatus(status));
|
|
if (projectCode != null && !projectCode.isBlank()) return ApiResp.ok(poRepo.findByProjectCode(projectCode));
|
|
if (supplier != null && !supplier.isBlank()) return ApiResp.ok(poRepo.findBySupplier(supplier));
|
|
if (poType != null && !poType.isBlank()) return ApiResp.ok(poRepo.findByPoType(poType));
|
|
return ApiResp.ok(poRepo.findAllByOrderByIdDesc());
|
|
}
|
|
|
|
@GetMapping("/{id}")
|
|
public ApiResp<MfgPurchaseOrder> get(@PathVariable Long id) {
|
|
return ApiResp.ok(poRepo.findById(id).orElseThrow(() -> new NotFoundException("采购订单不存在: " + id)));
|
|
}
|
|
|
|
public record PoRequest(
|
|
String poNo, String poType, String source, String projectCode, String workOrderNo,
|
|
String materialName, String spec, String unit, Double qty,
|
|
Double unitPrice, String supplier, String requireDate, String owner, String remark) {
|
|
}
|
|
|
|
@PostMapping
|
|
public ApiResp<MfgPurchaseOrder> create(@RequestBody PoRequest req) {
|
|
if (req.materialName() == null || req.materialName().isBlank()) {
|
|
throw new ApiException(400, "物料名称(materialName) 不能为空");
|
|
}
|
|
MfgPurchaseOrder po = new MfgPurchaseOrder();
|
|
po.setPoNo(req.poNo() == null || req.poNo().isBlank() ? "PO-" + (poRepo.count() + 1) : req.poNo());
|
|
po.setPoType(req.poType() == null ? "原材料" : req.poType());
|
|
po.setSource(req.source() == null ? "手工申请" : req.source());
|
|
po.setProjectCode(req.projectCode());
|
|
po.setWorkOrderNo(req.workOrderNo());
|
|
po.setMaterialName(req.materialName());
|
|
po.setSpec(req.spec());
|
|
po.setUnit(req.unit());
|
|
double qty = req.qty() == null ? 0 : req.qty();
|
|
po.setQty(qty);
|
|
BigDecimal up = Money.of(req.unitPrice());
|
|
po.setUnitPrice(up);
|
|
po.setAmount(Money.of(qty).multiply(up));
|
|
po.setSupplier(req.supplier());
|
|
po.setRequireDate(req.requireDate());
|
|
po.setOwner(req.owner());
|
|
po.setRemark(req.remark());
|
|
po.setStatus("草拟");
|
|
po.setArrivedQty(0);
|
|
po.setCreatedAt(Instant.now());
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
@PatchMapping("/{id}")
|
|
public ApiResp<MfgPurchaseOrder> update(@PathVariable Long id, @RequestBody PoRequest req) {
|
|
MfgPurchaseOrder po = poRepo.findById(id).orElseThrow(() -> new NotFoundException("采购订单不存在: " + id));
|
|
if (List.of("已完成", "已取消").contains(po.getStatus())) {
|
|
throw new ApiException(409, "已完成或已取消的采购订单不能修改");
|
|
}
|
|
if (req.materialName() != null && !req.materialName().isBlank()) po.setMaterialName(req.materialName());
|
|
if (req.spec() != null) po.setSpec(req.spec());
|
|
if (req.unit() != null) po.setUnit(req.unit());
|
|
if (req.qty() != null) {
|
|
po.setQty(req.qty());
|
|
po.setAmount(Money.of(req.qty()).multiply(po.getUnitPrice()));
|
|
}
|
|
if (req.unitPrice() != null) {
|
|
po.setUnitPrice(Money.of(req.unitPrice()));
|
|
po.setAmount(Money.of(po.getQty()).multiply(po.getUnitPrice()));
|
|
}
|
|
if (req.supplier() != null) po.setSupplier(req.supplier());
|
|
if (req.requireDate() != null) po.setRequireDate(req.requireDate());
|
|
if (req.projectCode() != null) po.setProjectCode(req.projectCode());
|
|
if (req.workOrderNo() != null) po.setWorkOrderNo(req.workOrderNo());
|
|
if (req.owner() != null) po.setOwner(req.owner());
|
|
if (req.remark() != null) po.setRemark(req.remark());
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
@DeleteMapping("/{id}")
|
|
public ApiResp<Void> delete(@PathVariable Long id) {
|
|
if (!poRepo.existsById(id)) throw new NotFoundException("采购订单不存在: " + id);
|
|
poRepo.deleteById(id);
|
|
return ApiResp.ok(null);
|
|
}
|
|
|
|
// ---------- 状态机动作 ----------
|
|
|
|
public record ApproveRequest(String comment, Boolean approve) {}
|
|
|
|
/** 提交审批(草拟 → 待审批)。 */
|
|
@PostMapping("/{id}/submit")
|
|
public ApiResp<MfgPurchaseOrder> submit(@PathVariable Long id) {
|
|
MfgPurchaseOrder po = requireStatus(id, "草拟");
|
|
po.setStatus("待审批");
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
/** 审批通过/拒绝(待审批 → 已审批/草拟)。 */
|
|
@PostMapping("/{id}/approve")
|
|
@Transactional
|
|
public ApiResp<MfgPurchaseOrder> approve(@PathVariable Long id, @RequestBody ApproveRequest req) {
|
|
MfgPurchaseOrder po = requireStatus(id, "待审批");
|
|
po.setApproveComment(req.comment());
|
|
if (req.approve() != null && !req.approve()) {
|
|
po.setStatus("草拟");
|
|
} else {
|
|
po.setStatus("已审批");
|
|
}
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
/** 下单(已审批 → 已下单)。 */
|
|
@PostMapping("/{id}/place")
|
|
public ApiResp<MfgPurchaseOrder> place(@PathVariable Long id) {
|
|
MfgPurchaseOrder po = requireStatus(id, "已审批");
|
|
po.setStatus("已下单");
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
public record ArrivalRequest(Double arrivedQty, String arrivalDate, String iqcNo) {}
|
|
|
|
/**
|
|
* 到货登记(已下单/到货待检/部分到货 → 部分到货/已完成)。
|
|
* 实际到货量累计达到计划量 → 已完成;否则 → 部分到货。
|
|
* 同时落 IQC 检验单号,联动触发质检流程(需求「IQC 闭环」)。
|
|
*/
|
|
@PostMapping("/{id}/arrive")
|
|
@Transactional
|
|
public ApiResp<MfgPurchaseOrder> arrive(@PathVariable Long id, @RequestBody ArrivalRequest req) {
|
|
MfgPurchaseOrder po = poRepo.findById(id).orElseThrow(() -> new NotFoundException("采购订单不存在: " + id));
|
|
if (!List.of("已下单", "到货待检", "部分到货").contains(po.getStatus())) {
|
|
throw new ApiException(409, "当前状态「" + po.getStatus() + "」不能执行到货登记");
|
|
}
|
|
if (req.arrivedQty() == null || req.arrivedQty() <= 0) {
|
|
throw new ApiException(400, "到货数量(arrivedQty)必须 > 0");
|
|
}
|
|
double newArrived = po.getArrivedQty() + req.arrivedQty();
|
|
po.setArrivedQty(newArrived);
|
|
po.setArrivalDate(req.arrivalDate() != null && !req.arrivalDate().isBlank()
|
|
? req.arrivalDate() : LocalDate.now().toString());
|
|
if (req.iqcNo() != null && !req.iqcNo().isBlank()) {
|
|
po.setIqcNo(req.iqcNo());
|
|
} else if (po.getIqcNo() == null) {
|
|
po.setIqcNo("IQC-" + po.getPoNo());
|
|
}
|
|
if (newArrived >= po.getQty()) {
|
|
po.setStatus("已完成");
|
|
} else {
|
|
po.setStatus("部分到货");
|
|
}
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
/** 取消(仅 草拟/待审批/已审批 可取消)。 */
|
|
@PostMapping("/{id}/cancel")
|
|
public ApiResp<MfgPurchaseOrder> cancel(@PathVariable Long id) {
|
|
MfgPurchaseOrder po = poRepo.findById(id).orElseThrow(() -> new NotFoundException("采购订单不存在: " + id));
|
|
if (!List.of("草拟", "待审批", "已审批").contains(po.getStatus())) {
|
|
throw new ApiException(409, "当前状态「" + po.getStatus() + "」不能取消(仅草拟/待审批/已审批可取消)");
|
|
}
|
|
po.setStatus("已取消");
|
|
return ApiResp.ok(poRepo.save(po));
|
|
}
|
|
|
|
// ---------- 采购看板 ----------
|
|
|
|
public record PoBoard(Map<String, Integer> statusCount, Map<String, Double> statusAmount,
|
|
int total, double totalAmount) {}
|
|
|
|
@GetMapping("/board")
|
|
public ApiResp<PoBoard> board() {
|
|
Map<String, Integer> cnt = new LinkedHashMap<>();
|
|
Map<String, Double> amt = new LinkedHashMap<>();
|
|
for (String s : STAGES) { cnt.put(s, 0); amt.put(s, 0.0); }
|
|
int total = 0;
|
|
BigDecimal totalAmt = BigDecimal.ZERO;
|
|
for (MfgPurchaseOrder po : poRepo.findAll()) {
|
|
String s = po.getStatus() == null || !cnt.containsKey(po.getStatus()) ? "草拟" : po.getStatus();
|
|
cnt.put(s, cnt.get(s) + 1);
|
|
amt.put(s, amt.get(s) + (po.getAmount() == null ? 0 : po.getAmount().doubleValue()));
|
|
total++;
|
|
totalAmt = Money.add(totalAmt, po.getAmount());
|
|
}
|
|
return ApiResp.ok(new PoBoard(cnt, amt, total, totalAmt.doubleValue()));
|
|
}
|
|
|
|
// ---------- 外协管理 ----------
|
|
|
|
@GetMapping("/outsources")
|
|
public ApiResp<List<MfgOutsource>> listOutsources(@RequestParam(required = false) String status,
|
|
@RequestParam(required = false) String projectCode,
|
|
@RequestParam(required = false) String workOrderNo) {
|
|
if (status != null && !status.isBlank()) return ApiResp.ok(outsourceRepo.findByStatus(status));
|
|
if (projectCode != null && !projectCode.isBlank()) return ApiResp.ok(outsourceRepo.findByProjectCode(projectCode));
|
|
if (workOrderNo != null && !workOrderNo.isBlank()) return ApiResp.ok(outsourceRepo.findByWorkOrderNo(workOrderNo));
|
|
return ApiResp.ok(outsourceRepo.findAllByOrderByIdDesc());
|
|
}
|
|
|
|
@GetMapping("/outsources/{id}")
|
|
public ApiResp<MfgOutsource> getOutsource(@PathVariable Long id) {
|
|
return ApiResp.ok(outsourceRepo.findById(id).orElseThrow(() -> new NotFoundException("外协任务不存在: " + id)));
|
|
}
|
|
|
|
public record OutsourceRequest(
|
|
String workOrderNo, String projectCode, String partName, String processType,
|
|
String processReq, String vendor, Double qty, String unit, Double unitPrice,
|
|
String requireReturnDate, String owner, String remark) {}
|
|
|
|
@PostMapping("/outsources")
|
|
public ApiResp<MfgOutsource> createOutsource(@RequestBody OutsourceRequest req) {
|
|
if (req.partName() == null || req.partName().isBlank()) {
|
|
throw new ApiException(400, "工件名称(partName) 不能为空");
|
|
}
|
|
MfgOutsource o = new MfgOutsource();
|
|
o.setOutsourceNo("WX-" + (outsourceRepo.count() + 1));
|
|
o.setWorkOrderNo(req.workOrderNo());
|
|
o.setProjectCode(req.projectCode());
|
|
o.setPartName(req.partName());
|
|
o.setProcessType(req.processType());
|
|
o.setProcessReq(req.processReq());
|
|
o.setVendor(req.vendor());
|
|
double qty = req.qty() == null ? 1 : req.qty();
|
|
o.setQty(qty);
|
|
o.setUnit(req.unit());
|
|
BigDecimal up = Money.of(req.unitPrice());
|
|
o.setUnitPrice(up);
|
|
o.setAmount(Money.of(qty).multiply(up));
|
|
o.setRequireReturnDate(req.requireReturnDate());
|
|
o.setOwner(req.owner());
|
|
o.setRemark(req.remark());
|
|
o.setStatus("待发件");
|
|
o.setCreatedAt(Instant.now());
|
|
return ApiResp.ok(outsourceRepo.save(o));
|
|
}
|
|
|
|
@PatchMapping("/outsources/{id}")
|
|
public ApiResp<MfgOutsource> updateOutsource(@PathVariable Long id, @RequestBody OutsourceRequest req) {
|
|
MfgOutsource o = outsourceRepo.findById(id).orElseThrow(() -> new NotFoundException("外协任务不存在: " + id));
|
|
if (req.partName() != null && !req.partName().isBlank()) o.setPartName(req.partName());
|
|
if (req.processType() != null) o.setProcessType(req.processType());
|
|
if (req.processReq() != null) o.setProcessReq(req.processReq());
|
|
if (req.vendor() != null) o.setVendor(req.vendor());
|
|
if (req.qty() != null) { o.setQty(req.qty()); o.setAmount(Money.of(req.qty()).multiply(o.getUnitPrice())); }
|
|
if (req.unitPrice() != null) { o.setUnitPrice(Money.of(req.unitPrice())); o.setAmount(Money.of(o.getQty()).multiply(o.getUnitPrice())); }
|
|
if (req.requireReturnDate() != null) o.setRequireReturnDate(req.requireReturnDate());
|
|
if (req.owner() != null) o.setOwner(req.owner());
|
|
if (req.remark() != null) o.setRemark(req.remark());
|
|
return ApiResp.ok(outsourceRepo.save(o));
|
|
}
|
|
|
|
@DeleteMapping("/outsources/{id}")
|
|
public ApiResp<Void> deleteOutsource(@PathVariable Long id) {
|
|
if (!outsourceRepo.existsById(id)) throw new NotFoundException("外协任务不存在: " + id);
|
|
outsourceRepo.deleteById(id);
|
|
return ApiResp.ok(null);
|
|
}
|
|
|
|
/** 发件(待发件 → 已发件)。 */
|
|
@PostMapping("/outsources/{id}/send")
|
|
public ApiResp<MfgOutsource> sendOutsource(@PathVariable Long id) {
|
|
MfgOutsource o = requireOutsourceStatus(id, "待发件");
|
|
o.setSentDate(LocalDate.now().toString());
|
|
o.setStatus("已发件");
|
|
return ApiResp.ok(outsourceRepo.save(o));
|
|
}
|
|
|
|
/** 跟进外协中(已发件 → 外协中)。 */
|
|
@PostMapping("/outsources/{id}/process")
|
|
public ApiResp<MfgOutsource> processOutsource(@PathVariable Long id) {
|
|
MfgOutsource o = requireOutsourceStatus(id, "已发件");
|
|
o.setStatus("外协中");
|
|
return ApiResp.ok(outsourceRepo.save(o));
|
|
}
|
|
|
|
public record OutsourceReturnRequest(String inspectResult, Boolean qualified, String ncDisposition) {}
|
|
|
|
/** 返回检验(外协中 → 已返回/检验中,记录检验结果,合格→合格入库,不合格→不合格退换)。 */
|
|
@PostMapping("/outsources/{id}/return")
|
|
@Transactional
|
|
public ApiResp<MfgOutsource> returnOutsource(@PathVariable Long id, @RequestBody OutsourceReturnRequest req) {
|
|
MfgOutsource o = requireOutsourceStatus(id, "外协中");
|
|
o.setActualReturnDate(LocalDate.now().toString());
|
|
o.setInspectResult(req.inspectResult());
|
|
o.setQualified(req.qualified());
|
|
if (Boolean.TRUE.equals(req.qualified())) {
|
|
o.setStatus("合格入库");
|
|
} else {
|
|
o.setNcDisposition(req.ncDisposition());
|
|
o.setStatus("不合格退换");
|
|
}
|
|
return ApiResp.ok(outsourceRepo.save(o));
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
private MfgPurchaseOrder requireStatus(Long id, String expected) {
|
|
MfgPurchaseOrder po = poRepo.findById(id).orElseThrow(() -> new NotFoundException("采购订单不存在: " + id));
|
|
if (!expected.equals(po.getStatus())) {
|
|
throw new ApiException(409, "当前状态「" + po.getStatus() + "」不能执行此操作,需处于「" + expected + "」");
|
|
}
|
|
return po;
|
|
}
|
|
|
|
private MfgOutsource requireOutsourceStatus(Long id, String expected) {
|
|
MfgOutsource o = outsourceRepo.findById(id).orElseThrow(() -> new NotFoundException("外协任务不存在: " + id));
|
|
if (!expected.equals(o.getStatus())) {
|
|
throw new ApiException(409, "当前状态「" + o.getStatus() + "」不能执行此操作,需处于「" + expected + "」");
|
|
}
|
|
return o;
|
|
}
|
|
}
|