Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/MfgPurchaseOrderController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

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;
}
}