云
4 天以前 87733eb3623593da247c051593d64b8ff9e1d3a4
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
@@ -71,6 +71,7 @@
@Validated
@Slf4j
public class MesWmProductSalesServiceImpl implements MesWmProductSalesService {
    @Resource private cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesShipmentMapper shipmentMapper;
    @Resource
    private MesWmProductSalesMapper productSalesMapper;
@@ -181,20 +182,26 @@
    @Override
    public boolean checkProductSalesQuantity(Long id) {
        // 校验每行明细数量之和是否等于行出库数量
        // 本次至少拣一条,累计拣货不超过计划;允许跨行、分次发货。
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(id);
        boolean hasPending = false;
        for (MesWmProductSalesLineDO line : lines) {
            List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListByLineId(line.getId());
            if (CollUtil.isEmpty(details)) {
                return false;
            }
            if (details.stream().anyMatch(d -> d.getShipmentId() == null)) hasPending = true;
            if (details.stream().anyMatch(d -> d.getQuantity() == null || d.getQuantity().signum() <= 0)) return false;
            BigDecimal totalDetailQty = CollectionUtils.getSumValue(details,
                    MesWmProductSalesDetailDO::getQuantity, BigDecimal::add, BigDecimal.ZERO);
            if (line.getQuantity() != null && totalDetailQty.compareTo(line.getQuantity()) != 0) {
            if (line.getQuantity() == null || totalDetailQty.compareTo(line.getQuantity()) > 0) {
                return false;
            }
        }
        return true;
        return hasPending;
    }
    @Override
    public List<cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO> getShipments(Long id) {
        validateProductSalesExists(id);
        return shipmentMapper.selectBySalesId(id);
    }
    private record PickingScope(String type, Long id, MesProBatchDO batch, List<MesProBagCodeDO> bags) { }
@@ -272,7 +279,8 @@
        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);
                    .setPalletId(b.getPalletId()).setMaterialStockId(stockBags.get(b.getId()) == null ? null : stockBags.get(b.getId()).getMaterialStockId())
                    .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)
@@ -282,6 +290,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesWmProductSalesScanRespVO scanProductSales(MesWmProductSalesScanReqVO reqVO) {
        if (Boolean.TRUE.equals(reqVO.getManual()) && (reqVO.getLineId() == null || reqVO.getMaterialStockId() == null))
            throw new IllegalArgumentException("手工选择袋码必须指定出库单行和库存记录");
        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);
@@ -302,6 +312,10 @@
        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 > 0 && own.stream().anyMatch(d -> selectedIds.contains(d.getBagCodeId())
                && (reqVO.getLineId() != null && !Objects.equals(reqVO.getLineId(), d.getLineId())
                    || reqVO.getMaterialStockId() != null && !Objects.equals(reqVO.getMaterialStockId(), d.getMaterialStockId()))))
            throw new IllegalArgumentException("所选袋码已加入本单其他行或库存记录");
        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("所选袋码已在本单拣货明细中");
@@ -313,9 +327,17 @@
        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);
            if (reqVO.getMaterialStockId() != null && !Objects.equals(reqVO.getMaterialStockId(), stockBags.get(bag.getId()).getMaterialStockId()))
                throw new IllegalArgumentException("所选袋码不属于指定库存记录");
        }
        MesWmProductSalesLineDO line = productSalesLineService.getProductSalesLineListBySalesId(sales.getId()).stream()
                .filter(l -> Objects.equals(l.getItemId(), scope.batch().getItemId())).findFirst().orElse(null);
                .filter(l -> Objects.equals(l.getItemId(), scope.batch().getItemId())
                        && (reqVO.getLineId() == null || Objects.equals(l.getId(), reqVO.getLineId()))
                        && (l.getBatchCode() == null || l.getBatchCode().isBlank() || Objects.equals(l.getBatchCode(), wmBatch.getCode())))
                .filter(l -> l.getQuantity() != null && productSalesDetailService.getProductSalesDetailListByLineId(l.getId()).stream()
                        .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add)
                        .add(quantity).compareTo(l.getQuantity()) <= 0)
                .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);
@@ -334,7 +356,11 @@
                    .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())
                    .setRemark("扫码类型 " + scope.type() + ";来源码 " + reqVO.getScanContent() + ";袋码 " + bag.getCode());
                    .setRemark((Boolean.TRUE.equals(reqVO.getManual()) ? "手工选择;" : "") + "扫码类型 " + scope.type() + ";" + switch (scope.type()) {
                        case "BATCH" -> "来源批次码 ";
                        case "PALLET" -> "来源托盘码 ";
                        default -> "来源袋码 ";
                    } + reqVO.getScanContent() + ";袋码 " + bag.getCode());
            productSalesDetailMapper.insert(detail);
            if (firstDetail == null) firstDetail = detail.getId();
        }
