src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -10,14 +10,20 @@
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
import com.ruoyi.production.bean.vo.ProductionOrderPickVo;
import com.ruoyi.production.mapper.ProductionBomStructureMapper;
import com.ruoyi.production.mapper.ProductionOperationTaskMapper;
import com.ruoyi.production.mapper.ProductionOrderMapper;
import com.ruoyi.production.mapper.ProductionOrderPickMapper;
import com.ruoyi.production.mapper.ProductionOrderPickRecordMapper;
import com.ruoyi.production.mapper.ProductionOrderRoutingOperationMapper;
import com.ruoyi.production.pojo.ProductionBomStructure;
import com.ruoyi.production.pojo.ProductionOperationTask;
import com.ruoyi.production.pojo.ProductionOrder;
import com.ruoyi.production.pojo.ProductionOrderPick;
import com.ruoyi.production.pojo.ProductionOrderPickRecord;
import com.ruoyi.production.pojo.ProductionOrderRoutingOperation;
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.production.service.ProductionOrderService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
@@ -26,6 +32,8 @@
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockOutRecordService;
import com.ruoyi.technology.mapper.TechnologyOperationMapper;
import com.ruoyi.technology.pojo.TechnologyOperation;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +61,10 @@
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final ProductionOrderRoutingOperationMapper productionOrderRoutingOperationMapper;
    private final ProductionBomStructureMapper productionBomStructureMapper;
    private final ProductionOrderService productionOrderService;
    private final TechnologyOperationMapper technologyOperationMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final StockInRecordService stockInRecordService;
@@ -83,6 +95,7 @@
            // 保存领料主记录快照。
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(resolvedDto.getProductionOrderId());
            orderPick.setProductionOperationTaskId(resolvedDto.getProductionOperationTaskId());
            orderPick.setProductModelId(resolvedDto.getProductModelId());
            orderPick.setBatchNo(storedBatchNo);
            orderPick.setQuantity(resolvedDto.getPickQuantity());
