云
2 天以前 704265308b1227dbada5a36b2027b588c062ded2
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -52,7 +52,13 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstockbag.MesWmMaterialStockBagMapper;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstockbag.MesWmMaterialStockBagDO;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@@ -103,6 +109,10 @@
    @Resource
    private MesTraceIntegrityService integrityService;
    @Resource private MesProBagCodeMapper pickingBagMapper;
    @Resource private MesProPalletMapper pickingPalletMapper;
    @Resource private MesProBatchMapper pickingBatchMapper;
    @Resource private MesWmMaterialStockBagMapper pickingStockBagMapper;
    @Override
    public Long createProductSales(MesWmProductSalesSaveReqVO createReqVO) {
@@ -222,83 +232,150 @@
        return true;
    }
    private record PickingScope(String type, Long id, MesProBatchDO batch, List<MesProBagCodeDO> bags) { }
    private PickingScope pickingScope(String code, String type, boolean lock) {
        if (type == null || type.isBlank()) {
            type = palletService.getPalletByScanCode(code) != null ? "PALLET"
                    : bagCodeService.getBagCodeByScanCode(code) != null ? "BAG" : "BATCH";
        }
        MesProBatchDO batch;
        List<MesProBagCodeDO> bags;
        Long id;
        switch (type) {
            case "BAG" -> {
                MesProBagCodeDO bag = lock ? pickingBagMapper.selectByScanCodeForUpdate(code) : bagCodeService.getBagCodeByScanCode(code);
                if (bag == null || Integer.valueOf(30).equals(bag.getStatus())) throw new IllegalArgumentException("袋码不存在或已作废");
                id = bag.getId(); batch = proBatchService.getBatch(bag.getBatchId()); bags = List.of(bag);
            }
            case "PALLET" -> {
                MesProPalletDO pallet = lock ? pickingPalletMapper.selectByScanCodeForUpdate(code) : palletService.getPalletByScanCode(code);
                if (pallet == null || Integer.valueOf(20).equals(pallet.getStatus())) throw new IllegalArgumentException("托盘不存在或已作废");
                if (pallet.getBatchId() == null) throw exception(PRO_PALLET_NOT_BIND_BATCH);
                id = pallet.getId(); batch = proBatchService.getBatch(pallet.getBatchId());
                bags = lock ? pickingBagMapper.selectListByPalletIdForUpdate(id) : bagCodeService.getBagCodeListByPalletId(id);
            }
            case "BATCH" -> {
                batch = lock ? pickingBatchMapper.selectByScanCodeForUpdate(code) : proBatchService.getBatchByScanCode(code);
                if (batch == null) throw new IllegalArgumentException("生产批次码不存在");
                id = batch.getId(); bags = lock ? pickingBagMapper.selectListByBatchIdForUpdate(id) : bagCodeService.getBagCodeListByBatchId(id);
            }
            default -> throw new IllegalArgumentException("不支持的拣货扫码类型");
        }
        if (batch == null || batch.getItemId() == null || Integer.valueOf(40).equals(batch.getStatus()))
            throw new IllegalArgumentException("未绑定有效品种或生产批次已作废");
        return new PickingScope(type, id, batch, bags.stream().filter(b -> !Integer.valueOf(30).equals(b.getStatus()))
                .sorted(Comparator.comparing(MesProBagCodeDO::getId)).toList());
    }
    private String unavailable(MesProBagCodeDO bag, MesWmMaterialStockBagDO stockBag,
            Map<Long, MesWmMaterialStockDO> stocks, List<MesWmProductSalesDetailDO> occupied, MesProBatchDO batch) {
        if (!Objects.equals(bag.getBatchId(), batch.getId())) return "袋码批次归属异常";
        if (bag.getItemId() != null && !Objects.equals(bag.getItemId(), batch.getItemId())) return "袋码品种归属异常";
        if (!MesProBagCodeQcStatusEnum.QUALIFIED.getStatus().equals(bag.getQcStatus())) return "尚未质检合格";
        if (stockBag == null || !Integer.valueOf(1).equals(stockBag.getStatus())) return "不在库或已出库";
        if (Boolean.TRUE.equals(stockBag.getFrozen())) return "袋码已冻结";
        if (!Integer.valueOf(20).equals(stockBag.getStockState()) || !Objects.equals(stockBag.getItemId(), batch.getItemId())
                || !stocks.containsKey(stockBag.getMaterialStockId()) || stockBag.getQuantity() == null || BigDecimal.ONE.compareTo(stockBag.getQuantity()) != 0)
            return "袋码不在有效合格库存中";
        if (occupied.stream().anyMatch(d -> Objects.equals(d.getBagCodeId(), bag.getId())
                || d.getBagCodeId() == null && bag.getPalletId() != null && Objects.equals(d.getPalletCodeId(), bag.getPalletId())))
            return "已加入出库单拣货";
        return null;
    }
    @Override
    public MesWmProductSalesScanRespVO previewScan(String code) { return previewScan(code, null, null); }
    /** 只读识别,并返回每袋的可选状态,不创建库存批次或拣货记录。 */
    @Override
    public MesWmProductSalesScanRespVO previewScan(String code, String scanType, Long salesId) {
        PickingScope scope = pickingScope(code, scanType, false);
        if (salesId != null) {
            MesWmProductSalesDO sales = validateProductSalesExists(salesId);
            if (!Integer.valueOf(2).equals(sales.getStatus())) throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
            if (productSalesLineService.getProductSalesLineListBySalesId(salesId).stream()
                    .noneMatch(l -> Objects.equals(l.getItemId(), scope.batch().getItemId()))) throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        }
        MesWmBatchDO wmBatch = mesWmBatchService.getBatchByCode(scope.batch().getCode());
        Map<Long, MesWmMaterialStockDO> stocks = wmBatch == null ? Map.of() : materialStockService
                .getQualifiedMaterialStockListByItemAndBatch(scope.batch().getItemId(), wmBatch.getId()).stream()
                .collect(Collectors.toMap(MesWmMaterialStockDO::getId, x -> x));
        Map<Long, MesWmMaterialStockBagDO> stockBags = pickingStockBagMapper.selectByBagIds(scope.bags().stream().map(MesProBagCodeDO::getId).toList(), false)
                .stream().collect(Collectors.toMap(MesWmMaterialStockBagDO::getBagCodeId, x -> x));
        List<MesWmProductSalesDetailDO> occupied = productSalesDetailMapper.selectPickingByBatchCode(scope.batch().getCode());
        List<MesWmProductSalesScanRespVO.BagOption> options = scope.bags().stream().map(b -> {
            String reason = unavailable(b, stockBags.get(b.getId()), stocks, occupied, scope.batch());
            return new MesWmProductSalesScanRespVO.BagOption().setId(b.getId()).setCode(b.getCode()).setBusinessCode(b.getBusinessCode())
                    .setPalletId(b.getPalletId()).setSelectable(reason == null).setUnavailableReason(reason);
        }).toList();
        return new MesWmProductSalesScanRespVO().setScanType(scope.type()).setCodeId(scope.id()).setScanContent(code)
                .setBatchCode(scope.batch().getCode()).setItemId(scope.batch().getItemId()).setBagCount(options.size()).setBags(options)
                .setScanQuantity(BigDecimal.valueOf(options.stream().filter(o -> Boolean.TRUE.equals(o.getSelectable())).count()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesWmProductSalesScanRespVO scanProductSales(MesWmProductSalesScanReqVO reqVO) {
        MesWmProductSalesDO sales = validateProductSalesExists(reqVO.getSalesId());
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.APPROVING.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(reqVO.getSalesId());
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!Integer.valueOf(2).equals(sales.getStatus())) throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        PickingScope scope = pickingScope(reqVO.getScanContent(), reqVO.getScanType(), true);
        List<Long> ids = reqVO.getBagIds();
        if (ids == null && "BAG".equals(scope.type())) ids = List.of(scope.id());
        if (ids == null || ids.isEmpty() || new HashSet<>(ids).size() != ids.size())
            throw new IllegalArgumentException("请选择具体袋码,不允许重复袋码");
        BigDecimal quantity = BigDecimal.valueOf(ids.size());
        if (reqVO.getQuantity() != null && quantity.compareTo(reqVO.getQuantity()) != 0)
            throw new IllegalArgumentException("拣货数量必须与所选袋码数量一致");
        Set<Long> selectedIds = new HashSet<>(ids);
        List<MesProBagCodeDO> selected = scope.bags().stream().filter(b -> selectedIds.contains(b.getId())).toList();
        if (selected.size() != ids.size()) throw new IllegalArgumentException("所选袋码不属于当前扫码对象或已作废,请重新核对");
        // 锁定实际袋码和库存袋明细,跨出库单的扫码也不能重复拣选同一袋。
        List<MesWmMaterialStockBagDO> stockBagRows = pickingStockBagMapper.selectByBagIds(ids, true);
        Map<Long, MesWmMaterialStockBagDO> stockBags = stockBagRows.stream().collect(Collectors.toMap(MesWmMaterialStockBagDO::getBagCodeId, x -> x));
        List<MesWmProductSalesDetailDO> occupied = productSalesDetailMapper.selectPickingByBatchCodeForUpdate(scope.batch().getCode());
        List<MesWmProductSalesDetailDO> own = occupied.stream().filter(d -> Objects.equals(d.getSalesId(), sales.getId())).toList();
        long already = selected.stream().filter(b -> own.stream().anyMatch(d -> Objects.equals(d.getBagCodeId(), b.getId()))).count();
        if (already == selected.size()) return new MesWmProductSalesScanRespVO().setScanType(scope.type()).setCodeId(scope.id())
                .setScanContent(reqVO.getScanContent()).setSelectedBagIds(ids).setAlreadyScanned(true).setOutboundQuantity(quantity)
                .setDetailId(own.stream().filter(d -> selectedIds.contains(d.getBagCodeId())).findFirst().orElseThrow().getId()).setMessage("所选袋码已在本单拣货明细中");
        if (already > 0) throw new IllegalArgumentException("所选袋码部分已加入本单,请重新选择未拣袋码");
        MesWmBatchDO wmBatch = mesWmBatchService.getBatchByCode(scope.batch().getCode());
        if (wmBatch == null) throw exception(WM_TRANSACTION_BATCH_NOT_EXISTS);
        Map<Long, MesWmMaterialStockDO> stocks = materialStockService.getQualifiedMaterialStockListByItemAndBatch(scope.batch().getItemId(), wmBatch.getId())
                .stream().collect(Collectors.toMap(MesWmMaterialStockDO::getId, x -> x));
        for (MesProBagCodeDO bag : selected) {
            String reason = unavailable(bag, stockBags.get(bag.getId()), stocks, occupied, scope.batch());
            if (reason != null) throw new IllegalArgumentException("袋码 " + bag.getCode() + ":" + reason);
        }
        MesProPalletDO pallet = palletService.getPalletByCode(reqVO.getScanContent());
        MesProBagCodeDO bag = pallet == null ? bagCodeService.getBagCodeByCode(reqVO.getScanContent()) : null;
        if (pallet == null && bag == null) {
            throw exception(WM_MATERIAL_STOCK_NOT_EXISTS);
        }
        // 未绑定生产批次的临时托盘不允许参与销售出库
        if (pallet != null && pallet.getBatchId() == null) {
            throw exception(PRO_PALLET_NOT_BIND_BATCH);
        }
        // 种子质检按袋码粒度流转后,出库只允许已质检合格的袋码/托盘
        if (pallet != null) {
            List<MesProBagCodeDO> palletBags = bagCodeService.getBagCodeListByPalletId(pallet.getId());
            if (palletBags.stream().anyMatch(b -> !MesProBagCodeQcStatusEnum.QUALIFIED.getStatus().equals(b.getQcStatus()))) {
                throw exception(WM_PRODUCT_SALES_SCAN_PALLET_NOT_ALL_QUALIFIED);
            }
        } else if (!MesProBagCodeQcStatusEnum.QUALIFIED.getStatus().equals(bag.getQcStatus())) {
            throw exception(WM_PRODUCT_SALES_SCAN_BAG_NOT_QUALIFIED);
        }
        Long codeId = pallet != null ? pallet.getId() : bag.getId();
        MesWmProductSalesDetailDO existed = pallet != null
                ? productSalesDetailMapper.selectBySalesIdAndPalletCodeId(reqVO.getSalesId(), codeId)
                : productSalesDetailMapper.selectBySalesIdAndBagCodeId(reqVO.getSalesId(), codeId);
        if (existed != null) {
            return new MesWmProductSalesScanRespVO().setScanType(pallet != null ? "PALLET" : "BAG")
                    .setCodeId(codeId).setScanContent(reqVO.getScanContent()).setAlreadyScanned(true)
                    .setDetailId(existed.getId()).setOutboundQuantity(existed.getQuantity()).setMessage("该码已扫描");
        }
        Long proBatchId = pallet != null ? pallet.getBatchId() : bag.getBatchId();
        MesProBatchDO batch = proBatchService.getBatch(proBatchId);
        if (batch == null) {
            throw exception(WM_TRANSACTION_BATCH_NOT_EXISTS);
        }
        // 生产批次桥接仓库批次:扫码入库写入的是仓库批次(mes_wm_batch),此处必须用仓库批次 id 查库存
        MesWmBatchDO wmBatch = mesWmBatchService.getOrCreateByProBatch(batch);
        if (wmBatch == null) {
            throw exception(WM_TRANSACTION_BATCH_NOT_EXISTS);
        }
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(reqVO.getSalesId());
        MesWmProductSalesLineDO line = lines.stream().filter(item -> ObjUtil.equal(item.getItemId(), batch.getItemId())).findFirst().orElse(null);
        if (line == null) {
            throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        }
        BigDecimal quantity = reqVO.getQuantity();
        if (quantity == null) {
            quantity = line.getQuantity();
        }
        // 只从合格库记录中选库存(同批次暂存库记录不可出库)
        List<MesWmMaterialStockDO> stocks = materialStockService.getQualifiedMaterialStockListByItemAndBatch(batch.getItemId(), wmBatch.getId());
        BigDecimal outboundQuantity = quantity;
        MesWmMaterialStockDO stock = stocks.stream().filter(item -> item.getQuantity().compareTo(outboundQuantity) >= 0).findFirst().orElse(null);
        if (stock == null) {
            throw exception(WM_MATERIAL_STOCK_INSUFFICIENT);
        }
        MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLineListBySalesId(sales.getId()).stream()
                .filter(l -> Objects.equals(l.getItemId(), scope.batch().getItemId())).findFirst().orElse(null);
        if (line == null) throw exception(WM_PRODUCT_SALES_DETAIL_ITEM_MISMATCH);
        BigDecimal scanned = productSalesDetailService.getProductSalesDetailListByLineId(line.getId()).stream()
                .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add);
        if (scanned.add(quantity).compareTo(line.getQuantity()) > 0) {
            throw exception(WM_PRODUCT_SALES_LINE_QUANTITY_INVALID);
        if (line.getQuantity() == null || scanned.add(quantity).compareTo(line.getQuantity()) > 0) throw exception(WM_PRODUCT_SALES_LINE_QUANTITY_INVALID);
        Map<Long, Long> byStock = selected.stream().collect(Collectors.groupingBy(b -> stockBags.get(b.getId()).getMaterialStockId(), Collectors.counting()));
        for (var allocation : byStock.entrySet()) {
            BigDecimal ownStockQty = own.stream().filter(d -> Objects.equals(d.getMaterialStockId(), allocation.getKey()))
                    .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add);
            if (stocks.get(allocation.getKey()).getQuantity().compareTo(ownStockQty.add(BigDecimal.valueOf(allocation.getValue()))) < 0)
                throw exception(WM_MATERIAL_STOCK_INSUFFICIENT);
        }
        MesWmProductSalesDetailDO detail = new MesWmProductSalesDetailDO().setLineId(line.getId())
                .setSalesId(reqVO.getSalesId()).setItemId(batch.getItemId()).setQuantity(quantity)
                .setMaterialStockId(stock.getId()).setBatchId(wmBatch.getId()).setBatchCode(wmBatch.getCode())
        Long firstDetail = null;
        for (MesProBagCodeDO bag : selected) {
            MesWmMaterialStockDO stock = stocks.get(stockBags.get(bag.getId()).getMaterialStockId());
            MesWmProductSalesDetailDO detail = new MesWmProductSalesDetailDO().setSalesId(sales.getId()).setLineId(line.getId())
                    .setItemId(scope.batch().getItemId()).setQuantity(BigDecimal.ONE).setBagCodeId(bag.getId())
                    .setPalletCodeId(bag.getPalletId()).setBatchId(wmBatch.getId()).setBatchCode(wmBatch.getCode()).setMaterialStockId(stock.getId())
                .setWarehouseId(stock.getWarehouseId()).setLocationId(stock.getLocationId()).setAreaId(stock.getAreaId())
                .setBagCodeId(pallet == null ? codeId : null).setPalletCodeId(pallet != null ? codeId : null);
                    .setRemark("扫码类型 " + scope.type() + ";来源码 " + reqVO.getScanContent() + ";袋码 " + bag.getCode());
        productSalesDetailMapper.insert(detail);
        return new MesWmProductSalesScanRespVO().setScanType(pallet != null ? "PALLET" : "BAG")
                .setCodeId(codeId).setScanContent(reqVO.getScanContent()).setItemId(batch.getItemId())
                .setBatchId(wmBatch.getId()).setBatchCode(wmBatch.getCode()).setMaterialStockId(stock.getId())
                .setAvailableQuantity(stock.getQuantity()).setScanQuantity(quantity).setOutboundQuantity(quantity)
                .setAlreadyScanned(false).setDetailId(detail.getId()).setWarehouseId(stock.getWarehouseId())
                .setLocationId(stock.getLocationId()).setAreaId(stock.getAreaId()).setMessage("扫码成功");
            if (firstDetail == null) firstDetail = detail.getId();
        }
        return new MesWmProductSalesScanRespVO().setScanType(scope.type()).setCodeId(scope.id()).setScanContent(reqVO.getScanContent())
                .setItemId(scope.batch().getItemId()).setBatchCode(wmBatch.getCode()).setSelectedBagIds(ids)
                .setScanQuantity(quantity).setOutboundQuantity(quantity).setAlreadyScanned(false).setDetailId(firstDetail).setMessage("已按所选袋码加入拣货");
    }
    @Override
@@ -398,7 +475,8 @@
            if (detail.getBagCodeId() != null) {
                bagCodeService.markOutbound(detail.getBagCodeId(), id);
            }
            if (detail.getPalletCodeId() != null) {
            // 部分托盘拣货可能分给多个经销商,不能把整个托盘标记为本单出库。
            if (detail.getPalletCodeId() != null && detail.getBagCodeId() == null) {
                palletService.markOutbound(detail.getPalletCodeId(), id);
            }
        }
@@ -428,7 +506,7 @@
                .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(detail.getItemId())
                .setQuantity(detail.getQuantity().negate()) // 出库数量为负数
                .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                .setBagCodeId(detail.getBagCodeId()) // 袋码型明细出库时置袋码已出库;托盘型明细不逐袋处理
                .setBagCodeId(detail.getBagCodeId()) // 新扫码明细逐袋关联,执行出库逐袋更新库存状态
                .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
                .setBizType(MesBizTypeConstants.WM_PRODUCT_SALES).setBizId(sales.getId())
                .setBizCode(sales.getCode()).setBizLineId(detail.getLineId())