From 87733eb3623593da247c051593d64b8ff9e1d3a4 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 06 十一月 2026 19:00:39 +0800
Subject: [PATCH] feat(crm,mes): 扩展客户字段与重构批次号生成逻辑

---
 yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java |  167 ++++++++++++++++++++++++++++++++++++++++++++++---------
 1 files changed, 140 insertions(+), 27 deletions(-)

diff --git a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java b/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
index 1b67a0c..1b8abc1 100644
--- a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesServiceImpl.java
+++ b/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) {
-        // 鏍¢獙姣忚鏄庣粏鏁伴噺涔嬪拰鏄惁绛変簬琛屽嚭搴撴暟閲�
+        // 鏈鑷冲皯鎷d竴鏉★紝绱鎷h揣涓嶈秴杩囪鍒掞紱鍏佽璺ㄨ銆佸垎娆″彂璐с��
         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("浠呭緟鎷h揣鐘舵�佸彲鍏抽棴鍓╀綑鏁伴噺");
+        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("璇峰厛鍒犻櫎鏈鏈彂璐х殑鎷h揣鏄庣粏");
+        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);

--
Gitblit v1.9.3