zss
3 天以前 9d42f647f5589e4a560d745d6b359ae6c273bd8d
src/main/java/com/ruoyi/production/service/impl/ProductionOrderPickServiceImpl.java
@@ -3,7 +3,9 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.common.enums.ReviewStatusEnum;
import com.ruoyi.common.enums.StockInQualifiedRecordTypeEnum;
import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.production.bean.dto.ProductionOrderPickDto;
@@ -18,8 +20,12 @@
import com.ruoyi.production.service.ProductionOrderPickService;
import com.ruoyi.stock.dto.StockInventoryDto;
import com.ruoyi.stock.mapper.StockInventoryMapper;
import com.ruoyi.stock.pojo.StockInRecord;
import com.ruoyi.stock.pojo.StockInventory;
import com.ruoyi.stock.pojo.StockOutRecord;
import com.ruoyi.stock.service.StockInventoryService;
import com.ruoyi.stock.service.StockInRecordService;
import com.ruoyi.stock.service.StockOutRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,12 +36,8 @@
import java.util.stream.Collectors;
/**
 * <p>
 * 璁㈠崟棰嗘枡绾胯竟浠?鏈嶅姟瀹炵幇绫?
 * </p>
 *
 * @author 鑺杞欢锛堟睙鑻忥級鏈夐檺鍏徃
 * @since 2026-04-21 03:55:52
 * 生产订单领料服务实现。
 * 负责领料新增、更新、补料、退料及库存联动。
 */
