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 不关联项目/任务号。
* 本控制器覆盖:
*
* - 采购订单 CRUD(关联项目编号/生产工单号,成本归集);
* - 状态机:草拟 → 待审批 → 已审批 → 已下单 → 到货待检 → 部分到货 → 已完成 /已取消;
* - /approve 审批动作(含拒绝理由 409 拦截);
* - /arrive 到货登记(实际到货量 arrivedQty,自动判断 部分到货/已完成,联动触发 IQC 单号);
* - /cancel 取消(仅 草拟/待审批/已审批 可取消);
* - /board 采购状态看板(各状态数量+金额);
* - 外协管理 /outsources/* :外协任务 CRUD + 发件→检验→合格入库闭环。
*
* 金额一律 {@link Money}/BigDecimal;读写均已登记进 AuthInterceptor 门禁(sharedFileSnippets)。
*/
@RestController
@RequestMapping("/api/oa/mfg-purchase-orders")
public class MfgPurchaseOrderController {
public static final List 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(@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 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 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 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 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 submit(@PathVariable Long id) {
MfgPurchaseOrder po = requireStatus(id, "草拟");
po.setStatus("待审批");
return ApiResp.ok(poRepo.save(po));
}
/** 审批通过/拒绝(待审批 → 已审批/草拟)。 */
@PostMapping("/{id}/approve")
@Transactional
public ApiResp 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 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 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 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 statusCount, Map statusAmount,
int total, double totalAmount) {}
@GetMapping("/board")
public ApiResp board() {
Map cnt = new LinkedHashMap<>();
Map 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> 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 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 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 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 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 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 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 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;
}
}