@@ -347,19 +373,21 @@
    @Transactional(rollbackFor = Exception.class)
    public void stockProductSales(Long id) {
        // 校验存在
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.APPROVING.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_PICK);
        }
        if (!checkProductSalesQuantity(id)) throw new IllegalArgumentException("请拣选本次发货明细,且累计数量不能超过计划数量");
        var shipment = new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO();
        shipment.setSalesId(id); shipment.setStatus(10); shipmentMapper.insert(shipment);
        // 校验每行至少有拣货明细,并校验库存充足
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(id);
        for (MesWmProductSalesLineDO line : lines) {
            List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListByLineId(line.getId());
            if (CollUtil.isEmpty(details)) {
                throw exception(WM_PRODUCT_SALES_DETAILS_EMPTY);
            }
            // 校验库存充足并创建占用
            for (MesWmProductSalesDetailDO detail : details) {
                if (detail.getShipmentId() != null) continue;
                materialStockService.validateSelectedStock(
                        detail.getMaterialStockId(),
                        detail.getItemId(),
@@ -380,6 +408,7 @@
                        detail.getItemId(),
                        detail.getQuantity()
                );
                productSalesDetailMapper.updateById(new MesWmProductSalesDetailDO().setId(detail.getId()).setShipmentId(shipment.getId()));
            }
        }