@Service
@RequiredArgsConstructor
@@ -43,27 +45,42 @@
    private static final byte PICK_TYPE_NORMAL = 1;
    private static final byte PICK_TYPE_FEEDING = 2;
    private static final String PICK_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.PICK_STOCK_OUT.getCode();
    private static final String FEED_STOCK_OUT_RECORD_TYPE = StockOutQualifiedRecordTypeEnum.FEED_STOCK_OUT.getCode();
    private static final String PICK_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode();
    private static final String FEED_RETURN_IN_RECORD_TYPE = StockInQualifiedRecordTypeEnum.FEED_RETURN_IN.getCode();
    private final ProductionOrderMapper productionOrderMapper;
    private final ProductionOperationTaskMapper productionOperationTaskMapper;
    private final ProductionOrderPickRecordMapper productionOrderPickRecordMapper;
    private final StockInventoryMapper stockInventoryMapper;
    private final StockInventoryService stockInventoryService;
    private final StockInRecordService stockInRecordService;
    private final StockOutRecordService stockOutRecordService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean savePick(ProductionOrderPickDto dto) {
        // 领料新增总流程:
        // 1) 解析前端行数据并逐行合并参数;
        // 2) 校验参数与批次;
        // 3) 先保存领料主记录;
        // 4) 再走“出库申请 + 审批通过”完成库存扣减;
        // 5) 写入领料流水,记录数量变化轨迹。
        List<ProductionOrderPickDto> pickItems = resolvePickItems(dto);
        // 逐行处理领料数据,行号用于拼装精确的报错信息。
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
            // 每行都做完整校验,异常信息带行号。
            validatePickParam(resolvedDto, rowNo);
            // 统一处理批次(支持单批次/多批次)。
            List<String> batchNoList = resolveBatchNoList(resolvedDto);
            String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
            String storedBatchNo = formatBatchNoStorage(batchNoList);
            subtractInventory(resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo);
            // 保存领料主记录快照。
            ProductionOrderPick orderPick = new ProductionOrderPick();
            orderPick.setProductionOrderId(resolvedDto.getProductionOrderId());
            orderPick.setProductModelId(resolvedDto.getProductModelId());
@@ -75,8 +92,13 @@
            orderPick.setDemandedQuantity(resolvedDto.getDemandedQuantity());
            orderPick.setBom(resolvedDto.getBom());
            orderPick.setReturned(false);
            // 新增主记录。
            baseMapper.insert(orderPick);
            // 先新增出库申请,再审批通过,完成库存扣减。
            subtractInventory(orderPick.getId(), resolvedDto.getProductModelId(), storedBatchNo, resolvedDto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            // 记录本次领料流水(before=0,after=本次领料量)。
            insertPickRecord(orderPick.getId(),
                    resolvedDto.getProductionOrderId(),
                    resolvedDto.getProductionOperationTaskId(),
@@ -95,8 +117,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean updatePick(ProductionOrderPickDto dto) {
        // 领料更新入口(同接口兼容三类业务):
        // 1) 普通领料改量/增删;
        // 2) 补料(pickType=2);
        // 3) 退料(returned=true)。
        if (dto == null) {
            throw new ServiceException("变更参数不能为空");
            throw new ServiceException("参数不能为空");
        }
        Long productionOrderId = resolveProductionOrderId(dto);
        if (productionOrderId == null) {
@@ -107,26 +133,32 @@
            throw new ServiceException("生产订单不存在");
        }
        // 查询订单下现有领料记录并构建ID索引。
        List<ProductionOrderPick> existingPickList = baseMapper.selectList(
                Wrappers.<ProductionOrderPick>lambdaQuery()
                        .eq(ProductionOrderPick::getProductionOrderId, productionOrderId));
        // 转成Map便于后续按ID快速校验与更新。
        Map<Long, ProductionOrderPick> existingPickMap = existingPickList.stream()
                .filter(item -> item.getId() != null)
                .collect(Collectors.toMap(ProductionOrderPick::getId, Function.identity(), (a, b) -> a));
        // 补料请求单独走补料分支。
        if (isFeedingRequest(dto)) {
            processFeedingPickItems(dto, existingPickMap, productionOrderId);
            return true;
        }
        // 退料请求单独走退料分支。
        if (isReturnRequest(dto)) {
            processReturnPickItems(dto, existingPickMap, productionOrderId);
            return true;
        }
        // 普通更新场景先处理显式删除。
        processDeletePickIds(dto, existingPickMap, productionOrderId);
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(dto);
        Set<Long> keepPickIdSet = new HashSet<>();
        // keepPickIdSet 用于标记本次前端仍然保留的旧记录,后续用于识别“未回传即删除”的行。
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
            ProductionOrderPickDto resolvedDto = mergeDto(dto, pickItems.get(i));
@@ -145,12 +177,14 @@
            keepPickIdSet.add(resolvedDto.getId());
            updateExistingPick(resolvedDto, rowNo, existingPickMap);
        }
        // 清理前端未回传旧行并回补库存。
        processMissingPickItems(dto, existingPickMap, productionOrderId, keepPickIdSet);
        return true;
    }
    @Override
    public List<ProductionOrderPickVo> listPickedDetail(Long productionOrderId) {
        // 查询订单领料明细,并补齐批次展示字段。
        if (productionOrderId == null) {
            return Collections.emptyList();
        }
@@ -163,6 +197,11 @@
    private void processDeletePickIds(ProductionOrderPickDto rootDto,
                                      Map<Long, ProductionOrderPick> existingPickMap,
                                      Long productionOrderId) {
        // 处理前端显式删除ID:
        // 1) 校验删除目标是否属于当前订单;
        // 2) 回补库存;
        // 3) 删除主记录;
        // 4) 记录删除流水。
        if (rootDto.getDeletePickIds() == null || rootDto.getDeletePickIds().isEmpty()) {
            return;
        }
@@ -173,14 +212,19 @@
            }
            ProductionOrderPick existingPick = existingPickMap.get(deleteId);
            if (existingPick == null || !Objects.equals(existingPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("要删除的领料记录不存在或不属于当前订单,ID=" + deleteId);
                throw new ServiceException("删除失败:领料记录不存在或不属于当前订单,ID=" + deleteId);
            }
            String oldBatchNo = resolveInventoryBatchNoFromStored(existingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(existingPick.getQuantity());
            addInventory(existingPick.getProductModelId(), oldBatchNo, oldQuantity);
            addInventory(existingPick.getId(), existingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // 删除关联领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, existingPick.getId())
            );
            int affected = baseMapper.deleteById(deleteId);
            if (affected <= 0) {
                throw new ServiceException("删除领料失败,ID=" + deleteId);
                throw new ServiceException("删除领料记录失败,ID=" + deleteId);
            }
            insertPickRecord(existingPick.getId(),
                    existingPick.getProductionOrderId(),
@@ -201,6 +245,9 @@
                                         Map<Long, ProductionOrderPick> existingPickMap,
                                         Long productionOrderId,
                                         Set<Long> keepPickIdSet) {
        // 处理“前端未回传”的旧行:
        // 对应场景是用户在前端删除行但未放入 deletePickIds。
        // 这里兜底识别并执行回补库存 + 删除主记录 + 写流水。
        if (rootDto.getPickList() == null) {
            return;
        }
@@ -213,10 +260,15 @@
        for (ProductionOrderPick missingPick : missingPickList) {
            String oldBatchNo = resolveInventoryBatchNoFromStored(missingPick.getBatchNo());
            BigDecimal oldQuantity = defaultDecimal(missingPick.getQuantity());
            addInventory(missingPick.getProductModelId(), oldBatchNo, oldQuantity);
            addInventory(missingPick.getId(), missingPick.getProductModelId(), oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            // 删除关联领料流水,避免遗留无主记录。
            productionOrderPickRecordMapper.delete(
                    Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                            .eq(ProductionOrderPickRecord::getPickId, missingPick.getId())
            );
            int affected = baseMapper.deleteById(missingPick.getId());
            if (affected <= 0) {
                throw new ServiceException("删除领料失败,ID=" + missingPick.getId());
                throw new ServiceException("删除未回传领料记录失败,ID=" + missingPick.getId());
            }
            insertPickRecord(missingPick.getId(),
                    missingPick.getProductionOrderId(),
@@ -234,10 +286,11 @@
    }
    private void addNewPickInUpdate(ProductionOrderPickDto dto, int rowNo) {
        // 更新场景下新增一条领料:
        // 新增主记录 -> 出库申请并审批 -> 写流水。
        List<String> batchNoList = resolveBatchNoList(dto);
        String inventoryBatchNo = pickInventoryBatchNo(batchNoList);
        String storedBatchNo = formatBatchNoStorage(batchNoList);
        subtractInventory(dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo);
        ProductionOrderPick orderPick = new ProductionOrderPick();
        orderPick.setProductionOrderId(dto.getProductionOrderId());
@@ -251,6 +304,9 @@
        orderPick.setBom(dto.getBom());
        orderPick.setReturned(false);
        baseMapper.insert(orderPick);
        // 先新增出库申请,再审批通过,完成库存扣减。
        subtractInventory(orderPick.getId(), dto.getProductModelId(), storedBatchNo, dto.getPickQuantity(), rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        insertPickRecord(orderPick.getId(),
                dto.getProductionOrderId(),
@@ -268,6 +324,8 @@
    private void processFeedingPickItems(ProductionOrderPickDto rootDto,
                                         Map<Long, ProductionOrderPick> existingPickMap,
                                         Long productionOrderId) {
        // 补料流程入口:
        // 逐行校验补料参数,校验原领料归属,再执行补料库存扣减和主记录回写。
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(rootDto);
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
@@ -276,7 +334,7 @@
                continue;
            }
            if (!isFeedingPick(resolvedDto)) {
                throw new ServiceException("补料请求中的领料类型必须全部为2");
                throw new ServiceException("补料请求中存在非补料类型数据");
            }
            if (resolvedDto.getProductionOrderId() == null) {
                resolvedDto.setProductionOrderId(productionOrderId);
@@ -285,15 +343,20 @@
            ProductionOrderPick oldPick = existingPickMap.get(resolvedDto.getId());
            if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("第" + rowNo + "条领料记录不存在或不属于当前订单");
                throw new ServiceException("第" + rowNo + "行补料失败:未找到对应的领料记录");
            }
            addFeedingPick(resolvedDto, oldPick, rowNo);
        }
    }
    private void addFeedingPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // 补料核心:
        // 1) 校验规格一致;
        // 2) 扣减补料库存;
        // 3) 写补料流水;
        // 4) 回写主单累计补料量和实际量。
        if (dto.getProductModelId() != null && !Objects.equals(dto.getProductModelId(), oldPick.getProductModelId())) {
            throw new ServiceException("第" + rowNo + "条补料产品规格与领料记录不一致");
            throw new ServiceException("第" + rowNo + "行补料失败:产品规格与原领料记录不一致");
        }
        Long productModelId = oldPick.getProductModelId();
        List<String> batchNoList = resolveBatchNoList(dto);
@@ -302,8 +365,9 @@
                : formatBatchNoStorage(batchNoList);
        BigDecimal feedingQuantity = dto.getFeedingQuantity();
        subtractInventory(productModelId, inventoryBatchNo, feedingQuantity, rowNo);
        subtractInventory(oldPick.getId(), productModelId, inventoryBatchNo, feedingQuantity, rowNo, FEED_STOCK_OUT_RECORD_TYPE);
        // 计算补料前后数量并写补料流水。
        BigDecimal beforeFeedingQty = sumFeedingQuantity(dto.getProductionOrderId(), oldPick.getId());
        BigDecimal afterFeedingQty = beforeFeedingQty.add(feedingQuantity);
        insertPickRecord(oldPick.getId(),
@@ -322,9 +386,10 @@
        updatePick.setId(oldPick.getId());
        updatePick.setFeedingQty(afterFeedingQty);
        updatePick.setActualQty(calculateActualQty(oldPick, afterFeedingQty));
        // 回写主记录的补料累计值与实际用量。
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条补料总量更新失败");
            throw new ServiceException("第" + rowNo + "行补料失败:更新领料主记录失败");
        }
        oldPick.setFeedingQty(afterFeedingQty);
        oldPick.setActualQty(updatePick.getActualQty());
@@ -333,6 +398,8 @@
    private void processReturnPickItems(ProductionOrderPickDto rootDto,
                                        Map<Long, ProductionOrderPick> existingPickMap,
                                        Long productionOrderId) {
        // 退料流程入口:
        // 逐行校验退料参数与领料归属,再更新退料量与实际量字段。
        List<ProductionOrderPickDto> pickItems = resolveUpdateItems(rootDto);
        for (int i = 0; i < pickItems.size(); i++) {
            int rowNo = i + 1;
@@ -347,33 +414,56 @@
            ProductionOrderPick oldPick = existingPickMap.get(resolvedDto.getId());
            if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), productionOrderId)) {
                throw new ServiceException("第" + rowNo + "条领料记录不存在或不属于当前订单");
                throw new ServiceException("第" + rowNo + "行退料失败:未找到对应的领料记录");
            }
            updateReturnPick(resolvedDto, oldPick, rowNo);
        }
    }
    private void updateReturnPick(ProductionOrderPickDto dto, ProductionOrderPick oldPick, int rowNo) {
        // 退料更新:
        // 1) returnQty 按“本次退料量”处理;
        // 2) 本次退料量回补到“生产退料入库”;
        // 3) 累加主记录退料总量并重算实际量。
        BigDecimal oldReturnQty = defaultDecimal(oldPick.getReturnQty());
        BigDecimal currentReturnQty = defaultDecimal(dto.getReturnQty());
        BigDecimal totalReturnQty = oldReturnQty.add(currentReturnQty);
        if (currentReturnQty.compareTo(BigDecimal.ZERO) > 0) {
            String returnBatchNo = resolveInventoryBatchNoFromStored(oldPick.getBatchNo());
            addInventoryRecordOnly(oldPick.getId(), oldPick.getProductModelId(), returnBatchNo, currentReturnQty, FEED_RETURN_IN_RECORD_TYPE);
        }
        BigDecimal actualQty = defaultDecimal(oldPick.getQuantity())
                .add(defaultDecimal(oldPick.getFeedingQty()))
                .subtract(totalReturnQty);
        if (actualQty.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "行退料失败:累计退料数量不能大于可用数量");
        }
        ProductionOrderPick updatePick = new ProductionOrderPick();
        updatePick.setId(oldPick.getId());
        updatePick.setReturnQty(dto.getReturnQty());
        updatePick.setActualQty(dto.getActualQty());
        updatePick.setReturned(true);
        updatePick.setReturnQty(totalReturnQty);
        updatePick.setActualQty(actualQty);
        updatePick.setReturned(totalReturnQty.compareTo(BigDecimal.ZERO) > 0);
        int affected = baseMapper.updateById(updatePick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条退料信息更新失败");
            throw new ServiceException("第" + rowNo + "行退料失败:更新领料主记录失败");
        }
        oldPick.setReturnQty(updatePick.getReturnQty());
        oldPick.setActualQty(updatePick.getActualQty());
        oldPick.setReturned(true);
        oldPick.setReturned(updatePick.getReturned());
    }
    private void updateExistingPick(ProductionOrderPickDto dto,
                                    int rowNo,
                                    Map<Long, ProductionOrderPick> existingPickMap) {
        // 普通更新单行核心流程:
        // 1) 校验旧记录存在且属于当前订单;
        // 2) 比较新旧“规格+批次”,决定库存处理策略;
        // 3) 更新主记录;
        // 4) 写变更流水(记录前后数量变化)。
        ProductionOrderPick oldPick = existingPickMap.get(dto.getId());
        if (oldPick == null || !Objects.equals(oldPick.getProductionOrderId(), dto.getProductionOrderId())) {
            throw new ServiceException("第" + rowNo + "条领料记录不存在或不属于当前订单");
            throw new ServiceException("第" + rowNo + "行更新失败:未找到对应的领料记录");
        }
        Long oldProductModelId = oldPick.getProductModelId();
@@ -386,18 +476,30 @@
        String newStoredBatchNo = formatBatchNoStorage(newBatchNoList);
        BigDecimal newQuantity = dto.getPickQuantity();
        // 判断规格+批次或数量是否变化,并按场景处理库存:
        // 1) 同规格同批次:按差值处理(增量扣减 / 减量回退);
        // 2) 规格或批次变化:回退旧领料后再重提新领料。
        boolean sameStockKey = Objects.equals(oldProductModelId, newProductModelId)
                && Objects.equals(oldBatchNo, newBatchNo);
        boolean quantityChanged = oldQuantity.compareTo(newQuantity) != 0;
        boolean needReissuePickRecord = !sameStockKey || quantityChanged;
        if (sameStockKey) {
            BigDecimal delta = newQuantity.subtract(oldQuantity);
            if (delta.compareTo(BigDecimal.ZERO) > 0) {
                subtractInventory(newProductModelId, newStoredBatchNo, delta, rowNo);
            } else if (delta.compareTo(BigDecimal.ZERO) < 0) {
                addInventory(oldProductModelId, oldBatchNo, delta.abs());
            BigDecimal deltaQuantity = newQuantity.subtract(oldQuantity);
            if (deltaQuantity.compareTo(BigDecimal.ZERO) > 0) {
                // 数量增加,只扣减新增部分。
                subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, deltaQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
            } else if (deltaQuantity.compareTo(BigDecimal.ZERO) < 0) {
                // 数量减少,只回退差值部分。
                addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, deltaQuantity.abs(), PICK_RETURN_IN_RECORD_TYPE);
            }
        } else {
            addInventory(oldProductModelId, oldBatchNo, oldQuantity);
            subtractInventory(newProductModelId, newStoredBatchNo, newQuantity, rowNo);
            // 规格或批次变化:先全量回退旧领料,再全量扣减新领料。
            addInventory(oldPick.getId(), oldProductModelId, oldBatchNo, oldQuantity, PICK_RETURN_IN_RECORD_TYPE);
            subtractInventory(oldPick.getId(), newProductModelId, newStoredBatchNo, newQuantity, rowNo, PICK_STOCK_OUT_RECORD_TYPE);
        }
        if (needReissuePickRecord) {
            // 正常领料流水按“最新领料量”重建,避免保留历史旧值。
            deleteNormalPickRecord(oldPick.getId());
        }
        oldPick.setProductModelId(newProductModelId);
@@ -406,6 +508,9 @@
        oldPick.setRemark(dto.getRemark());
        oldPick.setOperationName(dto.getOperationName());
        oldPick.setTechnologyOperationId(dto.getTechnologyOperationId());
        // 普通更新也要同步重算实际用量,避免沿用旧值。
        // 规则:实际用量 = 领料数量 + 补料数量 - 退料数量。
        oldPick.setActualQty(calculateActualQty(oldPick, oldPick.getFeedingQty()));
        if (dto.getDemandedQuantity() != null) {
            oldPick.setDemandedQuantity(dto.getDemandedQuantity());
        }
@@ -414,18 +519,18 @@
        }
        int affected = baseMapper.updateById(oldPick);
        if (affected <= 0) {
            throw new ServiceException("第" + rowNo + "条领料更新失败");
            throw new ServiceException("第" + rowNo + "行更新失败:更新领料记录失败");
        }
        BigDecimal recordQuantity = sameStockKey ? oldQuantity.subtract(newQuantity).abs() : newQuantity;
        if (recordQuantity.compareTo(BigDecimal.ZERO) > 0 || oldQuantity.compareTo(newQuantity) != 0 || !sameStockKey) {
        // 如果发生领料重提,补写一条新的正常领料流水。
        if (needReissuePickRecord) {
            insertPickRecord(oldPick.getId(),
                    dto.getProductionOrderId(),
                    dto.getProductionOperationTaskId(),
                    newProductModelId,
                    newBatchNo,
                    recordQuantity,
                    oldQuantity,
                    newQuantity,
                    BigDecimal.ZERO,
                    newQuantity,
                    dto.getPickType(),
                    dto.getRemark(),
@@ -444,6 +549,7 @@
                                  Byte pickType,
                                  String remark,
                                  String feedingReason) {
        // 写领料流水记录:统一记录领料/补料/退料数量变化轨迹。
        ProductionOrderPickRecord pickRecord = new ProductionOrderPickRecord();
        pickRecord.setPickId(pickId);
        pickRecord.setProductionOrderId(productionOrderId);
@@ -459,8 +565,31 @@
        productionOrderPickRecordMapper.insert(pickRecord);
    }
    private void subtractInventory(Long productModelId, String batchNo, BigDecimal quantity, int rowNo) {
    private void deleteNormalPickRecord(Long pickId) {
        // 删除该领料单历史上的“正常领料”流水,保留补料/退料流水。
        if (pickId == null) {
            return;
        }
        productionOrderPickRecordMapper.delete(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getPickId, pickId)
                        .eq(ProductionOrderPickRecord::getPickType, PICK_TYPE_NORMAL)
        );
    }
    private void subtractInventory(Long recordId,
                                   Long productModelId,
                                   String batchNo,
                                   BigDecimal quantity,
                                   int rowNo,
                                   String stockOutRecordType) {
        // 扣减库存总流程:
        // 1) 解析批次列表;
        // 2) 计算每个批次可用量与总可用量;
        // 3) 按批次顺序逐笔“新增出库记录并审批通过”,直到扣完目标数量;
        // 4) 任一步失败即抛错并回滚事务。
        BigDecimal deductQuantity = defaultDecimal(quantity);
        // 领料数量小于等于0时,不需要执行库存扣减。
        if (deductQuantity.compareTo(BigDecimal.ZERO) <= 0) {
            return;
        }
@@ -470,9 +599,12 @@
            batchNoList = Collections.singletonList(null);
        }
        // 先计算各批次可用量,避免边扣边算导致判断不一致。
        Map<String, BigDecimal> availableQuantityMap = new LinkedHashMap<>();
        BigDecimal totalAvailableQuantity = BigDecimal.ZERO;
        // 遍历批次,计算每个批次可用库存。
        for (String currentBatchNo : batchNoList) {
            // 查询当前规格+批次的库存记录。
            StockInventory stockInventory = stockInventoryMapper.selectOne(buildStockWrapper(productModelId, currentBatchNo));
            BigDecimal availableQuantity = BigDecimal.ZERO;
            if (stockInventory != null) {
@@ -488,10 +620,11 @@
        if (deductQuantity.compareTo(totalAvailableQuantity) > 0) {
            BigDecimal shortQuantity = deductQuantity.subtract(totalAvailableQuantity);
            throw new ServiceException("领料可用库存不足,可用库存为" + formatQuantity(totalAvailableQuantity)
                    + ",还差" + formatQuantity(shortQuantity));
            throw new ServiceException("第" + rowNo + "行扣减库存失败:可用库存不足,当前可用"
                    + formatQuantity(totalAvailableQuantity) + ",仍缺少" + formatQuantity(shortQuantity));
        }
        // 按批次顺序逐笔扣减库存。
        BigDecimal remainingQuantity = deductQuantity;
        for (Map.Entry<String, BigDecimal> entry : availableQuantityMap.entrySet()) {
            if (remainingQuantity.compareTo(BigDecimal.ZERO) <= 0) {
@@ -502,39 +635,137 @@
                continue;
            }
            BigDecimal currentDeductQuantity = remainingQuantity.min(availableQuantity);
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(entry.getKey());
            stockInventoryDto.setQualitity(currentDeductQuantity);
            int affected = stockInventoryMapper.updateSubtractStockInventory(stockInventoryDto);
            if (affected <= 0) {
                throw new ServiceException("第" + rowNo + "条领料扣减库存失败");
            }
            createAndApproveStockOutRecord(recordId, productModelId, entry.getKey(), currentDeductQuantity, rowNo, stockOutRecordType);
            remainingQuantity = remainingQuantity.subtract(currentDeductQuantity);
        }
        if (remainingQuantity.compareTo(BigDecimal.ZERO) > 0) {
            throw new ServiceException("第" + rowNo + "条领料扣减库存失败,剩余待扣减数量为" + formatQuantity(remainingQuantity));
            throw new ServiceException("第" + rowNo + "行扣减库存失败:仍有未扣减数量" + formatQuantity(remainingQuantity));
        }
    }
    private void addInventory(Long productModelId, String batchNo, BigDecimal quantity) {
    private void createAndApproveStockOutRecord(Long recordId,
                                                Long productModelId,
                                                String batchNo,
                                                BigDecimal quantity,
                                                int rowNo,
                                                String stockOutRecordType) {
        // 库存扣减改为两步:
        // 1) 先调用 addStockOutRecordOnly 新增待审批出库记录;
        // 2) 再调用出库审批,审批状态固定传 1(通过)。
        try {
            StockInventoryDto stockInventoryDto = new StockInventoryDto();
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
            stockInventoryDto.setRecordType(stockOutRecordType);
            stockInventoryDto.setProductModelId(productModelId);
            stockInventoryDto.setBatchNo(batchNo);
            stockInventoryDto.setQualitity(quantity);
            stockInventoryService.addStockOutRecordOnly(stockInventoryDto);
            LambdaQueryWrapper<StockOutRecord> recordWrapper = Wrappers.<StockOutRecord>lambdaQuery()
                    .eq(StockOutRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockOutRecord::getRecordType, stockOutRecordType)
                    .eq(StockOutRecord::getProductModelId, productModelId)
                    .eq(StockOutRecord::getType, "0")
                    .orderByDesc(StockOutRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockOutRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockOutRecord::getBatchNo, batchNo);
            }
            StockOutRecord stockOutRecord = stockOutRecordService.getOne(recordWrapper, false);
            if (stockOutRecord == null || stockOutRecord.getId() == null) {
                throw new ServiceException("第" + rowNo + "行扣减库存失败:未找到对应出库申请记录");
            }
            stockOutRecordService.batchApprove(
                    Collections.singletonList(stockOutRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("第" + rowNo + "行扣减库存失败:" + ex.getMessage());
        }
    }
    private void addInventory(Long recordId,
                              Long productModelId,
                              String batchNo,
                              BigDecimal quantity,
                              String stockInRecordType) {
        // 回补库存改为两步:
        // 1) 先新增入库申请;
        // 2) 再审批通过,确保库存立刻回补生效。
        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(String.valueOf(StockInQualifiedRecordTypeEnum.PICK_RETURN_IN.getCode()));
        stockInventoryDto.setRecordId(0L);
            stockInventoryDto.setRecordType(stockInRecordType);
            stockInventoryDto.setRecordId(recordId == null ? 0L : recordId);
        stockInventoryService.addStockInRecordOnly(stockInventoryDto);
            LambdaQueryWrapper<StockInRecord> recordWrapper = Wrappers.<StockInRecord>lambdaQuery()
                    .eq(StockInRecord::getRecordId, stockInventoryDto.getRecordId())
                    .eq(StockInRecord::getRecordType, stockInventoryDto.getRecordType())
                    .eq(StockInRecord::getProductModelId, productModelId)
                    .eq(StockInRecord::getType, "0")
                    .orderByDesc(StockInRecord::getId)
                    .last("limit 1");
            if (StringUtils.isEmpty(batchNo)) {
                recordWrapper.isNull(StockInRecord::getBatchNo);
            } else {
                recordWrapper.eq(StockInRecord::getBatchNo, batchNo);
            }
            StockInRecord stockInRecord = stockInRecordService.getOne(recordWrapper, false);
            if (stockInRecord == null || stockInRecord.getId() == null) {
                throw new ServiceException("回补库存失败:未找到对应入库申请记录");
            }
            stockInRecordService.batchApprove(
                    Collections.singletonList(stockInRecord.getId()),
                    ReviewStatusEnum.APPROVED.getCode()
            );
        } catch (ServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new ServiceException("回补库存失败:" + ex.getMessage());
        }
    }
    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) {
            throw new ServiceException("领料参数不能为空");
            throw new ServiceException("参数不能为空");
        }
        if (dto.getPickList() != null && !dto.getPickList().isEmpty()) {
            return dto.getPickList();
@@ -543,6 +774,7 @@
    }
    private List<ProductionOrderPickDto> resolveUpdateItems(ProductionOrderPickDto dto) {
        // 解析更新场景的领料明细集合。
        if (dto.getPickList() != null) {
            return dto.getPickList();
        }
@@ -553,6 +785,7 @@
    }
    private boolean isEmptyUpdateItem(ProductionOrderPickDto dto) {
        // 判断更新行是否为空白占位行。
        return dto.getId() == null
                && dto.getProductModelId() == null
                && dto.getPickQuantity() == null
@@ -573,6 +806,7 @@
    }
    private Long resolveProductionOrderId(ProductionOrderPickDto dto) {
        // 优先从主DTO解析订单ID,不存在时再从子项中回退查找。
        if (dto.getProductionOrderId() != null) {
            return dto.getProductionOrderId();
        }
@@ -588,7 +822,12 @@
    }
    private ProductionOrderPickDto mergeDto(ProductionOrderPickDto rootDto, ProductionOrderPickDto itemDto) {
        // 合并规则:
        // - itemDto 优先承载行级输入;
        // - itemDto 缺失字段从 rootDto 兜底继承;
        // - 输出 merged 作为统一业务入参。
        ProductionOrderPickDto merged = new ProductionOrderPickDto();
        // 先拷贝行级字段。
        if (itemDto != null) {
            merged.setId(itemDto.getId());
            merged.setProductionOrderId(itemDto.getProductionOrderId());
@@ -667,51 +906,52 @@
    }
    private void validatePickParam(ProductionOrderPickDto dto, int rowNo) {
        // 校验普通领料参数(订单、规格、数量、类型)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "条生产订单ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getProductModelId() == null) {
            throw new ServiceException("第" + rowNo + "条产品规格ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:产品规格不能为空");
        }
        if (dto.getPickQuantity() == null || dto.getPickQuantity().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条领料数量不能小于0");
            throw new ServiceException("第" + rowNo + "行参数错误:领料数量不能小于0");
        }
        if (dto.getPickType() != null && dto.getPickType() != PICK_TYPE_NORMAL && dto.getPickType() != PICK_TYPE_FEEDING) {
            throw new ServiceException("第" + rowNo + "条领料类型只能是1或2");
            throw new ServiceException("第" + rowNo + "行参数错误:领料类型仅支持1(领料)或2(补料)");
        }
    }
    private void validateFeedingParam(ProductionOrderPickDto dto, int rowNo) {
        // 校验补料参数(订单、领料ID、补料数量、类型)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "条生产订单ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getId() == null) {
            throw new ServiceException("第" + rowNo + "条领料ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:领料记录ID不能为空");
        }
        if (dto.getFeedingQuantity() == null || dto.getFeedingQuantity().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条本次补料数量不能小于0");
            throw new ServiceException("第" + rowNo + "行参数错误:补料数量不能小于0");
        }
        if (!isFeedingPick(dto)) {
            throw new ServiceException("第" + rowNo + "条补料类型必须为2");
            throw new ServiceException("第" + rowNo + "行参数错误:补料场景下领料类型必须为2");
        }
    }
    private void validateReturnParam(ProductionOrderPickDto dto, int rowNo) {
        // 校验退料参数(订单、领料ID、退料量)。
        if (dto.getProductionOrderId() == null) {
            throw new ServiceException("第" + rowNo + "条生产订单ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:生产订单ID不能为空");
        }
        if (dto.getId() == null) {
            throw new ServiceException("第" + rowNo + "条领料ID不能为空");
            throw new ServiceException("第" + rowNo + "行参数错误:领料记录ID不能为空");
        }
        if (dto.getReturnQty() == null || dto.getReturnQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条退料数量不能为空且不能小于0");
        }
        if (dto.getActualQty() == null || dto.getActualQty().compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("第" + rowNo + "条实际数量不能为空且不能小于0");
            throw new ServiceException("第" + rowNo + "行参数错误:退料数量不能小于0");
        }
    }
    private boolean isFeedingRequest(ProductionOrderPickDto dto) {
        // 判断当前请求是否属于补料流程。
        if (isFeedingPick(dto)) {
            return true;
        }
@@ -724,10 +964,12 @@
    }
    private boolean isFeedingPick(ProductionOrderPickDto dto) {
        // 判断当前行是否为补料类型。
        return dto != null && Objects.equals(dto.getPickType(), PICK_TYPE_FEEDING);
    }
    private boolean isReturnRequest(ProductionOrderPickDto dto) {
        // 判断当前请求是否属于退料流程。
        if (isReturnPick(dto)) {
            return true;
        }
@@ -740,10 +982,12 @@
    }
    private boolean isReturnPick(ProductionOrderPickDto dto) {
        // 判断当前行是否为退料类型。
        return dto != null && Boolean.TRUE.equals(dto.getReturned());
    }
    private BigDecimal sumFeedingQuantity(Long productionOrderId, Long pickId) {
        // 汇总指定领料单的历史补料总量。
        List<ProductionOrderPickRecord> feedingRecords = productionOrderPickRecordMapper.selectList(
                Wrappers.<ProductionOrderPickRecord>lambdaQuery()
                        .eq(ProductionOrderPickRecord::getProductionOrderId, productionOrderId)
@@ -756,12 +1000,14 @@
    }
    private BigDecimal calculateActualQty(ProductionOrderPick pick, BigDecimal feedingQty) {
        // 按“领料+补料-退料”计算实际用量。
        return defaultDecimal(pick.getQuantity())
                .add(defaultDecimal(feedingQty))
                .subtract(defaultDecimal(pick.getReturnQty()));
    }
    private String normalizeBatchNo(String batchNo) {
        // 标准化批次号(去空白、空串转null)。
        if (StringUtils.isEmpty(batchNo)) {
            return null;
        }
@@ -769,6 +1015,7 @@
        return trimBatchNo.isEmpty() ? null : trimBatchNo;
    }
    private List<String> resolveBatchNoList(ProductionOrderPickDto dto) {
        // 优先解析 batchNoList,空则回退解析 batchNo 字符串。
        List<String> normalizedBatchNoList = normalizeBatchNoList(dto.getBatchNoList());
        if (!normalizedBatchNoList.isEmpty()) {
            return normalizedBatchNoList;
@@ -777,6 +1024,7 @@
    }
    private String pickInventoryBatchNo(List<String> batchNoList) {
        // 从批次集合中取库存扣减使用的批次。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return null;
        }
@@ -784,10 +1032,12 @@
    }
    private String resolveInventoryBatchNoFromStored(String storedBatchNo) {
        // 从数据库存储批次字段中反解可用批次。
        return pickInventoryBatchNo(parseBatchNoValue(storedBatchNo));
    }
    private String formatBatchNoStorage(List<String> batchNoList) {
        // 将批次集合格式化为数据库存储值。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return null;
        }
@@ -798,6 +1048,7 @@
    }
    private List<String> normalizeBatchNoList(List<String> batchNoList) {
        // 批量标准化批次号并去重。
        if (batchNoList == null || batchNoList.isEmpty()) {
            return Collections.emptyList();
        }
@@ -812,6 +1063,7 @@
    }
    private void fillBatchNoList(List<ProductionOrderPickVo> detailList) {
        // 将同订单+同规格+同工序的数据按组聚合批次,便于前端统一展示。
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
@@ -832,9 +1084,11 @@
    }
    private void fillSelectableBatchNoList(List<ProductionOrderPickVo> detailList) {
        // 合并“已选批次”和“库存可选批次”,用于前端下拉。
        if (detailList == null || detailList.isEmpty()) {
            return;
        }
        // 先收集明细中涉及的规格ID,批量查询库存批次。
        Set<Long> productModelIdSet = detailList.stream()
                .map(ProductionOrderPickVo::getProductModelId)
                .filter(Objects::nonNull)
@@ -868,30 +1122,35 @@
    }
    private String buildBatchNoGroupKey(ProductionOrderPickVo detail) {
        return String.valueOf(detail.getProductionOrderId()) + "|"
                + String.valueOf(detail.getProductModelId()) + "|"
                + String.valueOf(detail.getTechnologyOperationId()) + "|"
                + String.valueOf(detail.getOperationName());
        // 构建批次聚合分组键。
        return detail.getProductionOrderId() + "|"
                + detail.getProductModelId() + "|"
                + detail.getTechnologyOperationId() + "|"
                + detail.getOperationName();
    }
    private List<String> parseBatchNoValue(String rawBatchNoValue) {
        // 批次解析兼容三种格式:
        // 1) 单值:A001
        // 2) 逗号分隔:A001,A002
        // 3) 类JSON数组字符串:["A001","A002"]
        String normalizedValue = normalizeBatchNo(rawBatchNoValue);
        if (StringUtils.isEmpty(normalizedValue)) {
            return Collections.emptyList();
        }
        if (normalizedValue.startsWith("[") && normalizedValue.endsWith("]")) {
        if (normalizedValue != null && normalizedValue.startsWith("[") && normalizedValue.endsWith("]")) {
            String value = normalizedValue.substring(1, normalizedValue.length() - 1);
            if (StringUtils.isEmpty(value)) {
                return Collections.emptyList();
            }
            List<String> parsed = Arrays.stream(value.split(","))
                    .map(item -> item == null ? null : item.trim().replace("\"", "").replace("'", ""))
                    .map(item -> item.trim().replace("\"", "").replace("'", ""))
                    .collect(Collectors.toList());
            return normalizeBatchNoList(parsed);
        }
        if (normalizedValue.contains(",")) {
        if (normalizedValue != null && normalizedValue.contains(",")) {
            List<String> parsed = Arrays.stream(normalizedValue.split(","))
                    .map(item -> item == null ? null : item.trim())
                    .map(item -> item.trim())
                    .collect(Collectors.toList());
            return normalizeBatchNoList(parsed);
        }
@@ -899,6 +1158,7 @@
    }
    private LambdaQueryWrapper<StockInventory> buildStockWrapper(Long productModelId, String batchNo) {
        // 构建库存查询条件(规格 + 批次)。
        LambdaQueryWrapper<StockInventory> wrapper = Wrappers.<StockInventory>lambdaQuery()
                .eq(StockInventory::getProductModelId, productModelId);
        if (StringUtils.isEmpty(batchNo)) {
@@ -910,10 +1170,12 @@
    }
    private BigDecimal defaultDecimal(BigDecimal value) {
        // BigDecimal 空值兜底,统一按0处理。
        return value == null ? BigDecimal.ZERO : value;
    }
    private String formatQuantity(BigDecimal value) {
        // 数量格式化输出(去除末尾无效0)。
        return defaultDecimal(value).stripTrailingZeros().toPlainString();
    }
}