云
2 天以前 ac2e2b5af3726ee64dc37c3ebf26989a0346f22a
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productproduce/MesWmProductProduceServiceImpl.java
@@ -26,6 +26,8 @@
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProductService;
import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteProcessService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import cn.iocoder.yudao.module.mes.service.wm.productreceipt.MesWmProductReceiptService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
@@ -39,9 +41,15 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@@ -61,6 +69,8 @@
    @Resource
    private MesWmProductProduceLineService productProduceLineService;
    @Resource
    private cn.iocoder.yudao.module.mes.dal.mysql.wm.productproduce.MesWmProductProduceLineMapper produceLineMapper;
    @Resource
    private MesWmProductProduceDetailService productProduceDetailService;
    @Resource
    private MesProWorkOrderService workOrderService;
@@ -68,6 +78,8 @@
    private MesProWorkOrderProcessService workOrderProcessService;
    @Resource
    private MesProRouteProductService routeProductService;
    @Resource
    private MesProRouteProcessService routeProcessService;
    @Resource
    private MesWmProductReceiptService productReceiptService;
    @Resource
@@ -80,6 +92,8 @@
    private MesWmWarehouseLocationService locationService;
    @Resource
    private MesWmWarehouseAreaService areaService;
    @Resource
    private MesMdItemService itemService;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -99,12 +113,34 @@
        productProduceMapper.updateById(new MesWmProductProduceDO()
                .setId(id).setStatus(MesWmProductProduceStatusEnum.FINISHED.getStatus()));
        // 4. 只有末道工序的合格产出才生成产品入库单,避免中间工序重复生成成品入库
        MesProWorkOrderProcessDO lastProcess = workOrderProcessService
                .getLastWorkOrderProcessByWorkOrderId(produce.getWorkOrderId());
        if (lastProcess != null && ObjUtil.equal(lastProcess.getProcessId(), produce.getProcessId())) {
            productReceiptService.generateProductReceipt(produce, lines);
        // 4. 末道工序生成成品入库单;中间工序产出到实际仓时生成半成品入库单,避免「有库存无单据」
        //    产出到虚拟线边库属于内部在制,不生成入库单
        boolean lastProcess = isLastProcess(produce.getWorkOrderId(), produce.getProcessId(), produce.getFeedbackId());
        if (lastProcess || hasRealWarehouseOutput(lines)) {
            productReceiptService.generateProductReceipt(produce, lines, lastProcess);
        }
    }
    /**
     * 判断合格产出是否落在实际仓(非虚拟线边库)
     */
    private boolean hasRealWarehouseOutput(List<MesWmProductProduceLineDO> lines) {
        MesWmWarehouseDO virtualWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
        Long virtualWarehouseId = virtualWarehouse != null ? virtualWarehouse.getId() : null;
        for (MesWmProductProduceLineDO line : lines) {
            if (!ObjUtil.equal(line.getQualityStatus(), MesWmQualityStatusEnum.PASS.getStatus())) {
                continue;
            }
            List<MesWmProductProduceDetailDO> details = productProduceDetailService
                    .getProductProduceDetailListByLineId(line.getId());
            if (CollUtil.isEmpty(details)) {
                continue;
            }
            if (!ObjUtil.equal(details.get(0).getWarehouseId(), virtualWarehouseId)) {
                return true;
            }
        }
        return false;
    }
    private void createTransactionList(MesWmProductProduceDO produce, List<MesWmProductProduceLineDO> lines) {
@@ -112,7 +148,16 @@
                ? feedbackMapper.selectById(produce.getFeedbackId()) : null;
        String bizCode = feedback != null && cn.hutool.core.util.StrUtil.isNotBlank(feedback.getCode())
                ? feedback.getCode() : "PRODUCE-" + produce.getId();
        MesWmWarehouseDO virtualWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
        Long virtualWarehouseId = virtualWarehouse != null ? virtualWarehouse.getId() : null;
        for (MesWmProductProduceLineDO line : lines) {
            // 不合格品保留产出记录供质检处置,不计入可用库存。
            if (ObjUtil.equal(line.getQualityStatus(), MesWmQualityStatusEnum.FAIL.getStatus())) {
                continue;
            }
            if (!ObjUtil.equal(line.getQualityStatus(), MesWmQualityStatusEnum.PASS.getStatus())) {
                throw exception(WM_PRODUCT_PRODUCE_STATUS_INVALID);
            }
            // 1. 校验每行明细数量之和等于行数量
            List<MesWmProductProduceDetailDO> details = productProduceDetailService.getProductProduceDetailListByLineId(
                    line.getId());
@@ -121,10 +166,17 @@
            if (line.getQuantity() != null && totalDetailQty.compareTo(line.getQuantity()) != 0) {
                throw exception(WM_PRODUCT_PRODUCE_DETAIL_QUANTITY_MISMATCH);
            }
            // 2. 按明细创建库存事务(产品产出到线边库)
            // 2. 按明细创建库存事务
            for (MesWmProductProduceDetailDO detail : details) {
                // 虚拟线边库产出属于车间在制,需立即记账供下道工序倒冲领用;
                // 实际仓库的成品/半成品留到产品入库单「执行入库」时记账,避免未上架就先有库存流水
                if (!ObjUtil.equal(detail.getWarehouseId(), virtualWarehouseId)) {
                    continue;
                }
                var item = itemService.validateItemExists(detail.getItemId());
                Long stockItemId = item.getMdmItemId() != null ? item.getMdmItemId() : item.getId();
                wmTransactionService.createTransaction(new MesWmTransactionSaveReqDTO()
                        .setType(MesWmTransactionTypeEnum.IN.getType()).setItemId(detail.getItemId())
                        .setType(MesWmTransactionTypeEnum.IN.getType()).setItemId(stockItemId)
                        .setQuantity(detail.getQuantity())
                        .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                        .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
@@ -154,9 +206,118 @@
                .build();
        productProduceMapper.insert(produce);
        // 2. 获取或生成批次(审批请求中的批次属性优先)
        // 2. 解析本次报工的产出列表:主产出为报工物料,联产出按工单工序快照顺序并回查路线配比
        List<OutputPlan> outputs = resolveOutputPlans(feedback, workOrder);
        // 3. 根据是否需要检验分支处理
        if (checkFlag) {
            // 3.1 需要检验:每个产出创建一条「待检验」行,不生成明细
            for (OutputPlan output : outputs) {
                BatchInfo batch = resolveBatch(feedback, workOrder, output.itemId(), approveReqVO);
                MesWmProductProduceLineDO line = buildProduceLine(produce, feedback, output.itemId(),
                        batch.id(), batch.code(), scaleQuantity(feedback.getFeedbackQuantity(), output.ratio()),
                        MesWmQualityStatusEnum.PENDING.getStatus());
                productProduceLineService.createProductProduceLine(line);
            }
            // 注意:checkFlag=true 时不生成明细行,等 IPQC 检验完成后由 splitPendingAndFinishProduce 按质量结果拆行+生成明细
        } else {
            // 3.2 无需检验:每个产出按合格品/不合格品各生成一行 + 明细
            BigDecimal qualifiedQty = ObjectUtil.defaultIfNull(feedback.getQualifiedQuantity(), BigDecimal.ZERO);
            BigDecimal unqualifiedQty = ObjectUtil.defaultIfNull(feedback.getUnqualifiedQuantity(), BigDecimal.ZERO);
            for (OutputPlan output : outputs) {
                BigDecimal outputQualifiedQty = scaleQuantity(qualifiedQty, output.ratio());
                BigDecimal outputUnqualifiedQty = scaleQuantity(unqualifiedQty, output.ratio());
                BatchInfo batch = resolveBatch(feedback, workOrder, output.itemId(), approveReqVO);
                // 产出仓库:工单工序配置优先,其次该产出物料的路线产品仓库,中间工序可回退虚拟线边库
                WarehouseTarget outputTarget = resolveOutputTarget(feedback.getWorkOrderId(),
                        feedback.getProcessId(), output.itemId(), feedback.getId());
                // 3.2.1 不合格品行 + 明细
                if (outputUnqualifiedQty.compareTo(BigDecimal.ZERO) > 0) {
                    MesWmProductProduceLineDO unqualifiedLine = buildProduceLine(produce, feedback, output.itemId(),
                            batch.id(), batch.code(), outputUnqualifiedQty, MesWmQualityStatusEnum.FAIL.getStatus());
                    productProduceLineService.createProductProduceLine(unqualifiedLine);
                    MesWmProductProduceDetailDO unqualifiedDetail = buildProduceDetail(produce, output.itemId(),
                            batch.id(), batch.code(), unqualifiedLine.getId(), outputUnqualifiedQty,
                            outputTarget.warehouseId(), outputTarget.locationId(), outputTarget.areaId());
                    productProduceDetailService.createProductProduceDetail(unqualifiedDetail);
                }
                // 3.2.2 合格品行 + 明细
                if (outputQualifiedQty.compareTo(BigDecimal.ZERO) > 0) {
                    MesWmProductProduceLineDO qualifiedLine = buildProduceLine(produce, feedback, output.itemId(),
                            batch.id(), batch.code(), outputQualifiedQty, MesWmQualityStatusEnum.PASS.getStatus());
                    productProduceLineService.createProductProduceLine(qualifiedLine);
                    MesWmProductProduceDetailDO qualifiedDetail = buildProduceDetail(produce, output.itemId(),
                            batch.id(), batch.code(), qualifiedLine.getId(), outputQualifiedQty,
                            outputTarget.warehouseId(), outputTarget.locationId(), outputTarget.areaId());
                    productProduceDetailService.createProductProduceDetail(qualifiedDetail);
                }
            }
        }
        return produce;
    }
    /**
     * 解析报工产出列表:主产出固定为报工物料(配比 1),联产出按工单工序快照顺序,配比回查工艺路线配置。
     */
    private List<OutputPlan> resolveOutputPlans(MesProFeedbackDO feedback, MesProWorkOrderDO workOrder) {
        List<OutputPlan> single = List.of(new OutputPlan(feedback.getItemId(), BigDecimal.ONE));
        if (feedback.getWorkOrderId() == null || feedback.getProcessId() == null) {
            return single;
        }
        MesProWorkOrderProcessDO workOrderProcess = workOrderProcessService
                .getWorkOrderProcessByWorkOrderIdAndProcessId(feedback.getWorkOrderId(), feedback.getProcessId());
        List<Long> snapshotItemIds = parseItemIds(workOrderProcess != null ? workOrderProcess.getOutputItemId() : null);
        if (snapshotItemIds.size() <= 1) {
            return single;
        }
        // 回查路线产出配比:找不到配置时按 1 处理
        Map<Long, BigDecimal> ratioMap = new HashMap<>();
        Long routeId = feedback.getRouteId() != null ? feedback.getRouteId()
                : (workOrder != null ? workOrder.getRouteId() : null);
        if (routeId != null) {
            var routeProcess = routeProcessService.getRouteProcessByRouteIdAndProcessId(routeId, feedback.getProcessId());
            if (routeProcess != null) {
                for (var config : routeProcessService.getProcessOutputs(routeProcess.getId(),
                        workOrder != null ? workOrder.getProductId() : null)) {
                    if (config.getItemId() != null && config.getQuantity() != null) {
                        ratioMap.putIfAbsent(config.getItemId(), config.getQuantity());
                    }
                }
            }
        }
        List<OutputPlan> plans = new ArrayList<>();
        plans.add(new OutputPlan(feedback.getItemId(), BigDecimal.ONE));
        for (Long itemId : snapshotItemIds) {
            if (itemId == null || itemId.equals(feedback.getItemId())) {
                continue;
            }
            plans.add(new OutputPlan(itemId, ratioMap.getOrDefault(itemId, BigDecimal.ONE)));
        }
        return plans;
    }
    private List<Long> parseItemIds(String csv) {
        if (csv == null || csv.isBlank()) {
            return Collections.emptyList();
        }
        return Arrays.stream(csv.split(","))
                .map(String::trim).filter(value -> !value.isEmpty())
                .map(Long::valueOf).distinct().toList();
    }
    /**
     * 产出数量 = 基准数量 × 配比,保留 6 位小数,与产出单数量字段精度一致。
     */
    private BigDecimal scaleQuantity(BigDecimal base, BigDecimal ratio) {
        BigDecimal baseValue = base != null ? base : BigDecimal.ZERO;
        BigDecimal ratioValue = ratio != null ? ratio : BigDecimal.ONE;
        return baseValue.multiply(ratioValue).setScale(6, RoundingMode.HALF_UP);
    }
    private BatchInfo resolveBatch(MesProFeedbackDO feedback, MesProWorkOrderDO workOrder,
                                   Long itemId, MesProFeedbackApproveReqVO approveReqVO) {
        MesWmBatchGenerateReqVO batchReqVO = new MesWmBatchGenerateReqVO()
                .setItemId(feedback.getItemId())
                .setItemId(itemId)
                .setProduceDate(LocalDate.now().atStartOfDay()) // 截断到当天零点,确保同一天的报工生成相同批次号
                .setExpireDate(feedback.getExpireDate())
                .setWorkOrderId(feedback.getWorkOrderId())
@@ -179,70 +340,37 @@
            if (approveReqVO.getQualityStatus() != null) batchReqVO.setQualityStatus(approveReqVO.getQualityStatus());
        }
        MesWmBatchDO batch = batchService.getOrGenerateBatchCode(batchReqVO);
        Long batchId = batch != null ? batch.getId() : null;
        String batchCode = batch != null ? batch.getCode() : null;
        // 1.5 优先使用工单工序配置的报工仓库(下单时可编辑),其次工艺路线产品,最后回退虚拟线边库
        WarehouseTarget outputTarget = resolveOutputTarget(feedback.getWorkOrderId(),
                feedback.getProcessId(), feedback.getItemId());
        // 3. 根据是否需要检验分支处理
        if (checkFlag) {
            // 3.1 需要检验:创建一条行(质量状态=待检验),不生成明细
            MesWmProductProduceLineDO line = buildProduceLine(produce, feedback, batchId, batchCode,
                    feedback.getFeedbackQuantity(), MesWmQualityStatusEnum.PENDING.getStatus());
            productProduceLineService.createProductProduceLine(line);
            // 注意:checkFlag=true 时不生成明细行,等 IPQC 检验完成后由 splitPendingAndFinishProduce 按质量结果拆行+生成明细
        } else {
            // 3.2 无需检验:按合格品/不合格品各生成一行 + 明细
            BigDecimal qualifiedQty = ObjectUtil.defaultIfNull(feedback.getQualifiedQuantity(), BigDecimal.ZERO);
            BigDecimal unqualifiedQty = ObjectUtil.defaultIfNull(feedback.getUnqualifiedQuantity(), BigDecimal.ZERO);
            // 3.2.1 不合格品行 + 明细
            if (unqualifiedQty.compareTo(BigDecimal.ZERO) > 0) {
                MesWmProductProduceLineDO unqualifiedLine = buildProduceLine(produce, feedback, batchId, batchCode,
                        unqualifiedQty, MesWmQualityStatusEnum.FAIL.getStatus());
                productProduceLineService.createProductProduceLine(unqualifiedLine);
                MesWmProductProduceDetailDO unqualifiedDetail = buildProduceDetail(produce, feedback, batchId, batchCode,
                        unqualifiedLine.getId(), unqualifiedQty,
                        outputTarget.warehouseId(), outputTarget.locationId(), outputTarget.areaId());
                productProduceDetailService.createProductProduceDetail(unqualifiedDetail);
            }
            // 3.2.2 合格品行 + 明细
            if (qualifiedQty.compareTo(BigDecimal.ZERO) > 0) {
                MesWmProductProduceLineDO qualifiedLine = buildProduceLine(produce, feedback, batchId, batchCode,
                        qualifiedQty, MesWmQualityStatusEnum.PASS.getStatus());
                productProduceLineService.createProductProduceLine(qualifiedLine);
                MesWmProductProduceDetailDO qualifiedDetail = buildProduceDetail(produce, feedback, batchId, batchCode,
                        qualifiedLine.getId(), qualifiedQty,
                        outputTarget.warehouseId(), outputTarget.locationId(), outputTarget.areaId());
                productProduceDetailService.createProductProduceDetail(qualifiedDetail);
            }
        }
        return produce;
        return new BatchInfo(batch != null ? batch.getId() : null, batch != null ? batch.getCode() : null);
    }
    private MesWmProductProduceLineDO buildProduceLine(MesWmProductProduceDO produce, MesProFeedbackDO feedback,
                                                       Long batchId, String batchCode,
                                                       Long itemId, Long batchId, String batchCode,
                                                       BigDecimal quantity, Integer qualityStatus) {
        return MesWmProductProduceLineDO.builder()
                .produceId(produce.getId()).feedbackId(feedback.getId())
                .itemId(feedback.getItemId()).quantity(quantity)
                .itemId(itemId).quantity(quantity)
                .batchId(batchId).batchCode(batchCode)
                .expireDate(feedback.getExpireDate()).lotNumber(feedback.getLotNumber())
                .qualityStatus(qualityStatus)
                .build();
    }
    private MesWmProductProduceDetailDO buildProduceDetail(MesWmProductProduceDO produce, MesProFeedbackDO feedback,
    private MesWmProductProduceDetailDO buildProduceDetail(MesWmProductProduceDO produce, Long itemId,
                                                           Long batchId, String batchCode,
                                                           Long lineId, BigDecimal quantity,
                                                           Long warehouseId, Long locationId, Long areaId) {
        return MesWmProductProduceDetailDO.builder()
                .produceId(produce.getId()).lineId(lineId)
                .itemId(feedback.getItemId()).quantity(quantity)
                .itemId(itemId).quantity(quantity)
                .batchId(batchId).batchCode(batchCode)
                .warehouseId(warehouseId).locationId(locationId).areaId(areaId)
                .build();
    }
    private record OutputPlan(Long itemId, BigDecimal ratio) {
    }
    private record BatchInfo(Long id, String code) {
    }
    @Override
@@ -275,15 +403,56 @@
            throw exception(WM_PRODUCT_PRODUCE_NOT_EXISTS);
        }
        // 2. 通过 sourceLineId 直接定位待检验行
        MesWmProductProduceLineDO pendingLine = productProduceLineService.validateProductProduceLineExists(sourceLineId);
        WarehouseTarget outputTarget = resolveOutputTarget(produce.getWorkOrderId(),
                produce.getProcessId(), pendingLine.getItemId());
        MesWmProductProduceLineDO pendingLine = produceLineMapper.selectByIdForUpdate(sourceLineId);
        if (pendingLine == null) throw exception(WM_PRODUCT_PRODUCE_LINE_NOT_EXISTS);
        if (!java.util.Objects.equals(pendingLine.getProduceId(), produce.getId())
                || !java.util.Objects.equals(pendingLine.getFeedbackId(), feedbackId)) {
            throw exception(QC_IPQC_SOURCE_LINE_NOT_BELONG);
        }
        if (!java.util.Objects.equals(pendingLine.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus())) {
            throw exception(QC_IPQC_SOURCE_LINE_NOT_PENDING);
        }
        if (qualifiedQty == null || unqualifiedQty == null || qualifiedQty.signum() < 0 || unqualifiedQty.signum() < 0
                || qualifiedQty.add(unqualifiedQty).signum() <= 0 || pendingLine.getQuantity() == null
                || qualifiedQty.add(unqualifiedQty).compareTo(pendingLine.getQuantity()) != 0) {
            throw exception(QC_IPQC_SOURCE_QUANTITY_INVALID);
        }
        // 3. 只拆分当前检验的产出行;同一报工的其它产出物料各自建检验单、独立检验
        applyQualitySplit(produce, pendingLine, qualifiedQty, unqualifiedQty);
        // 3A. 情况一:存在不合格品数量,需要拆分行
        // 4. 全部产出物料检验完成后才完成产出单(创建库存事务 + 更新状态为已完成)
        boolean allInspected = productProduceLineService.getProductProduceLineListByProduceId(produce.getId())
                .stream().noneMatch(line -> ObjUtil.equal(line.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus()));
        if (allInspected) {
            finishProductProduce(produce.getId());
        }
    }
    /**
     * 按合格/不合格数量拆分一条待检行:生成明细,必要时新增不合格行。
     */
    private void applyQualitySplit(MesWmProductProduceDO produce, MesWmProductProduceLineDO pendingLine,
                                   BigDecimal qualifiedQty, BigDecimal unqualifiedQty) {
        WarehouseTarget outputTarget = resolveOutputTarget(produce.getWorkOrderId(),
                produce.getProcessId(), pendingLine.getItemId(), produce.getFeedbackId());
        if (qualifiedQty.signum() == 0) {
            pendingLine.setQualityStatus(MesWmQualityStatusEnum.FAIL.getStatus());
            productProduceLineService.updateProductProduceLine(pendingLine);
            productProduceDetailService.createProductProduceDetail(MesWmProductProduceDetailDO.builder()
                    .produceId(produce.getId()).lineId(pendingLine.getId())
                    .itemId(pendingLine.getItemId()).quantity(unqualifiedQty)
                    .batchId(pendingLine.getBatchId()).batchCode(pendingLine.getBatchCode())
                    .warehouseId(outputTarget.warehouseId()).locationId(outputTarget.locationId()).areaId(outputTarget.areaId())
                    .build());
            return;
        }
        // 情况一:存在不合格品数量,需要拆分行
        if (unqualifiedQty != null && unqualifiedQty.compareTo(BigDecimal.ZERO) > 0) {
            // 3A.1 不合格品:新建一行 + 明细
            // 不合格品:新建一行 + 明细
            MesWmProductProduceLineDO unqualifiedLine = MesWmProductProduceLineDO.builder()
                    .produceId(produce.getId()).feedbackId(feedbackId)
                    .produceId(produce.getId()).feedbackId(pendingLine.getFeedbackId())
                    .itemId(pendingLine.getItemId()).quantity(unqualifiedQty)
                    .batchId(pendingLine.getBatchId()).batchCode(pendingLine.getBatchCode())
                    .expireDate(pendingLine.getExpireDate()).lotNumber(pendingLine.getLotNumber())
@@ -296,7 +465,7 @@
                    .batchId(pendingLine.getBatchId()).batchCode(pendingLine.getBatchCode())
                    .warehouseId(outputTarget.warehouseId()).locationId(outputTarget.locationId()).areaId(outputTarget.areaId())
                    .build());
            // 3A.2 合格品:复用原待检行,更新数量和状态 + 明细
            // 合格品:复用原待检行,更新数量和状态 + 明细
            pendingLine.setQuantity(ObjectUtil.defaultIfNull(qualifiedQty, BigDecimal.ZERO));
            pendingLine.setQualityStatus(MesWmQualityStatusEnum.PASS.getStatus());
            productProduceLineService.updateProductProduceLine(pendingLine);
@@ -309,7 +478,7 @@
                        .build());
            }
        } else {
            // 3B. 情况二:全部合格,直接更新行状态 + 明细
            // 情况二:全部合格,直接更新行状态 + 明细
            pendingLine.setQualityStatus(MesWmQualityStatusEnum.PASS.getStatus());
            productProduceLineService.updateProductProduceLine(pendingLine);
            productProduceDetailService.createProductProduceDetail(MesWmProductProduceDetailDO.builder()
@@ -319,12 +488,10 @@
                    .warehouseId(outputTarget.warehouseId()).locationId(outputTarget.locationId()).areaId(outputTarget.areaId())
                    .build());
        }
        // 4. 完成产出单(创建库存事务 + 更新状态为已完成)
        finishProductProduce(produce.getId());
    }
    private WarehouseTarget resolveOutputTarget(Long workOrderId, Long processId, Long itemId) {
    private WarehouseTarget resolveOutputTarget(Long workOrderId, Long processId, Long itemId, Long feedbackId) {
        boolean lastProcess = isLastProcess(workOrderId, processId, feedbackId);
        // 1. 工单工序配置(下单时可编辑)
        if (workOrderId != null && processId != null) {
            MesProWorkOrderProcessDO workOrderProcess = workOrderProcessService
@@ -333,6 +500,7 @@
                    && workOrderProcess.getLocationId() != null && workOrderProcess.getAreaId() != null) {
                areaService.validateWarehouseAreaExists(workOrderProcess.getWarehouseId(),
                        workOrderProcess.getLocationId(), workOrderProcess.getAreaId());
                validateFinalWarehouse(lastProcess, workOrderProcess.getWarehouseId());
                return new WarehouseTarget(workOrderProcess.getWarehouseId(),
                        workOrderProcess.getLocationId(), workOrderProcess.getAreaId());
            }
@@ -344,7 +512,11 @@
                && routeProduct.getLocationId() != null && routeProduct.getAreaId() != null) {
            areaService.validateWarehouseAreaExists(routeProduct.getWarehouseId(),
                    routeProduct.getLocationId(), routeProduct.getAreaId());
            validateFinalWarehouse(lastProcess, routeProduct.getWarehouseId());
            return new WarehouseTarget(routeProduct.getWarehouseId(), routeProduct.getLocationId(), routeProduct.getAreaId());
        }
        if (lastProcess) {
            throw exception(WM_PRODUCT_PRODUCE_OUTPUT_WAREHOUSE_REQUIRED);
        }
        MesWmWarehouseDO warehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
        MesWmWarehouseLocationDO location = locationService.getWarehouseLocationByCode(
@@ -353,6 +525,33 @@
        return new WarehouseTarget(warehouse.getId(), location.getId(), area.getId());
    }
    private boolean isLastProcess(Long workOrderId, Long processId, Long feedbackId) {
        MesProWorkOrderProcessDO last = workOrderProcessService.getLastWorkOrderProcessByWorkOrderId(workOrderId);
        if (last != null) return ObjUtil.equal(last.getProcessId(), processId);
        MesProFeedbackDO feedback = feedbackId == null ? null : feedbackMapper.selectById(feedbackId);
        if (feedback == null || feedback.getRouteId() == null) {
            throw exception(PRO_FEEDBACK_PARAM_INVALID, "无法确定末道工序,请检查工单和工艺路线的工序顺序");
        }
        var processes = routeProcessService.getRouteProcessListByRouteId(feedback.getRouteId());
        if (CollUtil.isEmpty(processes) || processes.stream().anyMatch(p -> p.getSort() == null)) {
            throw exception(PRO_FEEDBACK_PARAM_INVALID, "无法确定末道工序,请检查工单和工艺路线的工序顺序");
        }
        int maxSort = processes.stream().mapToInt(p -> p.getSort()).max().orElseThrow();
        var lastProcesses = processes.stream().filter(p -> p.getSort() == maxSort).toList();
        if (lastProcesses.size() != 1 || lastProcesses.get(0).getProcessId() == null) {
            throw exception(PRO_FEEDBACK_PARAM_INVALID, "无法唯一确定末道工序,请检查工艺路线的工序顺序");
        }
        return ObjUtil.equal(lastProcesses.get(0).getProcessId(), processId);
    }
    private void validateFinalWarehouse(boolean lastProcess, Long warehouseId) {
        if (!lastProcess) return;
        MesWmWarehouseDO virtualWarehouse = warehouseService.getWarehouseByCode(MesWmWarehouseDO.WIP_VIRTUAL_WAREHOUSE);
        if (virtualWarehouse != null && ObjUtil.equal(warehouseId, virtualWarehouse.getId())) {
            throw exception(WM_PRODUCT_PRODUCE_OUTPUT_WAREHOUSE_REQUIRED);
        }
    }
    private record WarehouseTarget(Long warehouseId, Long locationId, Long areaId) {
    }