@@ -392,10 +421,15 @@
    @Transactional(rollbackFor = Exception.class)
    public void shippingProductSales(MesWmProductSalesShippingReqVO reqVO) {
        // 校验存在
        MesWmProductSalesDO sales = validateProductSalesExists(reqVO.getId());
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(reqVO.getId());
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.SHIPPING.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_SHIPPING);
        }
        var shipment = ensureActiveShipment(sales, 10);
        shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(20).setCarrier(reqVO.getCarrier()).setShippingNumber(reqVO.getShippingNumber()));
        // 填写运单(待填写运单 → 待执行出库)
        productSalesMapper.updateById(new MesWmProductSalesDO()
@@ -409,16 +443,31 @@
    @Transactional(rollbackFor = Exception.class)
    public void finishProductSales(Long id) {
        // 1. 校验存在
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (ObjUtil.notEqual(MesWmProductSalesStatusEnum.APPROVED.getStatus(), sales.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_FINISH);
        }
        var shipment = ensureActiveShipment(sales, 20);
        List<MesWmProductSalesDetailDO> outDetails = productSalesDetailService.getProductSalesDetailListBySalesId(id).stream()
                .filter(d -> Objects.equals(d.getShipmentId(), shipment.getId())).toList();
        if (outDetails.isEmpty()) throw new IllegalArgumentException("本次发货没有出库明细");
        // 2. 确认占用转出库(释放占用量)
        stockReserveService.confirmReserveToOutbound(MesWmStockReserveBizTypeEnum.PRODUCT_SALES.getType(), id);
        // 3. 遍历所有明细,创建库存事务(扣减库存 + 记录流水)
        createTransactionList(sales);
        createTransactionList(sales, outDetails);
        shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(30).setOutboundTime(java.time.LocalDateTime.now())
                .setOutboundUserId(SecurityFrameworkUtils.getLoginUserId()));
        boolean complete = productSalesLineService.getProductSalesLineListBySalesId(id).stream().allMatch(line -> {
            BigDecimal total = productSalesDetailService.getProductSalesDetailListByLineId(line.getId()).stream()
                    .filter(d -> d.getShipmentId() != null).map(MesWmProductSalesDetailDO::getQuantity)
                    .reduce(BigDecimal.ZERO, BigDecimal::add);
            return line.getQuantity() != null && total.compareTo(line.getQuantity()) == 0;
        });
        // 4. 固化经销商与实际出库操作信息
        CrmCustomerRespDTO customer = customerApi.getCustomer(sales.getClientId());
@@ -427,15 +476,14 @@
                .setClientName(customer != null ? customer.getName() : null)
                .setOutboundTime(java.time.LocalDateTime.now())
                .setOutboundUserId(SecurityFrameworkUtils.getLoginUserId())
                .setStatus(MesWmProductSalesStatusEnum.FINISHED.getStatus()));
                .setStatus(complete ? MesWmProductSalesStatusEnum.FINISHED.getStatus() : MesWmProductSalesStatusEnum.APPROVING.getStatus()));
        // 5. 更新关联的发货通知单出库数量和状态
        if (sales.getNoticeId() != null) {
            updateSalesNoticeOutCount(sales);
            updateSalesNoticeOutCount(sales, outDetails);
        }
        // 6. 回写袋码/托盘出库去向(供窜货稽查定位经销商地区),并生成出库完整性摘要
        List<MesWmProductSalesDetailDO> outDetails = productSalesDetailService.getProductSalesDetailListBySalesId(id);
        for (MesWmProductSalesDetailDO detail : outDetails) {
            if (detail.getBagCodeId() != null) {
                bagCodeService.markOutbound(detail.getBagCodeId(), id);
@@ -445,13 +493,13 @@
                palletService.markOutbound(detail.getPalletCodeId(), id);
            }
        }
        integrityService.recordSalesIntegrity(id);
        if (complete) integrityService.recordSalesIntegrity(id);
    }
    /**
     * 更新发货通知单的出库数量和状态
     */
    private void updateSalesNoticeOutCount(MesWmProductSalesDO sales) {
    private void updateSalesNoticeOutCount(MesWmProductSalesDO sales, List<MesWmProductSalesDetailDO> outDetails) {
        // 获取出库单行
        List<MesWmProductSalesLineDO> lines = productSalesLineService.getProductSalesLineListBySalesId(sales.getId());
        if (CollUtil.isEmpty(lines)) {
@@ -459,44 +507,109 @@
        }
        // 遍历行,按 noticeLineId 更新出库数量
        for (MesWmProductSalesLineDO line : lines) {
            if (line.getNoticeLineId() != null && line.getQuantity() != null) {
                salesNoticeService.updateSalesNoticeLineOutCount(line.getNoticeLineId(), line.getQuantity());
            BigDecimal quantity = outDetails.stream().filter(d -> Objects.equals(d.getLineId(), line.getId()))
                    .map(MesWmProductSalesDetailDO::getQuantity).reduce(BigDecimal.ZERO, BigDecimal::add);
            if (line.getNoticeLineId() != null && quantity.signum() > 0) {
                salesNoticeService.updateSalesNoticeLineOutCount(line.getNoticeLineId(), quantity);
            }
        }
    }
    private void createTransactionList(MesWmProductSalesDO sales) {
        List<MesWmProductSalesDetailDO> details = productSalesDetailService.getProductSalesDetailListBySalesId(sales.getId());
    private void createTransactionList(MesWmProductSalesDO sales, List<MesWmProductSalesDetailDO> details) {
        Set<Long> bagIds = details.stream().map(MesWmProductSalesDetailDO::getBagCodeId).filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, MesProBagCodeDO> bags = bagIds.isEmpty() ? Map.of() : pickingBagMapper.selectBatchIds(bagIds).stream()
                .collect(Collectors.toMap(MesProBagCodeDO::getId, b -> b));
        wmTransactionService.createTransactionList(convertList(details, detail -> new MesWmTransactionSaveReqDTO()
                .setType(MesWmTransactionTypeEnum.OUT.getType()).setItemId(detail.getItemId())
                .setQuantity(detail.getQuantity().negate()) // 出库数量为负数
                .setBatchId(detail.getBatchId()).setBatchCode(detail.getBatchCode())
                .setBagCodeId(detail.getBagCodeId()) // 新扫码明细逐袋关联,执行出库逐袋更新库存状态
                .setShipmentId(detail.getShipmentId())
                .setBagCode(detail.getBagCodeId() == null || !bags.containsKey(detail.getBagCodeId()) ? null : bags.get(detail.getBagCodeId()).getCode())
                .setWarehouseId(detail.getWarehouseId()).setLocationId(detail.getLocationId()).setAreaId(detail.getAreaId())
                .setBizType(MesBizTypeConstants.WM_PRODUCT_SALES).setBizId(sales.getId())
                .setBizCode(sales.getCode()).setBizLineId(detail.getLineId())
                .setMaterialStockId(detail.getMaterialStockId()))); // 指定库存记录ID,避免重新匹配
    }
    /** 兼容升级前已进入待运单或待出库状态的旧单据。调用方须持有出库单行锁。 */
    private cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO ensureActiveShipment(
            MesWmProductSalesDO sales, int expectedStatus) {
        var shipment = shipmentMapper.selectActive(sales.getId());
        if (shipment != null) {
            if (!Integer.valueOf(expectedStatus).equals(shipment.getStatus())) throw new IllegalArgumentException("发货记录状态异常,请刷新后重试");
            return shipment;
        }
        shipment = new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO();
        shipment.setSalesId(sales.getId()); shipment.setStatus(expectedStatus);
        shipment.setCarrier(sales.getCarrier()); shipment.setShippingNumber(sales.getShippingNumber());
        shipmentMapper.insert(shipment);
        for (MesWmProductSalesDetailDO detail : productSalesDetailService.getProductSalesDetailListBySalesId(sales.getId())) {
            if (detail.getShipmentId() == null)
                productSalesDetailMapper.updateById(new MesWmProductSalesDetailDO().setId(detail.getId()).setShipmentId(shipment.getId()));
        }
        return shipment;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelProductSales(Long id) {
        // 校验存在
        MesWmProductSalesDO sales = validateProductSalesExists(id);
        MesWmProductSalesDO sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        // 已完成和已取消不允许取消
        if (ObjectUtils.equalsAny(sales.getStatus(),
                MesWmProductSalesStatusEnum.FINISHED.getStatus(),
                MesWmProductSalesStatusEnum.CANCELED.getStatus())) {
                MesWmProductSalesStatusEnum.CANCELED.getStatus(), MesWmProductSalesStatusEnum.CLOSED.getStatus())) {
            throw exception(WM_PRODUCT_SALES_CANNOT_CANCEL);
        }
        if (shipmentMapper.selectBySalesId(id).stream().anyMatch(s -> Integer.valueOf(30).equals(s.getStatus())))
            throw new IllegalArgumentException("该单已有出库记录,请关闭剩余数量,不能取消已发货单据");
        // 释放库存占用
        stockReserveService.releaseReserve(MesWmStockReserveBizTypeEnum.PRODUCT_SALES.getType(), id);
        var shipment = shipmentMapper.selectActive(id);
        if (shipment != null) shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(40));
        // 取消
        productSalesMapper.updateById(new MesWmProductSalesDO()
                .setId(id).setStatus(MesWmProductSalesStatusEnum.CANCELED.getStatus()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void closeRemaining(Long id, String reason) {
        var sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!MesWmProductSalesStatusEnum.APPROVING.getStatus().equals(sales.getStatus()))
            throw new IllegalArgumentException("仅待拣货状态可关闭剩余数量");
        if (reason == null || reason.isBlank() || reason.trim().length() > 500)
            throw new IllegalArgumentException("请填写不超过500字的关闭原因");
        if (shipmentMapper.selectBySalesId(id).stream().noneMatch(s -> Integer.valueOf(30).equals(s.getStatus())))
            throw new IllegalArgumentException("尚未发生出库,请使用取消操作");
        if (productSalesDetailService.getProductSalesDetailListBySalesId(id).stream().anyMatch(d -> d.getShipmentId() == null))
            throw new IllegalArgumentException("请先删除本次未发货的拣货明细");
        productSalesMapper.updateById(new MesWmProductSalesDO().setId(id)
                .setStatus(MesWmProductSalesStatusEnum.CLOSED.getStatus()).setCloseReason(reason.trim()));
        integrityService.recordSalesIntegrity(id);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelShipment(Long id) {
        var sales = productSalesMapper.selectByIdForUpdate(id);
        if (sales == null) throw exception(WM_PRODUCT_SALES_NOT_EXISTS);
        if (!ObjectUtils.equalsAny(sales.getStatus(), MesWmProductSalesStatusEnum.SHIPPING.getStatus(), MesWmProductSalesStatusEnum.APPROVED.getStatus()))
            throw new IllegalArgumentException("仅待运单或待出库的发货记录可取消");
        int expected = MesWmProductSalesStatusEnum.SHIPPING.getStatus().equals(sales.getStatus()) ? 10 : 20;
        var shipment = ensureActiveShipment(sales, expected);
        stockReserveService.releaseReserve(MesWmStockReserveBizTypeEnum.PRODUCT_SALES.getType(), id);
        productSalesDetailMapper.unassignShipment(shipment.getId());
        shipmentMapper.updateById(new cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesShipmentDO()
                .setId(shipment.getId()).setStatus(40));
        productSalesMapper.updateById(new MesWmProductSalesDO().setId(id).setStatus(MesWmProductSalesStatusEnum.APPROVING.getStatus()));
    }
    @Override
    public void confirmProductSales(Long id) {
        // 校验存在 + 待检测状态
        MesWmProductSalesDO sales = validateProductSalesExists(id);