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.MfgCostEntry; import com.kaidi.oa.domain.MfgOutsource; import com.kaidi.oa.domain.MfgDelivery; import com.kaidi.oa.repository.MfgCostEntryRepository; import com.kaidi.oa.repository.MfgOutsourceRepository; import com.kaidi.oa.repository.MfgDeliveryRepository; 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; /** * 制造管理中心 / 环保设备制造中心 —— 项目成本五维度归集(需求 §10 补深 GAP#10 low)。 * *

补深审计缺口 GAP#10(low): *

* *

本控制器提供: *

    *
  1. POST /mfg-cost-entries 手工录入成本条目(外协/安装调试/其他制造/质保维修);
  2. *
  3. POST /mfg-cost-entries/from-outsource/{outsourceId} 从外协任务自动提取外协费用, * 幂等(同 outsourceNo 已录入则 409);
  4. *
  5. POST /mfg-cost-entries/from-delivery/{deliveryId} 从发货单提取安装调试费用 * (deliveryId 关联的安装调试节点),幂等;
  6. *
  7. GET /mfg-cost-entries/project-summary/{projectCode} 五维度成本汇总(材料+人工+外协+安装调试+其他);
  8. *
  9. GET/PATCH/DELETE 条目管理;
  10. *
  11. POST /{id}/approve 审核确认(待审核 → 已审核)。
  12. *
* * 金额一律 {@link Money}/BigDecimal;外协费用从 MfgOutsource.amount 取, * 安装调试费用默认按工程师人天数计(前端可手工调整)。 */ @RestController @RequestMapping("/api/oa/mfg-cost-entries") public class MfgCostEntryController { public static final List COST_TYPES = List.of( "外协费用", "安装调试费用", "其他制造费用", "质保维修费用"); private final MfgCostEntryRepository entryRepo; private final MfgOutsourceRepository outsourceRepo; private final MfgDeliveryRepository deliveryRepo; public MfgCostEntryController(MfgCostEntryRepository entryRepo, MfgOutsourceRepository outsourceRepo, MfgDeliveryRepository deliveryRepo) { this.entryRepo = entryRepo; this.outsourceRepo = outsourceRepo; this.deliveryRepo = deliveryRepo; } // ---- CRUD ---- @GetMapping public ApiResp> list( @RequestParam(required = false) String projectCode, @RequestParam(required = false) String costType) { if (projectCode != null && !projectCode.isBlank() && costType != null && !costType.isBlank()) { return ApiResp.ok(entryRepo.findByProjectCodeAndCostType(projectCode, costType)); } if (projectCode != null && !projectCode.isBlank()) { return ApiResp.ok(entryRepo.findByProjectCode(projectCode)); } if (costType != null && !costType.isBlank()) { return ApiResp.ok(entryRepo.findByCostType(costType)); } return ApiResp.ok(entryRepo.findAllByOrderByIdDesc()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(entryRepo.findById(id) .orElseThrow(() -> new NotFoundException("成本条目不存在: " + id))); } public record EntryRequest( String projectCode, String costType, String description, String outsourceNo, String deliveryNo, Double amount, String costDate, String handler, String remark) { } @PostMapping public ApiResp create(@RequestBody EntryRequest req) { if (req.projectCode() == null || req.projectCode().isBlank()) { throw new ApiException(400, "项目编码(projectCode)不能为空"); } if (req.costType() == null || !COST_TYPES.contains(req.costType())) { throw new ApiException(400, "费用类型(costType)无效,可选: " + COST_TYPES); } MfgCostEntry entry = new MfgCostEntry(); entry.setEntryNo("MCE-" + (entryRepo.count() + 1)); fillEntry(entry, req); entry.setApprovalStatus("待审核"); entry.setCreatedAt(Instant.now()); return ApiResp.ok(entryRepo.save(entry)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody EntryRequest req) { MfgCostEntry entry = entryRepo.findById(id) .orElseThrow(() -> new NotFoundException("成本条目不存在: " + id)); if ("已审核".equals(entry.getApprovalStatus())) { throw new ApiException(409, "已审核的成本条目不能修改"); } if (req.costType() != null && !req.costType().isBlank() && !COST_TYPES.contains(req.costType())) { throw new ApiException(400, "费用类型无效,可选: " + COST_TYPES); } fillEntry(entry, req); return ApiResp.ok(entryRepo.save(entry)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { MfgCostEntry entry = entryRepo.findById(id) .orElseThrow(() -> new NotFoundException("成本条目不存在: " + id)); if ("已审核".equals(entry.getApprovalStatus())) { throw new ApiException(409, "已审核的成本条目不能删除"); } entryRepo.deleteById(id); return ApiResp.ok(null); } // ---- 从外协任务自动提取外协费用 ---- /** * 从外协任务单自动提取外协费用条目(幂等:同 outsourceNo 已录入则 409)。 * 外协费用 = outsource.amount(外协合同价),关联项目 = outsource.projectCode。 */ @PostMapping("/from-outsource/{outsourceId}") @Transactional public ApiResp fromOutsource(@PathVariable Long outsourceId) { MfgOutsource os = outsourceRepo.findById(outsourceId) .orElseThrow(() -> new NotFoundException("外协任务不存在: " + outsourceId)); // 幂等检查 String osNo = os.getOutsourceNo(); if (osNo != null) { boolean dup = entryRepo.findByCostType("外协费用").stream() .anyMatch(e -> osNo.equals(e.getOutsourceNo())); if (dup) { throw new ApiException(409, "该外协任务(" + osNo + ")已提取外协费用条目,不可重复提取"); } } // 只有「合格入库」状态才确认成本 if (!"合格入库".equals(os.getStatus()) && !"已返回".equals(os.getStatus())) { throw new ApiException(400, "外协任务须处于「已返回」或「合格入库」状态才能提取费用,当前: " + os.getStatus()); } MfgCostEntry entry = new MfgCostEntry(); entry.setEntryNo("MCE-OS-" + outsourceId); entry.setProjectCode(os.getProjectCode()); entry.setCostType("外协费用"); entry.setDescription("外协加工(" + os.getProcessType() + ") - " + os.getPartName() + " @ " + os.getVendor()); entry.setOutsourceNo(osNo); entry.setAmount(Money.nz(os.getAmount())); entry.setCostDate(LocalDate.now().toString()); entry.setHandler(os.getOwner()); entry.setApprovalStatus("待审核"); entry.setCreatedAt(Instant.now()); return ApiResp.ok(entryRepo.save(entry)); } // ---- 从发货单提取安装调试费用 ---- public record DeliveryInstallCostRequest( Double installCost, String description, String handler) { } /** * 从发货单提取安装调试费用(幂等:同 deliveryNo 已录入则 409)。 * 安装调试费用由调用方传入(工程师差旅+日补等实际发生额),关联 deliveryNo。 * 发货单须处于「安装调试」/「性能验收」/「已移交」阶段。 */ @PostMapping("/from-delivery/{deliveryId}") @Transactional public ApiResp fromDelivery(@PathVariable Long deliveryId, @RequestBody DeliveryInstallCostRequest req) { MfgDelivery delivery = deliveryRepo.findById(deliveryId) .orElseThrow(() -> new NotFoundException("发货单不存在: " + deliveryId)); String dlvNo = delivery.getDeliveryNo(); if (dlvNo != null) { boolean dup = entryRepo.findByCostType("安装调试费用").stream() .anyMatch(e -> dlvNo.equals(e.getDeliveryNo())); if (dup) { throw new ApiException(409, "该发货单(" + dlvNo + ")已录入安装调试费用,不可重复录入"); } } String status = delivery.getStatus(); if (!"安装调试".equals(status) && !"性能验收".equals(status) && !"已移交".equals(status)) { throw new ApiException(400, "发货单须处于「安装调试」「性能验收」「已移交」状态,当前: " + status); } if (req.installCost() == null || req.installCost() <= 0) { throw new ApiException(400, "安装调试费用(installCost)必须填写且大于0"); } MfgCostEntry entry = new MfgCostEntry(); entry.setEntryNo("MCE-DLV-" + deliveryId); entry.setProjectCode(delivery.getProjectCode()); entry.setCostType("安装调试费用"); entry.setDescription(req.description() != null ? req.description() : "现场安装调试费 - " + delivery.getDeviceName() + " @ " + (delivery.getSiteAddress() != null ? delivery.getSiteAddress() : "")); entry.setDeliveryNo(dlvNo); entry.setAmount(Money.of(req.installCost())); entry.setCostDate(LocalDate.now().toString()); entry.setHandler(req.handler() != null ? req.handler() : delivery.getEngineer()); entry.setApprovalStatus("待审核"); entry.setCreatedAt(Instant.now()); return ApiResp.ok(entryRepo.save(entry)); } // ---- 审核 ---- public record ApproveRequest(String approver) { } /** 审核确认(待审核 → 已审核)。 */ @PostMapping("/{id}/approve") public ApiResp approve(@PathVariable Long id, @RequestBody(required = false) ApproveRequest req) { MfgCostEntry entry = entryRepo.findById(id) .orElseThrow(() -> new NotFoundException("成本条目不存在: " + id)); if (!"待审核".equals(entry.getApprovalStatus())) { throw new ApiException(409, "只有「待审核」状态可审核,当前: " + entry.getApprovalStatus()); } entry.setApprovalStatus("已审核"); entry.setApproveDate(LocalDate.now().toString()); if (req != null && req.approver() != null) { entry.setApprover(req.approver()); } return ApiResp.ok(entryRepo.save(entry)); } // ---- 五维度项目成本汇总 ---- public record DimCost(String costType, BigDecimal totalAmount, long entryCount) {} public record ProjectCostFiveDim( String projectCode, BigDecimal outsourceCost, BigDecimal installCost, BigDecimal otherMfgCost, BigDecimal warrantyRepairCost, BigDecimal extendedCostTotal, List dimBreakdown) { } /** * 五维度项目成本汇总(外协+安装调试+其他制造+质保维修), * 供 MfgProjectCostController /summary 聚合接入: * 外协费用 / 安装调试费用 / 其他制造费用 / 质保维修费用 各维度分别汇总, * 前端在项目成本页额外展示「扩展成本」四维度,与原三维度(材料/人工/售后)并列。 */ @GetMapping("/project-summary/{projectCode}") public ApiResp projectSummary(@PathVariable String projectCode) { List entries = entryRepo.findByProjectCode(projectCode); Map byType = new LinkedHashMap<>(); Map countByType = new LinkedHashMap<>(); for (String t : COST_TYPES) { byType.put(t, BigDecimal.ZERO); countByType.put(t, 0L); } for (MfgCostEntry e : entries) { if (e.getCostType() == null) continue; byType.merge(e.getCostType(), Money.nz(e.getAmount()), Money::add); countByType.merge(e.getCostType(), 1L, Long::sum); } List breakdown = new ArrayList<>(); for (String t : COST_TYPES) { breakdown.add(new DimCost(t, byType.get(t), countByType.get(t))); } BigDecimal outsource = byType.get("外协费用"); BigDecimal install = byType.get("安装调试费用"); BigDecimal other = byType.get("其他制造费用"); BigDecimal warranty = byType.get("质保维修费用"); BigDecimal total = Money.add(Money.add(outsource, install), Money.add(other, warranty)); return ApiResp.ok(new ProjectCostFiveDim( projectCode, outsource, install, other, warranty, total, breakdown)); } // ---- helpers ---- private void fillEntry(MfgCostEntry entry, EntryRequest req) { if (req.projectCode() != null && !req.projectCode().isBlank()) entry.setProjectCode(req.projectCode()); if (req.costType() != null && !req.costType().isBlank()) entry.setCostType(req.costType()); if (req.description() != null) entry.setDescription(req.description()); if (req.outsourceNo() != null) entry.setOutsourceNo(req.outsourceNo()); if (req.deliveryNo() != null) entry.setDeliveryNo(req.deliveryNo()); if (req.amount() != null) entry.setAmount(Money.of(req.amount())); if (req.costDate() != null) entry.setCostDate(req.costDate()); if (req.handler() != null) entry.setHandler(req.handler()); if (req.remark() != null) entry.setRemark(req.remark()); } }