@@ -191,7 +204,237 @@
        List<ProductionOrderPickVo> detailList = baseMapper.listPickedDetailByOrderId(productionOrderId);
        fillBatchNoList(detailList);
        fillSelectableBatchNoList(detailList);
        fillPickedFlagAndEffectiveQuantity(detailList);
        return detailList;
    }
    /**
     * 工序物料清单 = 订单BOM快照中属于该工序的可领用物料建议 + 该工单已领料记录。
     * 建议行 picked=false(id 为空),历史领料行 picked=true。
     */
    @Override
    public List<ProductionOrderPickVo> listTaskMaterials(Long productionOperationTaskId) {
        if (productionOperationTaskId == null) {
            return Collections.emptyList();
        }
        TaskOperationContext context = resolveTaskOperationContext(productionOperationTaskId);
        List<ProductionOrderPickVo> result = new ArrayList<>();
        // BOM 建议物料:按当前工序过滤,不带任何需求数量
        List<ProductionOrderPickVo> suggestionList =
                productionOrderService.pick(context.productionOrderId(), context.technologyOperationId());
        for (ProductionOrderPickVo suggestion : suggestionList) {
            suggestion.setId(null);
            suggestion.setPicked(false);
            suggestion.setProductionOrderId(context.productionOrderId());
            suggestion.setProductionOperationTaskId(productionOperationTaskId);
            suggestion.setTechnologyOperationId(context.technologyOperationId());
            if (StringUtils.isEmpty(suggestion.getOperationName())) {
                suggestion.setOperationName(context.operationName());
            }
            suggestion.setWorkOrderNo(context.workOrderNo());
            result.add(suggestion);
        }
        // 该工单已产生的领料记录
        List<ProductionOrderPickVo> pickedList = baseMapper.listPickedDetailByTaskId(productionOperationTaskId);
        fillBatchNoList(pickedList);
        fillSelectableBatchNoList(pickedList);
        fillPickedFlagAndEffectiveQuantity(pickedList);
        result.addAll(pickedList);
        return result;
    }
    /**
     * 工序统一领料:
     * 1) 由工单反查订单与工序,不信任前端传入的归属字段;
     * 2) 逐行校验物料、数量与批次;
     * 3) 复用出库申请 + 立即审批的库存扣减流程;
     * 4) 写入领料主记录与流水,重复领料即新增记录,不区分初领与补料。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean savePickByTask(ProductionOrderPickDto dto) {
        if (dto == null) {
            throw new ServiceException("参数不能为空");
        }
        Long productionOperationTaskId = dto.getProductionOperationTaskId();
        if (productionOperationTaskId == null) {
            throw new ServiceException("工单ID不能为空");
        }
        TaskOperationContext context = resolveTaskOperationContext(productionOperationTaskId);
        List<ProductionOrderPickDto> pickItems = resolvePickItems(dto).stream()
                .filter(Objects::nonNull)
                .filter(item -> item.getProductModelId() != null || item.getPickQuantity() != null)
                .collect(Collectors.toList());
        if (pickItems.isEmpty()) {
            throw new ServiceException("请至少填写一条领料物料");
        }
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
            if (resolvedDto.getProductModelId() == null) {
                throw new ServiceException("第" + rowNo + "行参数错误:产品规格不能为空");
            }
            if (resolvedDto.getPickQuantity() == null
                    || resolvedDto.getPickQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                throw new ServiceException("第" + rowNo + "行参数错误:领料数量必须大于0");
            }
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(context.productionOrderId());
            orderPick.setProductionOperationTaskId(productionOperationTaskId);
            orderPick.setProductModelId(resolvedDto.getProductModelId());
            orderPick.setBatchNo(storedBatchNo);
            orderPick.setQuantity(resolvedDto.getPickQuantity());
            orderPick.setRemark(resolvedDto.getRemark());
            orderPick.setOperationName(context.operationName());
            orderPick.setTechnologyOperationId(context.technologyOperationId());
            // 扁平BOM不配置数量,领料不再有需求数量约束
            orderPick.setDemandedQuantity(null);
            orderPick.setBom(isBomMaterial(context, resolvedDto.getProductModelId()));
            orderPick.setReturned(false);
            baseMapper.insert(orderPick);
            // 立即扣减库存,失败则整个事务回滚
            subtractInventory(orderPick.getId(),
                    resolvedDto.getProductModelId(),
                    storedBatchNo,
                    resolvedDto.getPickQuantity(),
                    rowNo,
                    PICK_STOCK_OUT_RECORD_TYPE);
            insertPickRecord(orderPick.getId(),
                    context.productionOrderId(),
                    productionOperationTaskId,
                    resolvedDto.getProductModelId(),
                    inventoryBatchNo,
                    resolvedDto.getPickQuantity(),
                    BigDecimal.ZERO,
                    resolvedDto.getPickQuantity(),
                    PICK_TYPE_NORMAL,
                    resolvedDto.getRemark(),
                    null);
        }
        return true;
    }
    /**
     * 当前工序是否已有有效领料:任一物料的净领料数量(领料+补料-退料)大于0。
     */
    @Override
    public boolean hasEffectivePick(Long productionOperationTaskId) {
        if (productionOperationTaskId == null) {
            return false;
        }
        List<ProductionOrderPick> pickList = baseMapper.selectList(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOperationTaskId, productionOperationTaskId));
        for (ProductionOrderPick pick : pickList) {
            if (pick == null) {
                continue;
            }
            if (resolveEffectiveQuantity(pick).compareTo(BigDecimal.ZERO) > 0) {
                return true;
            }
        }
        return false;
    }
    /**
     * 由工单解析出订单、工序快照与工序名称,并校验归属关系。
     */
    private TaskOperationContext resolveTaskOperationContext(Long productionOperationTaskId) {
        ProductionOperationTask task = productionOperationTaskMapper.selectById(productionOperationTaskId);
        if (task == null) {
            throw new ServiceException("工单不存在");
        }
        if (task.getProductionOrderId() == null) {
            throw new ServiceException("工单未关联生产订单");
        }
        ProductionOrder productionOrder = productionOrderMapper.selectById(task.getProductionOrderId());
        if (productionOrder == null) {
            throw new ServiceException("生产订单不存在");
        }
        ProductionOrderRoutingOperation routingOperation =
                productionOrderRoutingOperationMapper.selectById(task.getProductionOrderRoutingOperationId());
        if (routingOperation == null) {
            throw new ServiceException("工单对应的订单工序不存在");
        }
        if (routingOperation.getProductionOrderId() != null
                && !Objects.equals(routingOperation.getProductionOrderId(), task.getProductionOrderId())) {
            throw new ServiceException("工单与订单工序归属不一致");
        }
        if (routingOperation.getTechnologyOperationId() == null) {
            throw new ServiceException("订单工序未关联工艺工序");
        }
        String operationName = routingOperation.getOperationName();
        if (StringUtils.isEmpty(operationName)) {
            TechnologyOperation technologyOperation =
                    technologyOperationMapper.selectById(routingOperation.getTechnologyOperationId());
            operationName = technologyOperation == null ? null : technologyOperation.getName();
        }
        return new TaskOperationContext(
                task.getId(),
                task.getProductionOrderId(),
                routingOperation.getTechnologyOperationId(),
                operationName,
                task.getWorkOrderNo());
    }
    /**
     * 判断物料是否来自订单BOM快照中的当前工序。
     */
    private boolean isBomMaterial(TaskOperationContext context, Long productModelId) {
        if (productModelId == null) {
            return false;
        }
        return productionBomStructureMapper.selectCount(
                Wrappers.<ProductionBomStructure>lambdaQuery()
                        .eq(ProductionBomStructure::getProductionOrderId, context.productionOrderId())
                        .eq(ProductionBomStructure::getTechnologyOperationId, context.technologyOperationId())
                        .eq(ProductionBomStructure::getProductModelId, productModelId)) > 0;
    }
    private void fillPickedFlagAndEffectiveQuantity(List<ProductionOrderPickVo> detailList) {
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        for (ProductionOrderPickVo detail : detailList) {
            if (detail == null) {
                continue;
            }
            detail.setPicked(detail.getId() != null);
            detail.setEffectiveQuantity(resolveEffectiveQuantity(detail));
        }
    }
    /**
     * 净领料数量 = 领料数量 + 补料数量 - 退料数量。
     */
    private BigDecimal resolveEffectiveQuantity(ProductionOrderPick pick) {
        if (pick == null) {
            return BigDecimal.ZERO;
        }
        return defaultDecimal(pick.getQuantity())
                .add(defaultDecimal(pick.getFeedingQty()))
                .subtract(defaultDecimal(pick.getReturnQty()));
    }
    /**
     * 工单对应的领料上下文,避免依赖前端传入的订单/工序字段。
     */
    private record TaskOperationContext(Long productionOperationTaskId,
                                        Long productionOrderId,
                                        Long technologyOperationId,
                                        String operationName,
                                        String workOrderNo) {
    }
    private void processDeletePickIds(ProductionOrderPickDto rootDto,
@@ -294,6 +537,7 @@
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
        orderPick.setProductionOperationTaskId(dto.getProductionOperationTaskId());
        orderPick.setProductModelId(dto.getProductModelId());
        orderPick.setBatchNo(storedBatchNo);
        orderPick.setQuantity(dto.getPickQuantity());
@@ -430,7 +674,7 @@
        BigDecimal totalReturnQty = oldReturnQty.add(currentReturnQty);
        if (currentReturnQty.compareTo(BigDecimal.ZERO) > 0) {
            String returnBatchNo = resolveInventoryBatchNoFromStored(oldPick.getBatchNo());
            addInventory(oldPick.getId(), oldPick.getProductModelId(), returnBatchNo, currentReturnQty, FEED_RETURN_IN_RECORD_TYPE);
            addInventoryRecordOnly(oldPick.getId(), oldPick.getProductModelId(), returnBatchNo, currentReturnQty, FEED_RETURN_IN_RECORD_TYPE);
        }
        BigDecimal actualQty = defaultDecimal(oldPick.getQuantity())
@@ -737,6 +981,31 @@
        }
    }
    private void addInventoryRecordOnly(Long recordId,
                                        Long productModelId,
                                        String batchNo,
                                        BigDecimal quantity,
                                        String stockInRecordType) {
        // 仅记录入库申请,不做审核通过。
        BigDecimal addQuantity = defaultDecimal(quantity);
        if (addQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(addQuantity);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryService.addStockInRecordOnly(stockInventoryDto);
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("退料入库记录保存失败:" + ex.getMessage());
        }
    }
    private List<ProductionOrderPickDto> resolvePickItems(ProductionOrderPickDto dto) {
        // 解析新增场景的领料明细集合。
        if (dto == null) {