From 6ef4265f1859e88e3e5ff22ef1848e12fa849e26 Mon Sep 17 00:00:00 2001
From: gongchunyi <deslre0381@gmail.com>
Date: 星期二, 12 五月 2026 07:03:43 +0800
Subject: [PATCH] feat: 扫码出库修改为扫码发货

---
 src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java      |   40 +-
 src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java           |    7 
 src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java        |  303 +++++++++++++++++++++++++
 src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java         |   30 ++
 src/main/java/com/ruoyi/sales/dto/SalesScanShipDto.java                       |   37 +++
 src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java                |   13 +
 src/test/java/com/ruoyi/sales/InvoiceLedgerReceiptIncomeRebuildBatchTest.java |  264 ++++++++++++++++++++++
 7 files changed, 675 insertions(+), 19 deletions(-)

diff --git a/src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java b/src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java
index c42eeab..3faa5d3 100644
--- a/src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java
+++ b/src/main/java/com/ruoyi/approve/service/impl/ApproveNodeServiceImpl.java
@@ -259,26 +259,28 @@
         }
         // 鍑哄簱瀹℃壒淇敼 (璁㈠崟绾у埆)
         if(approveProcess.getApproveType().equals(7)){
-            String[] split = approveProcess.getApproveReason().split(":");
-            if (split.length > 1) {
-                String identifier = split[1];
-                // 鏌ユ壘閿�鍞彴璐�
-                SalesLedger salesLedger = salesLedgerMapper.selectOne(new LambdaQueryWrapper<SalesLedger>()
-                        .eq(SalesLedger::getSalesContractNo, identifier)
-                        .last("limit 1"));
+            String scanRemark = approveProcess.getApproveRemark();
+            if (org.springframework.util.StringUtils.hasText(scanRemark) && scanRemark.startsWith("SCAN_SHIP_DELIVERY_JSON:")) {
+                salesLedgerService.onScanShipDeliveryApproveOutcome(approveProcess, status);
+            } else {
+                String[] split = approveProcess.getApproveReason().split(":");
+                if (split.length > 1) {
+                    String identifier = split[1];
+                    SalesLedger salesLedger = salesLedgerMapper.selectOne(new LambdaQueryWrapper<SalesLedger>()
+                            .eq(SalesLedger::getSalesContractNo, identifier)
+                            .last("limit 1"));
 
-                if (salesLedger != null) {
-                    if(status.equals(2)){
-                        // 瀹℃壒瀹屾垚 -> 淇敼鐘舵�佷负瀹℃牳閫氳繃锛屼笉鎵i櫎搴撳瓨锛堟墸闄ゅ簱瀛樺湪鍙戣揣鍙拌处琛ュ厖淇℃伅锛�
-                        updateSalesLedgerDeliveryStatus(salesLedger.getId(), 4);
-                        updateShippingInfoStatusByOrder(salesLedger.getId(), "瀹℃牳閫氳繃");
-                    } else if(status.equals(3)){
-                        updateSalesLedgerDeliveryStatus(salesLedger.getId(), 3);
-                        // 鏇存柊鍏宠仈鐨勫彂璐ц褰曚负瀹℃牳鎷掔粷
-                        updateShippingInfoStatusByOrder(salesLedger.getId(), "瀹℃牳鎷掔粷");
-                    } else if(status.equals(1)){
-                        updateSalesLedgerDeliveryStatus(salesLedger.getId(), 2);
-                        updateShippingInfoStatusByOrder(salesLedger.getId(), "瀹℃牳涓�");
+                    if (salesLedger != null) {
+                        if(status.equals(2)){
+                            updateSalesLedgerDeliveryStatus(salesLedger.getId(), 4);
+                            updateShippingInfoStatusByOrder(salesLedger.getId(), "瀹℃牳閫氳繃");
+                        } else if(status.equals(3)){
+                            updateSalesLedgerDeliveryStatus(salesLedger.getId(), 3);
+                            updateShippingInfoStatusByOrder(salesLedger.getId(), "瀹℃牳鎷掔粷");
+                        } else if(status.equals(1)){
+                            updateSalesLedgerDeliveryStatus(salesLedger.getId(), 2);
+                            updateShippingInfoStatusByOrder(salesLedger.getId(), "瀹℃牳涓�");
+                        }
                     }
                 }
             }
diff --git a/src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java b/src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java
index 26c14a5..0825a8d 100644
--- a/src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java
+++ b/src/main/java/com/ruoyi/sales/controller/SalesLedgerController.java
@@ -342,6 +342,13 @@
         return AjaxResult.success();
     }
 
+    @PostMapping("/scanShipApply")
+    @ApiOperation("閿�鍞鍗曟壂鐮�-鍙戣捣鍙戣揣瀹℃壒锛堝~鍐欒溅鐗�/蹇�掋�佸鎵逛汉銆侀檮浠讹紱閫氳繃鍚庤嚜鍔ㄦ墸搴撳瓨骞舵爣璁板凡鍙戣揣锛�")
+    public AjaxResult scanShipApply(@RequestBody SalesScanShipDto dto) {
+        salesLedgerService.scanShipApply(dto);
+        return AjaxResult.success("鍙戣揣瀹℃壒宸插彂璧�");
+    }
+
     @PostMapping("/scanOutboundUnqualified")
     @ApiOperation("閿�鍞鍗曟壂鐮�-涓嶅悎鏍煎嚭搴�")
     public AjaxResult scanOutboundUnqualified(@RequestBody SalesScanInboundDto dto) {
diff --git a/src/main/java/com/ruoyi/sales/dto/SalesScanShipDto.java b/src/main/java/com/ruoyi/sales/dto/SalesScanShipDto.java
new file mode 100644
index 0000000..9c3a2c9
--- /dev/null
+++ b/src/main/java/com/ruoyi/sales/dto/SalesScanShipDto.java
@@ -0,0 +1,37 @@
+package com.ruoyi.sales.dto;
+
+import com.ruoyi.sales.pojo.SalesLedgerProduct;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * APP 鎵爜鍙戣揣锛氬~鍐欏彂璐т俊鎭�佸鎵逛汉銆侀檮浠跺悗鍙戣捣鍙戣揣瀹℃壒锛涘鎵归�氳繃鍚庤嚜鍔ㄦ墸搴撳瓨骞舵爣璁板凡鍙戣揣銆�
+ */
+@Data
+@ApiModel(value = "SalesScanShipDto", description = "閿�鍞壂鐮佸彂璐э紙鍙戣捣瀹℃壒锛�")
+public class SalesScanShipDto {
+
+    @ApiModelProperty("閿�鍞鍗� Id")
+    private Long salesLedgerId;
+
+    @ApiModelProperty("鏈鍙戣揣鏁伴噺")
+    private List<SalesLedgerProduct> salesLedgerProductList;
+
+    @ApiModelProperty(value = "瀹℃壒浜� userId锛岄�楀彿鍒嗛殧", required = true)
+    private String approveUserIds;
+
+    @ApiModelProperty("鍙戣揣绫诲瀷锛氳揣杞� / 蹇��")
+    private String shipType;
+
+    @ApiModelProperty("杞︾墝鍙�")
+    private String shippingCarNumber;
+
+    @ApiModelProperty("蹇�掑崟鍙�")
+    private String expressNumber;
+
+    @ApiModelProperty("涓存椂鏂囦欢 id 鍒楄〃")
+    private List<String> tempFileIds;
+}
diff --git a/src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java b/src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java
index 048d8be..ca9de2d 100644
--- a/src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java
+++ b/src/main/java/com/ruoyi/sales/service/ISalesLedgerService.java
@@ -5,6 +5,7 @@
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.ruoyi.basic.pojo.Customer;
 import com.ruoyi.common.enums.SaleEnum;
+import com.ruoyi.approve.pojo.ApproveProcess;
 import com.ruoyi.sales.dto.*;
 import com.ruoyi.sales.pojo.SalesLedger;
 import com.ruoyi.sales.pojo.SalesLedgerProcessRoute;
@@ -85,6 +86,18 @@
 
     void scanOutboundUnqualified(SalesScanInboundDto dto);
 
+    /**
+     * APP 鎵爜鍙戣揣锛氬彂璧峰彂璐у鎵癸紙瀹℃壒閫氳繃鍚庤嚜鍔ㄦ墸搴撳瓨銆佸彂璐у彴璐︿笌璁㈠崟鐘舵�佷负宸插彂璐э級
+     */
+    void scanShipApply(SalesScanShipDto dto);
+
+    /**
+     * 鍙戣揣瀹℃壒锛堢被鍨� 7锛夎妭鐐圭姸鎬佸彉鏇达細鎵爜鍙戣揣娴佺▼ {@code approveRemark} 浠� {@code SCAN_SHIP_DELIVERY_JSON:} 寮�澶存椂鍥炶皟銆�
+     *
+     * @param outcomeStatus 瀹℃壒娴佺姸鎬侊細1 瀹℃牳涓� 2 閫氳繃 3 鎷掔粷
+     */
+    void onScanShipDeliveryApproveOutcome(ApproveProcess approveProcess, Integer outcomeStatus);
+
     void shippingImport(MultipartFile file);
 
     void notShippingImport(MultipartFile file);
diff --git a/src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java b/src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java
index dfc054f..8d93b46 100644
--- a/src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java
+++ b/src/main/java/com/ruoyi/sales/service/impl/CommonFileServiceImpl.java
@@ -2,6 +2,7 @@
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
+import com.ruoyi.common.enums.FileNameType;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.ruoyi.common.constant.Constants;
 import com.ruoyi.common.utils.StringUtils;
@@ -30,6 +31,7 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.UUID;
+import java.util.stream.Collectors;
 
 @Service
 @RequiredArgsConstructor
@@ -173,6 +175,34 @@
         }
     }
 
+    /**
+     * 灏嗗鎵瑰崟涓婄殑闄勪欢璁板綍澶嶅埗涓哄鏉″彂璐у彴璐﹂檮浠讹紙鍚屼竴鏂囦欢璺緞锛屾瘡鏉″彂璐у彴璐﹀悇涓�鏉¤褰曪級銆�
+     */
+    public void copyApproveProcessShipAttachmentsToShippingInfos(Long approveProcessId, List<Long> shippingInfoIds) {
+        if (approveProcessId == null || CollectionUtils.isEmpty(shippingInfoIds)) {
+            return;
+        }
+        List<CommonFile> files = commonFileMapper.selectList(new LambdaQueryWrapper<CommonFile>()
+                .eq(CommonFile::getCommonId, approveProcessId)
+                .eq(CommonFile::getType, FileNameType.ApproveProcess.getValue()));
+        if (CollectionUtils.isEmpty(files)) {
+            return;
+        }
+        List<Long> distinctTargets = shippingInfoIds.stream().filter(java.util.Objects::nonNull).distinct().collect(Collectors.toList());
+        for (Long sid : distinctTargets) {
+            for (CommonFile f : files) {
+                CommonFile copy = new CommonFile();
+                copy.setCommonId(sid);
+                copy.setName(f.getName());
+                copy.setUrl(f.getUrl());
+                copy.setLink(f.getLink());
+                copy.setType(FileNameType.SHIP.getValue());
+                copy.setCreateTime(LocalDateTime.now());
+                commonFileMapper.insert(copy);
+            }
+        }
+    }
+
     private String buildAccessLink(Path formalFilePath) {
         String normalizedPath = formalFilePath.toString().replace("\\", "/");
         String profile = RuoYiConfig.getProfile();
diff --git a/src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java b/src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
index ff32a36..61777bb 100644
--- a/src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
+++ b/src/main/java/com/ruoyi/sales/service/impl/SalesLedgerServiceImpl.java
@@ -1,6 +1,7 @@
 package com.ruoyi.sales.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
@@ -11,6 +12,9 @@
 import com.ruoyi.account.service.AccountIncomeService;
 import com.ruoyi.approve.service.IApproveProcessService;
 import com.ruoyi.approve.vo.ApproveProcessVO;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.ruoyi.approve.pojo.ApproveProcess;
 import com.ruoyi.common.enums.ApproveTypeEnum;
 import com.ruoyi.basic.mapper.CustomerMapper;
@@ -3012,4 +3016,303 @@
         }
         return productModel.getUnit();
     }
+
+    private static final String SCAN_SHIP_REMARK_PREFIX = "SCAN_SHIP_DELIVERY_JSON:";
+
+    private static final class ScanShipPayload {
+        private String shippingNo;
+        private Long ledgerId;
+        private String car;
+        private String express;
+        private String shipType;
+        private Map<Long, BigDecimal> linesQty = new LinkedHashMap<>();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void scanShipApply(SalesScanShipDto dto) {
+        if (dto == null || dto.getSalesLedgerId() == null) {
+            throw new ServiceException("鎵爜鍙戣揣澶辫触锛岃鍗曚笉鑳戒负绌�");
+        }
+        if (StringUtils.isEmpty(dto.getApproveUserIds())) {
+            throw new ServiceException("璇烽�夋嫨瀹℃壒浜�");
+        }
+        if (CollectionUtils.isEmpty(dto.getSalesLedgerProductList())) {
+            throw new ServiceException("璇峰~鍐欏彂璐т骇鍝佽");
+        }
+        String shipType = StringUtils.hasText(dto.getShipType()) ? dto.getShipType().trim() : "璐ц溅";
+        if ("璐ц溅".equals(shipType)) {
+            if (!StringUtils.hasText(dto.getShippingCarNumber())) {
+                throw new ServiceException("璇峰~鍐欒溅鐗屽彿");
+            }
+        } else if ("蹇��".equals(shipType)) {
+            if (!StringUtils.hasText(dto.getExpressNumber())) {
+                throw new ServiceException("璇峰~鍐欏揩閫掑崟鍙�");
+            }
+        }
+        SalesLedger salesLedger = baseMapper.selectById(dto.getSalesLedgerId());
+        if (salesLedger == null) {
+            throw new ServiceException("閿�鍞鍗曚笉瀛樺湪");
+        }
+        if (salesLedger.getDeliveryStatus() != null && salesLedger.getDeliveryStatus() == 5) {
+            throw new ServiceException("璇ラ攢鍞鍗曞凡鍙戣揣");
+        }
+        if (salesLedger.getDeliveryStatus() != null && salesLedger.getDeliveryStatus() == 2) {
+            throw new ServiceException("璇ラ攢鍞鍗曞凡鍙戣捣鍙戣揣瀹℃壒锛岃鍏堝畬鎴愬鎵�");
+        }
+        List<SalesLedgerProduct> notStocked = salesLedgerProductMapper.selectList(new LambdaQueryWrapper<SalesLedgerProduct>()
+                .eq(SalesLedgerProduct::getSalesLedgerId, salesLedger.getId())
+                .eq(SalesLedgerProduct::getType, 1)
+                .ne(SalesLedgerProduct::getProductStockStatus, 2));
+        if (org.apache.commons.collections4.CollectionUtils.isNotEmpty(notStocked)) {
+            throw new ServiceException("鍙戣揣澶辫触,璇ラ攢鍞鍗曞瓨鍦ㄦ湭鍏ュ簱浜у搧,璇峰厛瀹屾垚鍏ㄩ儴鍏ュ簱鍚庡啀鍙戣揣");
+        }
+        int saleType = SaleEnum.SALE.getCode();
+        Map<Long, BigDecimal> shipQtyByLineId = new LinkedHashMap<>();
+        for (SalesLedgerProduct line : dto.getSalesLedgerProductList()) {
+            if (line == null || line.getId() == null) {
+                throw new ServiceException("浜у搧淇℃伅涓嶅畬鏁�");
+            }
+            BigDecimal q = line.getStockedQuantity();
+            if (q == null) {
+                throw new ServiceException("鍙戣揣鏁伴噺涓嶈兘涓虹┖");
+            }
+            if (q.compareTo(BigDecimal.ZERO) < 0) {
+                throw new ServiceException("鍙戣揣鏁伴噺涓嶈兘涓鸿礋鏁�");
+            }
+            shipQtyByLineId.merge(line.getId(), q, BigDecimal::add);
+        }
+        boolean anyPositive = shipQtyByLineId.values().stream().anyMatch(v -> v.compareTo(BigDecimal.ZERO) > 0);
+        if (!anyPositive) {
+            throw new ServiceException("璇疯嚦灏戝~鍐欎竴琛屽ぇ浜� 0 鐨勫彂璐ф暟閲�");
+        }
+        Long ledgerId = salesLedger.getId();
+        for (Map.Entry<Long, BigDecimal> entry : shipQtyByLineId.entrySet()) {
+            if (entry.getValue().compareTo(BigDecimal.ZERO) == 0) {
+                continue;
+            }
+            SalesLedgerProduct dbProduct = salesLedgerProductMapper.selectById(entry.getKey());
+            if (dbProduct == null) {
+                throw new ServiceException("閿�鍞骇鍝佷笉瀛樺湪");
+            }
+            if (!Objects.equals(dbProduct.getSalesLedgerId(), ledgerId) || !Objects.equals(dbProduct.getType(), saleType)) {
+                throw new ServiceException("閿�鍞骇鍝佷笌璁㈠崟涓嶅尮閰�");
+            }
+            if (dbProduct.getProductModelId() == null) {
+                throw new ServiceException("浜у搧瑙勬牸鏈淮鎶わ紝鏃犳硶鍙戣揣");
+            }
+            BigDecimal orderQty = defaultDecimal(dbProduct.getQuantity());
+            BigDecimal prevShipped = defaultDecimal(dbProduct.getShippedQuantity());
+            if (prevShipped.add(entry.getValue()).compareTo(orderQty) > 0) {
+                throw new ServiceException("绱鍙戣揣鏁伴噺涓嶈兘澶т簬璇ヤ骇鍝佽鍗曟暟閲�");
+            }
+            stockUtils.assertQualifiedAvailable(dbProduct.getProductModelId(), entry.getValue());
+        }
+        String shNo = OrderUtils.countTodayByCreateTime(shippingInfoMapper, "SH");
+        Map<Long, BigDecimal> positiveLines = new LinkedHashMap<>();
+        for (Map.Entry<Long, BigDecimal> e : shipQtyByLineId.entrySet()) {
+            if (e.getValue().compareTo(BigDecimal.ZERO) > 0) {
+                positiveLines.put(e.getKey(), e.getValue());
+            }
+        }
+        for (Map.Entry<Long, BigDecimal> e : positiveLines.entrySet()) {
+            ShippingInfo si = new ShippingInfo();
+            si.setSalesLedgerId(ledgerId);
+            si.setSalesLedgerProductId(e.getKey());
+            si.setShippingNo(shNo);
+            si.setStatus("寰呭鏍�");
+            si.setType(shipType);
+            if ("璐ц溅".equals(shipType)) {
+                si.setShippingCarNumber(dto.getShippingCarNumber().trim());
+            }
+            if ("蹇��".equals(shipType)) {
+                si.setExpressNumber(dto.getExpressNumber().trim());
+            }
+            shippingInfoMapper.insert(si);
+        }
+        String remarkJson = buildScanShipRemarkJson(shNo, ledgerId, dto, positiveLines);
+        LoginUser loginUser = SecurityUtils.getLoginUser();
+        ApproveProcessVO approveProcessVO = new ApproveProcessVO();
+        approveProcessVO.setApproveType(7);
+        approveProcessVO.setApproveDeptId(loginUser.getCurrentDeptId());
+        approveProcessVO.setApproveReason("鍙戣揣瀹℃壒:" + salesLedger.getSalesContractNo());
+        approveProcessVO.setApproveRemark(remarkJson);
+        approveProcessVO.setApproveUserIds(dto.getApproveUserIds().trim());
+        approveProcessVO.setApproveUser(loginUser.getUserId());
+        approveProcessVO.setApproveTime(LocalDate.now().toString());
+        approveProcessVO.setTempFileIds(dto.getTempFileIds());
+        try {
+            approveProcessService.addApprove(approveProcessVO);
+        } catch (Exception e) {
+            throw new ServiceException("鍙戣捣鍙戣揣瀹℃壒澶辫触: " + e.getMessage());
+        }
+        salesLedger.setDeliveryStatus(2);
+        baseMapper.updateById(salesLedger);
+    }
+
+    private String buildScanShipRemarkJson(String shippingNo, Long ledgerId, SalesScanShipDto dto, Map<Long, BigDecimal> lines) {
+        try {
+            ObjectMapper om = new ObjectMapper();
+            ObjectNode root = om.createObjectNode();
+            root.put("shippingNo", shippingNo);
+            root.put("ledgerId", ledgerId);
+            root.put("car", dto.getShippingCarNumber() == null ? "" : dto.getShippingCarNumber().trim());
+            root.put("express", dto.getExpressNumber() == null ? "" : dto.getExpressNumber().trim());
+            root.put("shipType", dto.getShipType() == null ? "璐ц溅" : dto.getShipType().trim());
+            ObjectNode linesNode = om.createObjectNode();
+            for (Map.Entry<Long, BigDecimal> e : lines.entrySet()) {
+                linesNode.put(String.valueOf(e.getKey()), e.getValue().stripTrailingZeros().toPlainString());
+            }
+            root.set("lines", linesNode);
+            return SCAN_SHIP_REMARK_PREFIX + om.writeValueAsString(root);
+        } catch (Exception e) {
+            throw new ServiceException("鏋勫缓鍙戣揣瀹℃壒鍙傛暟澶辫触");
+        }
+    }
+
+    private ScanShipPayload parseScanShipPayload(String remark) {
+        if (!StringUtils.hasText(remark) || !remark.startsWith(SCAN_SHIP_REMARK_PREFIX)) {
+            return null;
+        }
+        try {
+            String json = remark.substring(SCAN_SHIP_REMARK_PREFIX.length());
+            ObjectMapper om = new ObjectMapper();
+            JsonNode n = om.readTree(json);
+            ScanShipPayload p = new ScanShipPayload();
+            p.shippingNo = n.path("shippingNo").asText(null);
+            p.ledgerId = n.path("ledgerId").asLong(0L);
+            p.car = n.path("car").asText("");
+            p.express = n.path("express").asText("");
+            p.shipType = n.path("shipType").asText("璐ц溅");
+            JsonNode lines = n.path("lines");
+            p.linesQty = new LinkedHashMap<>();
+            if (lines.isObject()) {
+                Iterator<String> it = lines.fieldNames();
+                while (it.hasNext()) {
+                    String k = it.next();
+                    p.linesQty.put(Long.valueOf(k), new BigDecimal(lines.get(k).asText()));
+                }
+            }
+            return p;
+        } catch (Exception e) {
+            log.warn("瑙f瀽鎵爜鍙戣揣瀹℃壒澶囨敞澶辫触: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void onScanShipDeliveryApproveOutcome(ApproveProcess approveProcess, Integer outcomeStatus) {
+        if (approveProcess == null) {
+            return;
+        }
+        ScanShipPayload ctx = parseScanShipPayload(approveProcess.getApproveRemark());
+        if (ctx == null || ctx.ledgerId == null || ctx.ledgerId <= 0 || !StringUtils.hasText(ctx.shippingNo)) {
+            return;
+        }
+        if (outcomeStatus != null && outcomeStatus == 2) {
+            executeScanShipDeliveryApproved(approveProcess, ctx);
+        } else if (outcomeStatus != null && outcomeStatus == 3) {
+            updateScanShipBatchShippingStatus(ctx.ledgerId, ctx.shippingNo, "瀹℃牳鎷掔粷");
+            SalesLedger sl = baseMapper.selectById(ctx.ledgerId);
+            if (sl != null) {
+                sl.setDeliveryStatus(3);
+                baseMapper.updateById(sl);
+            }
+        } else if (outcomeStatus != null && outcomeStatus == 1) {
+            updateScanShipBatchShippingStatus(ctx.ledgerId, ctx.shippingNo, "瀹℃牳涓�");
+        }
+    }
+
+    private void updateScanShipBatchShippingStatus(Long ledgerId, String shippingNo, String statusText) {
+        if (ledgerId == null || !StringUtils.hasText(shippingNo)) {
+            return;
+        }
+        shippingInfoMapper.update(null, new UpdateWrapper<ShippingInfo>().lambda()
+                .set(ShippingInfo::getStatus, statusText)
+                .eq(ShippingInfo::getSalesLedgerId, ledgerId)
+                .eq(ShippingInfo::getShippingNo, shippingNo));
+    }
+
+    private void executeScanShipDeliveryApproved(ApproveProcess approveProcess, ScanShipPayload ctx) {
+        int saleType = SaleEnum.SALE.getCode();
+        Date now = new Date();
+        List<ShippingInfo> batch = shippingInfoMapper.selectList(Wrappers.<ShippingInfo>lambdaQuery()
+                .eq(ShippingInfo::getSalesLedgerId, ctx.ledgerId)
+                .eq(ShippingInfo::getShippingNo, ctx.shippingNo));
+        if (CollectionUtils.isEmpty(batch)) {
+            log.warn("鎵爜鍙戣揣瀹℃壒閫氳繃浣嗘湭鎵惧埌鍙戣揣鍙拌处 batch ledgerId={} shippingNo={}", ctx.ledgerId, ctx.shippingNo);
+            return;
+        }
+        for (Map.Entry<Long, BigDecimal> entry : ctx.linesQty.entrySet()) {
+            Long productLineId = entry.getKey();
+            BigDecimal shipQty = entry.getValue();
+            if (shipQty == null || shipQty.compareTo(BigDecimal.ZERO) <= 0) {
+                continue;
+            }
+            SalesLedgerProduct dbProduct = salesLedgerProductMapper.selectById(productLineId);
+            if (dbProduct == null) {
+                throw new ServiceException("閿�鍞骇鍝佷笉瀛樺湪");
+            }
+            ShippingInfo row = batch.stream()
+                    .filter(si -> Objects.equals(si.getSalesLedgerProductId(), productLineId))
+                    .findFirst()
+                    .orElse(null);
+            if (row == null) {
+                throw new ServiceException("鏈壘鍒板搴斿彂璐у彴璐﹁");
+            }
+            if (!"宸插彂璐�".equals(row.getStatus())) {
+                stockUtils.substractStock(ctx.ledgerId, productLineId, dbProduct.getProductModelId(), shipQty,
+                        StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode(), row.getId());
+                BigDecimal oldShipped = defaultDecimal(dbProduct.getShippedQuantity());
+                dbProduct.setShippedQuantity(oldShipped.add(shipQty));
+                dbProduct.fillRemainingQuantity();
+                salesLedgerProductMapper.updateById(dbProduct);
+            }
+            row.setStatus("宸插彂璐�");
+            row.setShippingDate(now);
+            if (StringUtils.hasText(ctx.car)) {
+                row.setShippingCarNumber(ctx.car.trim());
+            }
+            if (StringUtils.hasText(ctx.express)) {
+                row.setExpressNumber(ctx.express.trim());
+            }
+            if (StringUtils.hasText(ctx.shipType)) {
+                row.setType(ctx.shipType.trim());
+            }
+            shippingInfoMapper.updateById(row);
+        }
+        List<Long> shippingIds = batch.stream().map(ShippingInfo::getId).filter(Objects::nonNull).collect(Collectors.toList());
+        commonFileService.copyApproveProcessShipAttachmentsToShippingInfos(approveProcess.getId(), shippingIds);
+        List<SalesLedgerProduct> ledgerAllProducts = salesLedgerProductMapper.selectList(
+                Wrappers.<SalesLedgerProduct>lambdaQuery().eq(SalesLedgerProduct::getSalesLedgerId, ctx.ledgerId));
+        SalesLedger salesLedger = baseMapper.selectById(ctx.ledgerId);
+        if (salesLedger == null) {
+            return;
+        }
+        boolean anyInbound = ledgerAllProducts.stream().anyMatch(p -> {
+            BigDecimal sq = p.getStockedQuantity();
+            return sq != null && sq.compareTo(BigDecimal.ZERO) > 0;
+        });
+        boolean allLinesFull = ledgerAllProducts.stream().allMatch(p -> Objects.equals(p.getProductStockStatus(), 2));
+        salesLedger.setStockStatus(allLinesFull ? 2 : (anyInbound ? 1 : 0));
+        List<SalesLedgerProduct> saleLines = ledgerAllProducts.stream()
+                .filter(p -> Objects.equals(p.getType(), saleType))
+                .collect(Collectors.toList());
+        boolean allDelivered = !saleLines.isEmpty() && saleLines.stream().allMatch(p -> {
+            BigDecimal q = defaultDecimal(p.getQuantity());
+            BigDecimal s = defaultDecimal(p.getShippedQuantity());
+            return q.compareTo(BigDecimal.ZERO) <= 0 || s.compareTo(q) >= 0;
+        });
+        if (allDelivered) {
+            salesLedger.setDeliveryStatus(5);
+        } else {
+            boolean anyLineShipped = saleLines.stream()
+                    .anyMatch(p -> defaultDecimal(p.getShippedQuantity()).compareTo(BigDecimal.ZERO) > 0);
+            if (anyLineShipped) {
+                salesLedger.setDeliveryStatus(6);
+            }
+        }
+        baseMapper.updateById(salesLedger);
+    }
 }
diff --git a/src/test/java/com/ruoyi/sales/InvoiceLedgerReceiptIncomeRebuildBatchTest.java b/src/test/java/com/ruoyi/sales/InvoiceLedgerReceiptIncomeRebuildBatchTest.java
new file mode 100644
index 0000000..14f50f2
--- /dev/null
+++ b/src/test/java/com/ruoyi/sales/InvoiceLedgerReceiptIncomeRebuildBatchTest.java
@@ -0,0 +1,264 @@
+package com.ruoyi.sales;
+
+import com.ruoyi.RuoYiApplication;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.test.annotation.Rollback;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.sql.DataSource;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * <b>涓氬姟鍙e緞</b>锛氬巻鍙查噷 {@code receipt_payment.invoice_ledger_id} 甯歌瀛樻垚 <b>閿�鍞骇鍝佽 id</b>锛坽@code sales_ledger_product_id}锛夛紝
+ * 涓庣湡瀹� {@code invoice_ledger.id} 涓嶄竴鑷淬�傚悓姝ユ椂搴斾互 <b>寮�绁ㄥ彴璐�</b> 涓哄噯锛氬嚒鏄�屽凡缁忓紑绁ㄣ�嶇殑閿�鍞彴璐︿骇鍝佽锛�
+ * 灏卞繀椤绘湁涓庝箣瀵瑰簲鐨勫洖娆炬祦姘翠笌鍥炴鏀跺叆锛屼笖<strong>涓�琛屽紑绁ㄥ彴璐� 鈫� 涓�琛屽洖娆� 鈫� 涓�琛屾敹鍏�</strong>銆�
+ * <p>
+ * <b>鎺ㄨ崘锛歿@link #syncReceiptByInvoicedSalesProducts_hbtmblcSchema()}</b>
+ * <ol>
+ *   <li>鏌ュ嚭鎵�鏈夊湪寮�绁ㄥ彴璐﹂噷鍑虹幇杩囩殑 {@code sales_ledger_product_id}锛坽@code invoice_ledger} 鈫� {@code invoice_registration_product}锛夈��</li>
+ *   <li>鍒犻櫎杩欎簺<strong>閿�鍞骇鍝佽</strong>涓婄幇鏈夌殑 {@code receipt_payment}锛屽苟鍒犻櫎 {@code business_type=1} 涓旀寕鍦ㄨ繖浜涘洖娆句笂鐨� {@code account_income}銆�</li>
+ *   <li>鎸� {@code invoice_ledger} 閫愯鎻掑叆鏂扮殑 {@code receipt_payment}锛歿@code sales_ledger_id}銆亄@code sales_ledger_product_id} 鏉ヨ嚜鐧昏浜у搧琛岋紝
+ *       {@code invoice_ledger_id = il.id}锛堢湡瀹炲彴璐︿富閿級锛岄噾棰�/鏃ユ湡/寮�绁ㄤ汉涓庡紑绁ㄥ彴璐︿竴鑷淬��</li>
+ *   <li>鎸夋柊鍥炴涓婚敭鎻掑叆 {@code account_income}锛堜笌 {@link com.ruoyi.sales.service.impl.ReceiptPaymentServiceImpl} 涓�鑷达細{@code business_id=receipt_payment.id}锛夈��</li>
+ * </ol>
+ * 鏈湪寮�绁ㄩ噷鍑虹幇鐨勯攢鍞骇鍝侊紝鍏跺洖娆捐褰�<strong>涓嶅垹涓嶆敼</strong>銆�
+ * <p>
+ * <b>鎱庣敤锛歿@link #fullRebuildReceiptFromInvoiceLedger_hbtmblcSchema()}</b> 鈥� 娓呯┖<b>鍏ㄩ儴</b>鍥炴涓�<b>鍏ㄩ儴</b>鍥炴绫绘敹鍏ュ悗閲嶅缓锛�
+ * 浠呯敤浜庢暣搴撲互寮�绁ㄤ负鍞竴浜嬪疄婧愮殑鍦烘櫙銆�
+ * <p>
+ * <b>鍐欏簱寮�鍏�</b>锛歿@code -Druoyi.invoiceReceiptRebuild.commit=true} 鎴� {@code RUOYI_INVOICE_RECEIPT_REBUILD_COMMIT=true}銆�
+ */
+@SpringBootTest(classes = RuoYiApplication.class)
+class InvoiceLedgerReceiptIncomeRebuildBatchTest {
+
+    private static final Logger log = LoggerFactory.getLogger(InvoiceLedgerReceiptIncomeRebuildBatchTest.class);
+
+    private static final Long TENANT_ID = null;
+
+    private static final String DEFAULT_REGISTRANT = "妯婂織鑻�";
+
+    private static final String RECEIPT_PAYMENT_TYPE = "0";
+
+    static boolean commitSwitchEnabled() {
+        String p = System.getProperty("ruoyi.invoiceReceiptRebuild.commit");
+        if (p != null && "true".equalsIgnoreCase(p.trim())) {
+            return true;
+        }
+        String e = System.getenv("RUOYI_INVOICE_RECEIPT_REBUILD_COMMIT");
+        return e != null && "true".equalsIgnoreCase(e.trim());
+    }
+
+    static MapSqlParameterSource tenantParams() {
+        return new MapSqlParameterSource("tenantId", TENANT_ID);
+    }
+
+    static String tenantCondIl() {
+        return "(:tenantId IS NULL OR COALESCE(irp.tenant_id, il.tenant_id) = :tenantId)";
+    }
+
+    static String tenantRp(String alias) {
+        return "(:tenantId IS NULL OR " + alias + ".tenant_id = :tenantId)";
+    }
+
+    /**
+     * 鍑虹幇鍦ㄥ紑绁ㄩ摼璺噷鐨勯攢鍞骇鍝� id 闆嗗悎锛堝瓙鏌ヨ鐗囨锛屼笉鍚灞傛嫭鍙凤級銆�
+     */
+    static String invoicedProductIdSubquery() {
+        return "SELECT DISTINCT irp.sales_ledger_product_id "
+                + "FROM invoice_ledger il "
+                + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                + "WHERE irp.sales_ledger_product_id IS NOT NULL "
+                + "AND (" + tenantCondIl() + ")";
+    }
+
+    @Autowired
+    private DataSource dataSource;
+
+    /**
+     * 鎸夈�屽凡寮�绁ㄧ殑閿�鍞彴璐︿骇鍝佽銆嶉噸寤哄洖娆句笌鏀跺叆锛堟帹鑽愶級銆�
+     */
+    @Test
+    @Transactional
+    void syncReceiptByInvoicedSalesProducts_hbtmblcSchema() {
+        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
+        MapSqlParameterSource p = tenantParams();
+
+        String deleteIncome =
+                "DELETE ai FROM account_income ai "
+                        + "INNER JOIN receipt_payment rp ON ai.business_id = rp.id AND ai.business_type = 1 "
+                        + "WHERE rp.sales_ledger_product_id IN (" + invoicedProductIdSubquery() + ") "
+                        + "AND (" + tenantRp("rp") + ")";
+        int delInc = named.update(deleteIncome, p);
+        log.warn("宸插垹闄ゃ�屽凡寮�绁ㄤ骇鍝併�嶅叧鑱旂殑鍥炴鏀跺叆 account_income 琛屾暟: {}", delInc);
+
+        String deleteReceipt =
+                "DELETE rp FROM receipt_payment rp "
+                        + "WHERE rp.sales_ledger_product_id IN (" + invoicedProductIdSubquery() + ") "
+                        + "AND (" + tenantRp("rp") + ")";
+        int delRp = named.update(deleteReceipt, p);
+        log.warn("宸插垹闄ゃ�屽凡寮�绁ㄤ骇鍝併�嶄笂鐨勬棫鍥炴 receipt_payment 琛屾暟: {}", delRp);
+
+        MapSqlParameterSource insRp = tenantParams().addValue("rpType", RECEIPT_PAYMENT_TYPE);
+        String insertReceipt =
+                "INSERT INTO receipt_payment ("
+                        + "sales_ledger_id, sales_ledger_product_id, invoice_ledger_id, "
+                        + "receipt_payment_type, receipt_payment_amount, registrant, receipt_payment_date, "
+                        + "create_time, create_user, update_time, update_user, tenant_id) "
+                        + "SELECT "
+                        + "  CAST(irp.sales_ledger_id AS SIGNED), "
+                        + "  CAST(irp.sales_ledger_product_id AS SIGNED), "
+                        + "  il.id, "
+                        + "  :rpType, "
+                        + "  il.invoice_total, "
+                        + "  NULLIF(TRIM(il.invoice_person), ''), "
+                        + "  DATE(il.invoice_date), "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  il.create_user, "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  il.update_user, "
+                        + "  COALESCE(irp.tenant_id, il.tenant_id) "
+                        + "FROM invoice_ledger il "
+                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                        + "WHERE irp.sales_ledger_product_id IS NOT NULL "
+                        + "AND (" + tenantCondIl() + ")";
+
+        int insRpCnt = named.update(insertReceipt, insRp);
+        log.warn("宸叉寜寮�绁ㄥ彴璐︽彃鍏� receipt_payment 琛屾暟: {}", insRpCnt);
+
+        MapSqlParameterSource regP = tenantParams().addValue("defReg", DEFAULT_REGISTRANT);
+        named.update(
+                "UPDATE receipt_payment rp SET rp.registrant = :defReg "
+                        + "WHERE (rp.registrant IS NULL OR rp.registrant = '') AND (" + tenantRp("rp") + ")",
+                regP);
+
+        MapSqlParameterSource insAi = tenantParams()
+                .addValue("rpType", RECEIPT_PAYMENT_TYPE)
+                .addValue("defReg", DEFAULT_REGISTRANT);
+        String insertIncome =
+                "INSERT INTO account_income ("
+                        + "income_date, income_type, customer_name, income_money, income_described, income_method, "
+                        + "invoice_number, input_user, input_time, business_id, business_type, tenant_id, "
+                        + "create_time, create_user, update_time, update_user) "
+                        + "SELECT "
+                        + "  DATE(il.invoice_date), "
+                        + "  '3', "
+                        + "  sl.customer_name, "
+                        + "  rp.receipt_payment_amount, "
+                        + "  '鍥炴鏀跺叆', "
+                        + "  :rpType, "
+                        + "  il.invoice_no, "
+                        + "  COALESCE(NULLIF(TRIM(rp.registrant), ''), :defReg), "
+                        + "  DATE(il.invoice_date), "
+                        + "  rp.id, "
+                        + "  1, "
+                        + "  rp.tenant_id, "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  rp.create_user, "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  rp.update_user "
+                        + "FROM receipt_payment rp "
+                        + "INNER JOIN invoice_ledger il ON il.id = rp.invoice_ledger_id "
+                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                        + "INNER JOIN sales_ledger sl ON sl.id = irp.sales_ledger_id "
+                        + "WHERE (" + tenantCondIl() + ") "
+                        + "AND (" + tenantRp("rp") + ") "
+                        + "AND NOT EXISTS ("
+                        + "  SELECT 1 FROM account_income x WHERE x.business_id = rp.id AND x.business_type = 1"
+                        + ")";
+
+        int insAiCnt = named.update(insertIncome, insAi);
+        log.warn("宸叉彃鍏ュ洖娆炬敹鍏� account_income 琛屾暟: {}", insAiCnt);
+
+        assertTrue(insRpCnt >= 0 && insAiCnt >= 0);
+    }
+
+    /**
+     * 娓呯┖<b>鍏ㄩ儴</b>鍥炴涓庡洖娆剧被鏀跺叆鍚庯紝浠呮寜寮�绁ㄥ彴璐﹂噸寤猴紙浼氬奖鍝嶆湭寮�绁ㄤ骇鍝佺殑鍥炴锛屾厧鐢級銆�
+     */
+    @Test
+    @Transactional
+    @Rollback(false)
+    void fullRebuildReceiptFromInvoiceLedger_hbtmblcSchema() {
+
+        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
+        MapSqlParameterSource insertParams = tenantParams().addValue("rpType", RECEIPT_PAYMENT_TYPE);
+
+        int deletedIncome = named.update("DELETE FROM account_income WHERE business_type = 1", new MapSqlParameterSource());
+        log.warn("fullRebuild: 宸插垹闄ゅ叏閮ㄥ洖娆剧被鏀跺叆琛屾暟: {}", deletedIncome);
+
+        int deletedReceipt = named.update("DELETE FROM receipt_payment", new MapSqlParameterSource());
+        log.warn("fullRebuild: 宸叉竻绌� receipt_payment 琛屾暟: {}", deletedReceipt);
+
+        String insertReceipt =
+                "INSERT INTO receipt_payment ("
+                        + "sales_ledger_id, sales_ledger_product_id, invoice_ledger_id, "
+                        + "receipt_payment_type, receipt_payment_amount, registrant, receipt_payment_date, "
+                        + "create_time, create_user, update_time, update_user, tenant_id) "
+                        + "SELECT "
+                        + "  CAST(irp.sales_ledger_id AS SIGNED), "
+                        + "  CAST(irp.sales_ledger_product_id AS SIGNED), "
+                        + "  il.id, "
+                        + "  :rpType, "
+                        + "  il.invoice_total, "
+                        + "  NULLIF(TRIM(il.invoice_person), ''), "
+                        + "  DATE(il.invoice_date), "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  il.create_user, "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  il.update_user, "
+                        + "  COALESCE(irp.tenant_id, il.tenant_id) "
+                        + "FROM invoice_ledger il "
+                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                        + "WHERE " + tenantCondIl();
+
+        int insertedReceipt = named.update(insertReceipt, insertParams);
+        log.warn("fullRebuild: 宸叉彃鍏� receipt_payment 琛屾暟: {}", insertedReceipt);
+
+        named.update(
+                "UPDATE receipt_payment SET registrant = :defReg WHERE registrant IS NULL OR registrant = ''",
+                new MapSqlParameterSource("defReg", DEFAULT_REGISTRANT));
+
+        insertParams.addValue("defReg", DEFAULT_REGISTRANT);
+        String insertIncome =
+                "INSERT INTO account_income ("
+                        + "income_date, income_type, customer_name, income_money, income_described, income_method, "
+                        + "invoice_number, input_user, input_time, business_id, business_type, tenant_id, "
+                        + "create_time, create_user, update_time, update_user) "
+                        + "SELECT "
+                        + "  DATE(il.invoice_date), "
+                        + "  '3', "
+                        + "  sl.customer_name, "
+                        + "  rp.receipt_payment_amount, "
+                        + "  '鍥炴鏀跺叆', "
+                        + "  :rpType, "
+                        + "  il.invoice_no, "
+                        + "  COALESCE(NULLIF(TRIM(rp.registrant), ''), :defReg), "
+                        + "  DATE(il.invoice_date), "
+                        + "  rp.id, "
+                        + "  1, "
+                        + "  rp.tenant_id, "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  rp.create_user, "
+                        + "  CAST(DATE(il.invoice_date) AS DATETIME), "
+                        + "  rp.update_user "
+                        + "FROM receipt_payment rp "
+                        + "INNER JOIN invoice_ledger il ON il.id = rp.invoice_ledger_id "
+                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                        + "INNER JOIN sales_ledger sl ON sl.id = irp.sales_ledger_id "
+                        + "WHERE " + tenantCondIl();
+
+        int insertedIncome = named.update(insertIncome, insertParams);
+        log.warn("fullRebuild: 宸叉彃鍏� account_income 琛屾暟: {}", insertedIncome);
+
+        assertTrue(insertedReceipt >= 0 && insertedIncome >= 0);
+    }
+}

--
Gitblit v1.9.3