From 9f281fb21c139b96cd2be8809e1c704782868c95 Mon Sep 17 00:00:00 2001
From: gongchunyi <deslre0381@gmail.com>
Date: 星期日, 10 五月 2026 10:38:20 +0800
Subject: [PATCH] test: 测试类提交

---
 src/test/java/com/ruoyi/sales/InvoiceLedgerPendingReceiptBatchTest.java    |  253 ++++++++++++
 src/test/java/com/ruoyi/sales/DeliveryApproveExtraCleanupTest.java         |  144 ++++++
 src/test/java/com/ruoyi/sales/ShippingLedgerOutboundAndDatesBatchTest.java |  720 ++++++++++++++++++++++++++++++++++
 src/test/java/com/ruoyi/sales/SalesProductApproveStatusSyncTest.java       |   95 ++++
 4 files changed, 1,212 insertions(+), 0 deletions(-)

diff --git a/src/test/java/com/ruoyi/sales/DeliveryApproveExtraCleanupTest.java b/src/test/java/com/ruoyi/sales/DeliveryApproveExtraCleanupTest.java
new file mode 100644
index 0000000..b3cf3fb
--- /dev/null
+++ b/src/test/java/com/ruoyi/sales/DeliveryApproveExtraCleanupTest.java
@@ -0,0 +1,144 @@
+package com.ruoyi.sales;
+
+import com.ruoyi.RuoYiApplication;
+import com.ruoyi.common.enums.ApproveTypeEnum;
+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 java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 娓呯悊澶氬嚭鏉ョ殑銆屽彂璐у鎵广�峽@code approve_process}锛坽@code approve_type=7}锛屼簨鐢� {@code 鍙戣揣瀹℃壒:鍚堝悓鍙穧锛夈��
+ * <p>
+ * 鍏稿瀷鍦烘櫙锛氶攢鍞鍗曞凡鍙戣揣鏉℃暟涓庢湁鏁堝彂璐у鎵规潯鏁板簲涓�鑷达紱鑻ユ壒閲忚剼鏈噸澶嶆彃鍏ワ紝浼氬嚭鐜板悓涓�浜嬬敱澶氭潯
+ * {@code approve_delete=0} 鐨勮褰曪紝鎴栧瓨鍦ㄥ悎鍚屽彿鍦ㄣ�屾湭鍙戣揣銆嶅彴璐︿笂浠嶆湁涓�鏉″彂璐у鎵圭殑瀛ゅ効鏁版嵁銆�
+ * <p>
+ * 澶勭悊绛栫暐锛堜笌鑻ヤ緷鍒犻櫎涔犳儻涓�鑷达紝<b>杞垹闄�</b>锛夛細
+ * <ol>
+ *   <li><b>閲嶅浜嬬敱</b>锛氬悓涓� {@code approve_reason} 涓嬩繚鐣� {@code id} 鏈�灏忕殑涓�鏉★紝鍏朵綑 {@code approve_delete=1}銆�</li>
+ *   <li><b>瀛ゅ効浜嬬敱</b>锛氫簨鐢辫В鏋愬嚭鐨勫悎鍚屽彿鍦� {@code sales_ledger} 涓笉瀛樺湪 {@code delivery_status=5}锛堝凡鍙戣揣锛夌殑璁㈠崟鏃讹紝
+ *       灏嗚鍙戣揣瀹℃壒杞垹闄ゃ�傦紙鑻ヤ綘甯屾湜浠ュ叾瀹冭鍒欏畾涔夈�屽凡鍙戣揣銆嶏紝闇�鏀� SQL銆傦級</li>
+ * </ol>
+ * 鍚屾灏嗗搴� {@code approve_node} 鐨� {@code delete_flag} 缃负 1锛堝垹闄ゆ爣璁帮級銆�
+ * <p>
+ * <b>鎵ц鍓嶅繀椤�</b>锛歏M options {@code -Druoyi.deliveryApprove.cleanup.commit=true} 鎴栫幆澧冨彉閲�
+ * {@code RUOYI_DELIVERY_APPROVE_CLEANUP_COMMIT=true}锛屼笖杩炰笟鍔″簱銆傛湭寮�鍚椂鏈敤渚嬭烦杩囥��
+ */
+@SpringBootTest(classes = RuoYiApplication.class)
+class DeliveryApproveExtraCleanupTest {
+
+    private static final Logger log = LoggerFactory.getLogger(DeliveryApproveExtraCleanupTest.class);
+
+    private static final String REASON_PREFIX = "鍙戣揣瀹℃壒:";
+
+    @Autowired
+    private DataSource dataSource;
+
+    @Test
+    @Transactional
+    @Rollback(false)
+    void softRemoveExtraDeliveryApprovals() {
+        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
+        int type7 = ApproveTypeEnum.DELIVERY.getCode();
+
+        List<ApproveRowRef> duplicateExtras = selectDuplicateDeliveryExtras(named, type7);
+        int n1 = applySoftRemove(named, duplicateExtras);
+        log.warn("[鍙戣揣瀹℃壒娓呯悊] 閲嶅浜嬬敱杞垹闄よ鏁�={}, id鍒楄〃={}", n1, formatIds(duplicateExtras));
+
+        List<ApproveRowRef> orphans = selectOrphanDeliveryApprovals(named, type7);
+        int n2 = applySoftRemove(named, orphans);
+        log.warn("[鍙戣揣瀹℃壒娓呯悊] 瀛ゅ効浜嬬敱杞垹闄よ鏁�={}, id鍒楄〃={}", n2, formatIds(orphans));
+
+        log.warn("[鍙戣揣瀹℃壒娓呯悊] 瀹屾垚銆傞噸澶�={}, 瀛ゅ効={}, 鍚堣杞垹闄�={}", n1, n2, n1 + n2);
+    }
+
+    private static String formatIds(List<ApproveRowRef> refs) {
+        return refs.stream().map(r -> String.valueOf(r.id)).collect(Collectors.joining(","));
+    }
+
+    private List<ApproveRowRef> selectDuplicateDeliveryExtras(NamedParameterJdbcTemplate named, int approveType) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("atype", approveType);
+        p.addValue("pfx", REASON_PREFIX + "%");
+        return named.query(
+                "SELECT ap.id, ap.approve_id AS approveId FROM approve_process ap "
+                        + "INNER JOIN ( "
+                        + "  SELECT approve_reason, MIN(id) AS keep_id "
+                        + "  FROM approve_process "
+                        + "  WHERE approve_delete = 0 AND approve_type = :atype AND approve_reason LIKE :pfx "
+                        + "  GROUP BY approve_reason "
+                        + "  HAVING COUNT(*) > 1 "
+                        + ") d ON ap.approve_reason = d.approve_reason AND ap.id <> d.keep_id "
+                        + "WHERE ap.approve_delete = 0 AND ap.approve_type = :atype",
+                p,
+                (rs, i) -> new ApproveRowRef(rs.getLong("id"), rs.getString("approveId")));
+    }
+
+    /**
+     * 浜嬬敱涓悎鍚屽彿鍦ㄣ�屽凡鍙戣揣銆嶉攢鍞彴璐︿腑涓嶅瓨鍦ㄥ垯瑙嗕负瀛ゅ効
+     */
+    private List<ApproveRowRef> selectOrphanDeliveryApprovals(NamedParameterJdbcTemplate named, int approveType) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("atype", approveType);
+        p.addValue("pfx", REASON_PREFIX + "%");
+        return named.query(
+                "SELECT ap.id, ap.approve_id AS approveId FROM approve_process ap "
+                        + "WHERE ap.approve_delete = 0 AND ap.approve_type = :atype AND ap.approve_reason LIKE :pfx "
+                        + "AND NOT EXISTS ( "
+                        + "  SELECT 1 FROM sales_ledger sl "
+                        + "  WHERE sl.sales_contract_no = TRIM(SUBSTRING(ap.approve_reason, CHAR_LENGTH('鍙戣揣瀹℃壒:') + 1)) "
+                        + "    AND sl.delivery_status = 5 "
+                        + ")",
+                p,
+                (rs, i) -> new ApproveRowRef(rs.getLong("id"), rs.getString("approveId")));
+    }
+
+    private int applySoftRemove(NamedParameterJdbcTemplate named, List<ApproveRowRef> refs) {
+        if (refs.isEmpty()) {
+            return 0;
+        }
+        Set<Long> idSet = new LinkedHashSet<>();
+        Set<String> approveIdSet = new LinkedHashSet<>();
+        for (ApproveRowRef r : refs) {
+            idSet.add(r.id);
+            if (r.approveId != null) {
+                approveIdSet.add(r.approveId);
+            }
+        }
+        List<Long> ids = new ArrayList<>(idSet);
+        List<String> approveIds = new ArrayList<>(approveIdSet);
+
+        MapSqlParameterSource p1 = new MapSqlParameterSource();
+        p1.addValue("ids", ids);
+        int u1 = named.update("UPDATE approve_process SET approve_delete = 1 WHERE id IN (:ids)", p1);
+
+        MapSqlParameterSource p2 = new MapSqlParameterSource();
+        p2.addValue("aids", approveIds);
+        int u2 = named.update("UPDATE approve_node SET delete_flag = 1 WHERE delete_flag = 0 AND approve_process_id IN (:aids)", p2);
+
+        log.debug("approve_process 鏇存柊琛屾暟={}, approve_node 鏇存柊琛屾暟={}", u1, u2);
+        return u1;
+    }
+
+    private static final class ApproveRowRef {
+        private final long id;
+        private final String approveId;
+
+        private ApproveRowRef(long id, String approveId) {
+            this.id = id;
+            this.approveId = approveId;
+        }
+    }
+}
diff --git a/src/test/java/com/ruoyi/sales/InvoiceLedgerPendingReceiptBatchTest.java b/src/test/java/com/ruoyi/sales/InvoiceLedgerPendingReceiptBatchTest.java
new file mode 100644
index 0000000..e7b5d65
--- /dev/null
+++ b/src/test/java/com/ruoyi/sales/InvoiceLedgerPendingReceiptBatchTest.java
@@ -0,0 +1,253 @@
+package com.ruoyi.sales;
+
+import com.ruoyi.RuoYiApplication;
+import com.ruoyi.account.pojo.AccountIncome;
+import com.ruoyi.account.service.AccountIncomeService;
+import com.ruoyi.sales.mapper.ReceiptPaymentMapper;
+import com.ruoyi.sales.mapper.SalesLedgerMapper;
+import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
+import com.ruoyi.sales.pojo.ReceiptPayment;
+import com.ruoyi.sales.pojo.SalesLedger;
+import com.ruoyi.sales.pojo.SalesLedgerProduct;
+import com.ruoyi.sales.service.impl.ReceiptPaymentServiceImpl;
+import lombok.Getter;
+import lombok.Setter;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.BeanPropertyRowMapper;
+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 java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 鍥炴鎵归噺缁撴竻 鈥斺�� 渚濇嵁搴撹剼鏈� {@code product-inventory-management-hbtmblc.sql} 涓〃缁撴瀯涓庣湡瀹� INSERT 鏍锋湰缂栧啓銆�
+ * <p>
+ * <b>琛ㄧ粨鏋勬憳瑕侊紙涓庤剼鏈竴鑷达級</b>
+ * <ul>
+ *   <li>{@code receipt_payment}锛歿@code sales_ledger_id}銆亄@code sales_ledger_product_id} 涓� {@code int}锛�
+ *       {@code invoice_ledger_id} 涓� {@code bigint}锛泏@code receipt_payment_type} 涓� {@code char(1)}锛�0/1锛夛紱
+ *       {@code receipt_payment_amount} {@code decimal(10,2)}锛泏@code receipt_payment_date} 涓� {@code datetime}銆�</li>
+ *   <li>鑴氭湰涓洖娆炬牱鏈細{@code invoice_ledger_id} 涓� {@code sales_ledger_product_id} 鍙栧�肩浉鍚岋紙渚嬪 2158锛夛紝
+ *       涓庡洖娆剧櫥璁伴〉鎶婂垪琛ㄨ涓婚敭褰撲綔 {@code invoiceLedgerId} 鎻愪氦鐨勮涓轰竴鑷淬��</li>
+ *   <li>{@code sales_ledger_product}锛氬緟鍥炴/宸插洖娆惧垪涓� {@code pending_invoice_total}銆亄@code invoice_total}锛堟敞閲婁负鍥炴鎬婚噾棰濓級锛�
+ *       浜у搧琛屾棤 {@code tenant_id}锛岀鎴峰湪 {@code sales_ledger.tenant_id}銆�</li>
+ *   <li>{@code account_income}锛歿@code business_id} 涓� {@code int}锛屼笌 {@code receipt_payment.id} 瀵瑰簲锛�
+ *       {@code income_type='3'}銆亄@code income_described='鍥炴鏀跺叆'}銆亄@code business_type=1} 涓庡簱涓洖娆炬牱鏈竴鑷淬��</li>
+ *   <li>{@code registrant} / {@code input_user}锛氫笌鑴氭湰閲� {@code receipt_payment} 鐧昏浜哄瓧娈典竴鑷达紙鏍锋湰涓恒�屾▕蹇楄嫳銆嶏級銆�</li>
+ * </ul>
+ * <p>
+ * <b>鍒楄〃椤�</b>锛歿@code GET /sales/product/listPageSalesLedger} 鈫� {@code SalesLedgerProductMapper.listPage}锛�
+ * 绛涢�夈�屽緟鍥炴&gt;0銆嶅嵆 {@code pending_invoice_total &gt; 0}銆�
+ * <p>
+ * <b>鍗曠瑪淇濆瓨</b>锛歿@link ReceiptPaymentServiceImpl#receiptPaymentSaveOrUpdate(java.util.List)}锛堟湰娴嬭瘯澶嶇敤鍏舵洿鏂伴�昏緫涓庢敹鍏ュ瓧娈靛惈涔夛級銆�
+ * <p>
+ * <b>寮�绁ㄥ彴璐︽牎楠�</b>锛氱粨娓呭墠鏍规嵁 {@code invoice_ledger} + {@code invoice_registration_product} 姹囨�诲紑绁ㄩ噾棰濓紝
+ * 涓庡凡鏈� {@code receipt_payment} 姣斿锛涜嫢寮�绁ㄤ晶宸插叏閮ㄨ鐩栵紙鎸変骇鍝佹眹鎬诲洖娆炬垨鎸夌湡瀹炲紑绁ㄥ彴璐� id 姹囨�诲洖娆撅級锛屽垯璺宠繃璇ヤ骇鍝侊紝閬垮厤閲嶅缁撴竻銆�
+ * <p>
+ * 鎵ц鍓嶅幓鎺� {@link Disabled}锛涢粯璁� {@link Rollback}锛岃惤搴撴椂鏀逛负 {@code @Rollback(false)}銆�
+ */
+@SpringBootTest(classes = RuoYiApplication.class)
+//@Disabled("杩炴帴 hbtmblc 搴撳苟閰嶇疆 spring.profiles 鍚庡幓鎺夋湰娉ㄨВ鍐嶈繍琛�")
+class InvoiceLedgerPendingReceiptBatchTest {
+
+    /**
+     * 瀵瑰簲 {@code sales_ledger.tenant_id}锛沶ull 琛ㄧず涓嶆寜绉熸埛杩囨护銆�
+     */
+    private static final Long TENANT_ID = null;
+
+    /**
+     * 浠呭鐞嗚繖浜� {@code sales_ledger_product.id}锛堜笌鍒楄〃琛屼富閿竴鑷达級锛涚┖琛ㄧず鍏ㄩ儴寰呭洖娆句骇鍝佽銆�
+     */
+    private static final List<Integer> ONLY_SALES_LEDGER_PRODUCT_IDS = Collections.emptyList();
+
+    /**
+     * 涓� {@code product-inventory-management-hbtmblc.sql} 涓� {@code receipt_payment} 鏍锋湰鐨� {@code registrant} 涓�鑷达紙濡� id=286 涓�琛岋細妯婂織鑻憋級銆�
+     * 鑻ュ鍏ュ簱涓櫥璁颁汉涓嶅悓锛岃鏀规垚涓庝綘搴� {@code receipt_payment.registrant} 瀹為檯鍙栧�肩浉鍚屻��
+     */
+    private static final String REGISTRANT = "妯婂織鑻�";
+    private static final String RECEIPT_PAYMENT_TYPE = "0";
+    private static final LocalDate RECEIPT_PAYMENT_DATE = LocalDate.now();
+
+    /**
+     * 鏄惁鍦ㄧ粨娓呭墠鏍¢獙銆屽紑绁ㄥ彴璐︽槸鍚﹀凡鍥炴瀹屾瘯銆嶃�傚叧闂垯浠呯湅 {@code pending_invoice_total}銆�
+     */
+    private static final boolean VERIFY_INVOICE_LEDGER_PAID = true;
+
+    /**
+     * 寮�绁ㄩ噾棰濅笌宸插洖娆炬瘮杈冩椂鐨勫宸紙鍏冿級
+     */
+    private static final BigDecimal INVOICE_PAY_TOLERANCE = new BigDecimal("0.02");
+
+    /**
+     * 涓� listPage 缁村害涓�鑷达細寰呭洖娆� = {@code pending_invoice_total}銆�
+     */
+    private static final String PENDING_BY_PRODUCT_SQL =
+            "SELECT slp.id AS salesLedgerProductId, "
+                    + "slp.sales_ledger_id AS salesLedgerId, "
+                    + "sl.tenant_id AS tenantId "
+                    + "FROM sales_ledger_product slp "
+                    + "INNER JOIN sales_ledger sl ON sl.id = slp.sales_ledger_id "
+                    + "WHERE slp.type = 1 "
+                    + "AND IFNULL(slp.pending_invoice_total, 0) > 0 ";
+
+    @Autowired
+    private DataSource dataSource;
+
+    @Autowired
+    private ReceiptPaymentMapper receiptPaymentMapper;
+
+    @Autowired
+    private SalesLedgerMapper salesLedgerMapper;
+
+    @Autowired
+    private SalesLedgerProductMapper salesLedgerProductMapper;
+
+    @Autowired
+    private AccountIncomeService accountIncomeService;
+
+    @Test
+    @Transactional
+    void batchCompletePendingReceipts_hbtmblcSchema() {
+        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
+        StringBuilder sql = new StringBuilder(PENDING_BY_PRODUCT_SQL);
+        MapSqlParameterSource params = new MapSqlParameterSource();
+        if (!ONLY_SALES_LEDGER_PRODUCT_IDS.isEmpty()) {
+            sql.append(" AND slp.id IN (:productIds) ");
+            params.addValue("productIds", ONLY_SALES_LEDGER_PRODUCT_IDS);
+        }
+        sql.append(" ORDER BY slp.id ASC ");
+
+        List<ProductPendingRow> rows = named.query(sql.toString(), params,
+                new BeanPropertyRowMapper<>(ProductPendingRow.class));
+
+        int done = 0;
+        for (ProductPendingRow row : rows) {
+            SalesLedger salesLedger = salesLedgerMapper.selectById(row.getSalesLedgerId().longValue());
+            if (salesLedger == null) {
+                throw new IllegalStateException("sales_ledger_product.id=" + row.getSalesLedgerProductId() + " 鍏宠仈 sales_ledger 涓嶅瓨鍦�");
+            }
+            SalesLedgerProduct product = salesLedgerProductMapper.selectById(row.getSalesLedgerProductId().longValue());
+            if (product == null) {
+                throw new IllegalStateException("sales_ledger_product.id=" + row.getSalesLedgerProductId() + " 涓嶅瓨鍦�");
+            }
+
+            BigDecimal remaining = product.getPendingInvoiceTotal() == null ? BigDecimal.ZERO : product.getPendingInvoiceTotal();
+            if (remaining.compareTo(BigDecimal.ZERO) <= 0) {
+                continue;
+            }
+            remaining = remaining.setScale(2, RoundingMode.HALF_UP);
+
+            if (VERIFY_INVOICE_LEDGER_PAID && isInvoiceLedgerSideFullyReceived(named, row.getSalesLedgerProductId())) {
+                continue;
+            }
+
+            BigDecimal invoicedTotal = product.getInvoiceTotal() == null ? BigDecimal.ZERO : product.getInvoiceTotal();
+            BigDecimal taxTotal = product.getTaxInclusiveTotalPrice() == null ? BigDecimal.ZERO : product.getTaxInclusiveTotalPrice();
+            product.setInvoiceTotal(invoicedTotal.add(remaining));
+            product.setPendingInvoiceTotal(taxTotal.subtract(product.getInvoiceTotal()));
+
+            ReceiptPayment rp = new ReceiptPayment();
+            rp.setReceiptPaymentType(RECEIPT_PAYMENT_TYPE);
+            rp.setReceiptPaymentAmount(remaining);
+            rp.setRegistrant(REGISTRANT);
+            rp.setInvoiceLedgerId(row.getSalesLedgerProductId());
+            rp.setSalesLedgerId(row.getSalesLedgerId().longValue());
+            rp.setSalesLedgerProductId(row.getSalesLedgerProductId().longValue());
+            rp.setReceiptPaymentDate(RECEIPT_PAYMENT_DATE);
+            rp.setTenantId(row.getTenantId());
+            receiptPaymentMapper.insert(rp);
+
+            AccountIncome income = new AccountIncome();
+            income.setIncomeDate(salesLedger.getEntryDate());
+            income.setIncomeType("3");
+            income.setCustomerName(salesLedger.getCustomerName());
+            income.setIncomeMoney(remaining);
+            income.setIncomeDescribed("鍥炴鏀跺叆");
+            income.setIncomeMethod(RECEIPT_PAYMENT_TYPE);
+            income.setInputTime(new Date());
+            income.setInputUser(REGISTRANT);
+            if (rp.getId() != null) {
+                income.setBusinessId(rp.getId().longValue());
+            }
+            income.setBusinessType(1);
+            income.setTenantId(row.getTenantId());
+            accountIncomeService.save(income);
+
+            salesLedgerProductMapper.updateById(product);
+            done++;
+        }
+        assertTrue(done >= 0, "鏂板鍥炴绗旀暟: " + done);
+    }
+
+    /**
+     * 鍒ゆ柇鏈骇鍝佸叧鑱旂殑銆屽紑绁ㄥ彴璐︺�嶆槸鍚﹀凡鍦ㄥ洖娆句晶缁撴竻锛堟棤闇�鍐嶆彃鍏ュ洖娆撅級銆�
+     * <ul>
+     *   <li>鏃犲紑绁ㄨ褰曪紙危 invoice_total 涓� 0锛夛細杩斿洖 false锛屼笉鎹鎷︽埅銆�</li>
+     *   <li>鏈夊紑绁細鑻� {@code receipt_payment} 鎸� {@code sales_ledger_product_id} 姹囨�诲凡 鈮� 寮�绁ㄦ�婚锛岃涓哄凡鍥炴锛堝吋瀹� hbtmblc 灏� invoice_ledger_id 瀛樻垚浜у搧 id 鐨勫啓娉曪級銆�</li>
+     *   <li>鍚﹀垯锛氳嫢姣忔潯 {@code invoice_ledger} 鎸夌湡瀹� {@code invoice_ledger_id} 姹囨�荤殑宸插洖娆惧潎 鈮� 璇ヨ鍙戠エ閲戦锛屼篃瑙嗕负宸插洖娆俱��</li>
+     * </ul>
+     */
+    private boolean isInvoiceLedgerSideFullyReceived(NamedParameterJdbcTemplate named, int salesLedgerProductId) {
+        MapSqlParameterSource p = new MapSqlParameterSource("pid", salesLedgerProductId);
+
+        BigDecimal invoicedSum = named.queryForObject(
+                "SELECT COALESCE(SUM(il.invoice_total), 0) FROM invoice_ledger il "
+                        + "INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                        + "WHERE irp.sales_ledger_product_id = :pid",
+                p, BigDecimal.class);
+        if (invoicedSum == null) {
+            invoicedSum = BigDecimal.ZERO;
+        }
+        invoicedSum = invoicedSum.setScale(2, RoundingMode.HALF_UP);
+        if (invoicedSum.compareTo(BigDecimal.ZERO) <= 0) {
+            return false;
+        }
+
+        BigDecimal paidByProduct = named.queryForObject(
+                "SELECT COALESCE(SUM(receipt_payment_amount), 0) FROM receipt_payment WHERE sales_ledger_product_id = :pid",
+                p, BigDecimal.class);
+        if (paidByProduct == null) {
+            paidByProduct = BigDecimal.ZERO;
+        }
+        paidByProduct = paidByProduct.setScale(2, RoundingMode.HALF_UP);
+        if (paidByProduct.compareTo(invoicedSum.subtract(INVOICE_PAY_TOLERANCE)) >= 0) {
+            return true;
+        }
+
+        Integer unpaidLedgerLineCount = named.queryForObject(
+                "SELECT COUNT(1) FROM ("
+                        + " SELECT 1 FROM invoice_ledger il "
+                        + " INNER JOIN invoice_registration_product irp ON irp.id = il.invoice_registration_product_id "
+                        + " LEFT JOIN receipt_payment rp ON rp.invoice_ledger_id = il.id "
+                        + " WHERE irp.sales_ledger_product_id = :pid "
+                        + " GROUP BY il.id, il.invoice_total "
+                        + " HAVING (IFNULL(il.invoice_total, 0) - IFNULL(SUM(rp.receipt_payment_amount), 0)) > :tol"
+                        + ") t",
+                p.addValue("tol", INVOICE_PAY_TOLERANCE), Integer.class);
+
+        return unpaidLedgerLineCount != null && unpaidLedgerLineCount == 0;
+    }
+
+    @Setter
+    @Getter
+    @SuppressWarnings("unused")
+    public static class ProductPendingRow {
+        private Integer salesLedgerProductId;
+        private Integer salesLedgerId;
+        private Long tenantId;
+
+    }
+}
diff --git a/src/test/java/com/ruoyi/sales/SalesProductApproveStatusSyncTest.java b/src/test/java/com/ruoyi/sales/SalesProductApproveStatusSyncTest.java
new file mode 100644
index 0000000..1184ba6
--- /dev/null
+++ b/src/test/java/com/ruoyi/sales/SalesProductApproveStatusSyncTest.java
@@ -0,0 +1,95 @@
+package com.ruoyi.sales;
+
+import com.ruoyi.RuoYiApplication;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+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 java.util.Collections;
+import java.util.List;
+
+/**
+ * 鏁版嵁淇锛氬綋閿�鍞鍗曚富琛ㄥ彂璐х姸鎬佷负銆屽凡鍙戣揣銆嶆椂锛屽皢涓嬪睘閿�鍞骇鍝佽鐨�<b>浜у搧鍑哄簱瀹℃壒鐘舵��</b>
+ * {@code sales_ledger_product.approve_status} 缁熶竴涓�<b>宸插嚭搴�</b>銆�
+ * <p>
+ * 涓� {@code product-inventory-management-hbtmblc.sql} 涓瓧娈垫敞閲婁竴鑷达細
+ * {@code sales_ledger.delivery_status} {@code tinyint(1) NOT NULL DEFAULT '0'}锛�
+ * 鍙戣揣鐘舵�侊細1-鏈彂璐э紝2-瀹℃壒涓紝3-瀹℃壒涓嶉�氳繃锛�4-瀹℃壒閫氳繃锛�5-宸插彂璐с�傛湰浠诲姟浠呭湪 {@code 5-宸插彂璐 鏃跺悓姝ヤ骇鍝佽銆�
+ * {@code sales_ledger_product.approve_status}锛�0-鏈嚭搴擄紝1-宸插嚭搴擄紝2-寰呭鏍革紝3-瀹℃牳瀹屾垚锛�4-瀹℃牳澶辫触銆�
+ * <p>
+ * 浠呭鐞� {@code type = 1} 鐨勯攢鍞骇鍝佽锛涗粎鏇存柊褰撳墠 {@code approve_status} 涓嶆槸 {@code 1}锛堝凡鍑哄簱锛夌殑琛岋紝閬垮厤鏃犳剰涔夊啓搴撱��
+ * <p>
+ * <b>鎵ц鍓嶅繀椤�</b>锛歿@code -Druoyi.salesProductApproveStatus.sync.commit=true} 鎴栫幆澧冨彉閲�
+ * {@code RUOYI_SALES_PRODUCT_APPROVE_STATUS_SYNC_COMMIT=true}銆傛湭寮�鍚椂鏈敤渚嬭烦杩囥��
+ */
+@SpringBootTest(classes = RuoYiApplication.class)
+class SalesProductApproveStatusSyncTest {
+
+    private static final Logger log = LoggerFactory.getLogger(SalesProductApproveStatusSyncTest.class);
+
+    /**
+     * 涓庡簱娉ㄩ噴涓�鑷达細1 = 宸插嚭搴�
+     */
+    private static final int APPROVE_STATUS_SHIPPED_OUT = 1;
+
+    /**
+     * 瑙嗕负銆岃鍗曞凡鍙戣揣銆侀渶鍚屾浜у搧鐘舵�併�嶇殑涓昏〃 {@code delivery_status}锛涢粯璁や粎 {@code 5}锛堝凡鍙戣揣锛夈��
+     */
+    private static final List<Integer> DELIVERY_STATUS_TRIGGER_SYNC = Collections.singletonList(5);
+
+    /**
+     * 浠呭悓姝ヨ繖浜涢攢鍞彴璐︿富閿紱绌鸿〃绀哄叏閮ㄧ鍚堟潯浠剁殑璁㈠崟銆�
+     */
+    private static final List<Long> ONLY_SALES_LEDGER_IDS = Collections.emptyList();
+
+    @SuppressWarnings("unused")
+    static boolean syncCommitEnabled() {
+        String p = System.getProperty("ruoyi.salesProductApproveStatus.sync.commit");
+        if (p != null && "true".equalsIgnoreCase(p.trim())) {
+            return true;
+        }
+        String e = System.getenv("RUOYI_SALES_PRODUCT_APPROVE_STATUS_SYNC_COMMIT");
+        return e != null && "true".equalsIgnoreCase(e.trim());
+    }
+
+    @Autowired
+    private DataSource dataSource;
+
+    @Test
+    @EnabledIf("com.ruoyi.sales.SalesProductApproveStatusSyncTest#syncCommitEnabled")
+    @Transactional
+    @Rollback(false)
+    void syncSalesProductApproveStatusWhenOrderShipped() {
+        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
+
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("outStatus", APPROVE_STATUS_SHIPPED_OUT);
+        p.addValue("dstats", DELIVERY_STATUS_TRIGGER_SYNC);
+        p.addValue("ptype", 1);
+
+        StringBuilder sql = new StringBuilder(
+                "UPDATE sales_ledger_product slp "
+                        + "INNER JOIN sales_ledger sl ON slp.sales_ledger_id = sl.id "
+                        + "SET slp.approve_status = :outStatus "
+                        + "WHERE sl.delivery_status IN (:dstats) "
+                        + "AND slp.type = :ptype "
+                        + "AND (slp.approve_status IS NULL OR slp.approve_status <> :outStatus) ");
+        if (!ONLY_SALES_LEDGER_IDS.isEmpty()) {
+            sql.append("AND sl.id IN (:slids) ");
+            p.addValue("slids", ONLY_SALES_LEDGER_IDS);
+        }
+
+        int updated = named.update(sql.toString(), p);
+        log.warn(
+                "[閿�鍞骇鍝� approve_status 鍚屾] 宸插彂璐ц鍗� delivery_status in {}锛屽皢閿�鍞骇鍝� approve_status 缃负 {}锛堝凡鍑哄簱锛夛紝鏇存柊琛屾暟={}",
+                DELIVERY_STATUS_TRIGGER_SYNC, APPROVE_STATUS_SHIPPED_OUT, updated);
+    }
+}
diff --git a/src/test/java/com/ruoyi/sales/ShippingLedgerOutboundAndDatesBatchTest.java b/src/test/java/com/ruoyi/sales/ShippingLedgerOutboundAndDatesBatchTest.java
new file mode 100644
index 0000000..1be59d7
--- /dev/null
+++ b/src/test/java/com/ruoyi/sales/ShippingLedgerOutboundAndDatesBatchTest.java
@@ -0,0 +1,720 @@
+package com.ruoyi.sales;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.RuoYiApplication;
+import com.ruoyi.common.enums.ApproveTypeEnum;
+import com.ruoyi.common.enums.StockOutQualifiedRecordTypeEnum;
+import com.ruoyi.sales.mapper.SalesLedgerMapper;
+import com.ruoyi.sales.mapper.SalesLedgerProductMapper;
+import com.ruoyi.sales.mapper.ShippingInfoMapper;
+import com.ruoyi.sales.pojo.SalesLedger;
+import com.ruoyi.sales.pojo.SalesLedgerProduct;
+import com.ruoyi.sales.pojo.ShippingInfo;
+import org.apache.commons.collections4.CollectionUtils;
+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 java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 鍙戣揣鍙拌处鎵归噺鍑哄簱涓庢棩鏈熷榻愶紙娴嬭瘯/鏁版嵁淇鐢級銆�
+ * <p>
+ * 琛ㄧ粨鏋勪互 {@code product-inventory-management-hbtmblc.sql} 涓哄噯锛屼緥濡傦細
+ * {@code shipping_info.shipping_date} 涓� {@code datetime}锛�
+ * {@code approve_process.approve_time}/{@code approve_over_time} 涓� {@code datetime}锛�
+ * {@code approve_process.start_date}/{@code end_date} 涓� {@code date}锛�
+ * 鍙戣揣瀹℃壒鏍锋湰浜嬬敱涓� {@code 鍙戣揣瀹℃壒:} + 閿�鍞悎鍚屽彿锛堝 {@code 鍙戣揣瀹℃壒:D20260413001}锛岃鑴氭湰绾� L1988锛夈��
+ * {@code stock_out_record.record_type='13'}銆亄@code record_id} 瀵瑰簲 {@code shipping_info.id}锛堣鑴氭湰绾� L28306 璧凤級銆�
+ * <p>鏈簱 {@code product-inventory-management-hbtmblc.sql} 涓� {@code sys_user} 鏃� {@code dept_id} 瀛楁锛岄儴闂ㄩ�氳繃
+ * {@code sys_user_dept} 鍏宠仈 {@code sys_dept}锛岃ˉ鍏ㄥ鎵规椂鎸夋缁撴瀯瑙f瀽鐢宠浜洪儴闂ㄣ��
+ * <p><b>鍑哄簱鍙拌处涓轰綍鎼滀笉鍒板悎鍚屽彿</b>锛氬垪琛� SQL 鐢� {@code sales_ledger.sales_contract_no} 杩囨护鏃讹紝渚濊禆
+ * {@code stock_out_record.sales_ledger_id}銆亄@code sales_ledger_product_id} 鍏宠仈閿�鍞彴璐︼紙瑙� {@code StockOutRecordMapper.xml}锛夈��
+ * 鑻ユ湭鎻掑叆璁板綍鎴栨彃鍏ユ椂鏈甫杩欎袱鍒楋紝鍒欏悎鏍煎嚭搴撳垪琛ㄤ负绌恒�傛湰绫�<b>涓嶄慨鏀瑰簱瀛樿〃</b>锛屼粎鍚� {@code stock_out_record} <b>INSERT</b> 涓�鏉°�岄攢鍞�-鍙戣揣鍑哄簱銆嶈褰曞苟鍐欏叆涓婅堪澶栭敭銆�
+ * <p>
+ * 閫昏緫姒傝锛�
+ * <ul>
+ *   <li>閬嶅巻 {@code shipping_info}锛涜嫢 {@code sales_ledger.delivery_date} 涓虹┖鍒欒烦杩囥��</li>
+ *   <li>鑻ヨ琛屽凡瀛樺湪 {@code record_type = 13} 涓� {@code record_id = shipping_info.id} 鐨勫嚭搴撹褰曪細涓嶅啀鎻掑叆锛屼粎瀵归綈鏃ユ湡銆�</li>
+ *   <li>鍚﹀垯鍦ㄦ弧瓒充骇鍝佸凡鍏ュ簱绛夋潯浠舵椂锛屾寜鍑哄簱鏁伴噺瑙f瀽瑙勫垯鍐欏叆涓�鏉″嚭搴撹褰曪紙{@code record_type=13}锛寋@code record_id=shipping_info.id}锛夛紝
+ *       骞跺洖鍐欎骇鍝� {@code shipped_quantity}锛�<b>涓嶈皟鐢�</b>搴撳瓨鎵e噺閫昏緫銆�</li>
+ *   <li><b>鏃ユ湡鍩哄噯锛堝敮涓�锛�</b>锛氫竴寰嬪彇鍏宠仈閿�鍞鍗� {@code sales_ledger.delivery_date}锛堜氦浠樻棩鏈燂級鐨�<strong>鏃ュ巻鏃ュ墠涓�澶�</strong>锛�
+ *       鏃跺埢缁熶竴涓哄綋鏃� 00:00:00銆備緥濡備氦浠樻棩 2026-04-10 鈫� 鍙戣揣鏃ユ湡銆佸嚭搴撴棩鏈熴�佸叆搴撹褰曟椂闂淬�佸鎵圭敵璇�/缁撴潫鏃ユ湡绛夊潎瀵归綈鍒� 2026-04-09 00:00:00锛堝悎鍚� {@code D260403007} 鍚岀被鍦烘櫙锛夈��</li>
+ *   <li><b>瀵归綈椤哄簭</b>锛氣憼 鍏堟敼鍙戣揣鍙拌处 {@code shipping_info.shipping_date}锛堝強 {@code update_time}锛夛紱鈶� 鍐嶆敼璇ヤ骇鍝佽鐨勫叆搴�/鍑哄簱璁板綍鏃堕棿锛�
+ *       鈶� {@code shipment_approval}锛涒懀 {@code approve_process} 涓笌璇ラ攢鍞悎鍚岀浉鍏崇殑<b>鍙戣揣瀹℃壒</b>锛坽@code approve_type=7}锛変笌<b>閿�鍞叆搴撳鎵�</b>锛坽@code approve_type=9}锛屽惈 Web 鍏ュ簱涓庢壂鐮佸悎鏍�/涓嶅悎鏍煎叆搴撲簨鐢卞墠缂�锛夈�傛彃鍏ュ嚭搴撹褰曞湪鈶犱箣鍚庛��</li>
+ *   <li><b>涓绘祦绋嬪墠銆岃ˉ鍏ㄣ��</b>锛氬銆屽凡鍏ュ簱涓斿凡鍙戣揣銆嶇殑閿�鍞骇鍝佽锛堣 {@link #isProductStockedForSync} / {@link #isProductShippedForSync}锛夛紝鑻ョ己灏�
+ *       {@code approve_process} 鍙戣揣瀹℃壒锛堜簨鐢� {@code 鍙戣揣瀹℃壒:鍚堝悓鍙穧锛夋垨缂哄皯浠讳竴褰㈠紡閿�鍞叆搴撳鎵癸紝鍒欐彃鍏�<b>宸查�氳繃</b>锛坽@code approve_status=2}锛夎褰曞強涓�鏉″悓鎰忕殑 {@code approve_node}锛�
+ *       鑻ヨ浜у搧琛屽皻鏃� {@code shipping_info}锛屽垯鎻掑叆涓�鏉$姸鎬佷负銆屽凡鍙戣揣銆嶇殑鍙戣揣鍙拌处锛堜笌涓氬姟銆屼竴琛屼骇鍝佷竴鏉″彂璐ц褰曘�嶅榻愶級銆�</li>
+ * </ul>
+ * <p><b>鐣岄潰浠嶆悳涓嶅埌鍑哄簱鐨勫父瑙佸師鍥�</b>锛�
+ * <ul>
+ *   <li>鏈墦寮�鎻愪氦寮�鍏筹紙瑙佷笅鏂广�屽浣曠湡姝e啓搴撱�嶏級鏃舵湰鐢ㄤ緥涓嶄細鎵ц锛屾暟鎹簱涓嶄細鏈変换浣曞彉鏇淬��</li>
+ *   <li>鍔犱簡鍙傛暟浣� Maven/IDE 杩炵殑涓嶆槸娴忚鍣ㄥ悓涓�濂楁暟鎹簮锛岀晫闈粛鏄剧ず鏃ф暟鎹��</li>
+ *   <li>浜у搧琛� {@code product_stock_status != 2}锛堟湭鏍囪鍏ㄩ儴鍏ュ簱锛夋椂鍘熼�昏緫浼� {@code continue}锛屽凡鐢� {@link #RELAX_PRODUCT_STOCK_STATUS_CHECK} 鏀惧銆�</li>
+ *   <li>瑙f瀽鍑虹殑鍑哄簱鏁伴噺涓� 0 鏃跺師鍏堝彧鏀规棩鏈熶笉 INSERT鈥斺�斿凡瀵广�屽彂璐х姸鎬�=宸插彂璐с�嶅厹搴曚粛鎻掑叆涓�鏉°��</li>
+ *   <li>鍚堟牸鍑哄簱鍒楄〃浼氬甫 {@code type=0}锛屾彃鍏ユ椂宸插啓 {@code type='0'}銆�</li>
+ * </ul>
+ * <p><b>濡備綍鐪熸鍐欏簱</b>锛堟弧瓒冲叾涓�鍗冲彲锛夛細
+ * <ul>
+ *   <li>IDE锛歊un Configuration 鈫� VM options 澧炲姞 {@code -Druoyi.shippingBatch.commit=true}</li>
+ *   <li>鐜鍙橀噺锛歿@code RUOYI_SHIPPING_BATCH_COMMIT=true}锛圵indows 鍙湪杩愯鍓� {@code set RUOYI_SHIPPING_BATCH_COMMIT=true}锛�</li>
+ *   <li>Maven锛歿@code mvn test -Dtest=ShippingLedgerOutboundAndDatesBatchTest "-DargLine=-Druoyi.shippingBatch.commit=true"}</li>
+ * </ul>
+ * 骞剁‘璁� profile 鎸囧悜涓庢祻瑙堝櫒鐩稿悓鐨勪笟鍔″簱銆�
+ */
+@SpringBootTest(classes = RuoYiApplication.class)
+class ShippingLedgerOutboundAndDatesBatchTest {
+
+    private static final Logger log = LoggerFactory.getLogger(ShippingLedgerOutboundAndDatesBatchTest.class);
+
+    private static final int SALE_PRODUCT_TYPE = 1;
+    private static final String SHIP_DONE = "宸插彂璐�";
+    private static final int DELIVERY_APPROVE_TYPE = ApproveTypeEnum.DELIVERY.getCode();
+    private static final int STOCK_IN_APPROVE_TYPE = ApproveTypeEnum.STOCK_IN.getCode();
+    /**
+     * 涓� {@link com.ruoyi.approve.pojo.ApproveProcess} 娉ㄩ噴涓�鑷达細2=瀹℃牳瀹屾垚锛堥�氳繃锛夈��
+     */
+    private static final int APPROVE_STATUS_COMPLETED = 2;
+    /**
+     * 瀹℃壒鑺傜偣锛�1=鍚屾剰锛堣 {@code approve_node} 鏍蜂緥鏁版嵁锛夈��
+     */
+    private static final int APPROVE_NODE_STATUS_AGREE = 1;
+    private static final long FALLBACK_SYS_USER_ID = 1L;
+
+    /**
+     * 浠呭鐞嗚繖浜� {@code shipping_info.id}锛涚┖琛ㄧず鍏ㄩ儴銆�
+     */
+    private static final List<Long> ONLY_SHIPPING_INFO_IDS = Collections.emptyList();
+
+    /**
+     * 浠呭鐞嗚繖浜涢攢鍞悎鍚屽彿锛堜笌 {@code sales_ledger.sales_contract_no} 涓�鑷达級锛屼緥濡� {@code D260503010}锛涚┖琛ㄧず鍏ㄩ儴銆�
+     */
+    private static final List<String> ONLY_SALES_CONTRACT_NOS = Collections.emptyList();
+
+    /**
+     * 褰撳墿浣欏彲鍑哄簱閲忎负 0 鏃讹紝鏄惁鐢ㄨ鍗曡 {@code quantity} 浣滀负琛ュ綍鍑哄簱鏁伴噺銆�
+     */
+    private static final boolean USE_LINE_QUANTITY_WHEN_REMAINING_ZERO = true;
+
+    /**
+     * 涓� false 鏃朵粎澶勭悊 {@code product_stock_status = 2}锛堝凡鍏ㄩ儴鍏ュ簱锛夌殑浜у搧琛岋紱鏁版嵁淇寤鸿 true锛岄伩鍏嶈璺宠繃瀵艰嚧涓嶅嚭搴撹褰曘��
+     */
+    private static final boolean RELAX_PRODUCT_STOCK_STATUS_CHECK = true;
+
+    /**
+     * 鏄惁鍏佽鏈壒浠诲姟鎻愪氦鏁版嵁搴擄紙绯荤粺灞炴�� {@code ruoyi.shippingBatch.commit=true} 鎴栫幆澧冨彉閲� {@code RUOYI_SHIPPING_BATCH_COMMIT=true}锛夈��
+     */
+    @SuppressWarnings("unused")
+    static boolean commitSwitchEnabled() {
+        String p = System.getProperty("ruoyi.shippingBatch.commit");
+        if (p != null && "true".equalsIgnoreCase(p.trim())) {
+            return true;
+        }
+        String e = System.getenv("RUOYI_SHIPPING_BATCH_COMMIT");
+        return e != null && "true".equalsIgnoreCase(e.trim());
+    }
+
+    @Autowired
+    private DataSource dataSource;
+
+    @Autowired
+    private ShippingInfoMapper shippingInfoMapper;
+
+    @Autowired
+    private SalesLedgerMapper salesLedgerMapper;
+
+    @Autowired
+    private SalesLedgerProductMapper salesLedgerProductMapper;
+
+    /**
+     * 鏈樉寮忓厑璁告彁浜ゆ椂鏈敤渚嬩笉鎵ц锛堥伩鍏� {@code mvn test} 璇啓涓氬姟搴擄級銆傝绫绘敞閲� {@link #commitSwitchEnabled()}銆�
+     */
+    @Test
+    @Transactional
+    @Rollback(false)
+    void batchOutboundAndAlignDatesToDayBeforeDelivery() {
+        NamedParameterJdbcTemplate named = new NamedParameterJdbcTemplate(dataSource);
+
+        LedgerSyncStats syncStats = syncMissingApprovalsAndShippingLedgers(named);
+        log.warn(
+                "琛ュ叏瀹℃壒/鍙戣揣鍙拌处: 鎵弿璁㈠崟鏁�={}, 鏂板鍙戣揣瀹℃壒={}, 鏂板鍏ュ簱瀹℃壒={}, 鏂板 shipping_info 琛屾暟={}",
+                syncStats.scannedLedgers, syncStats.deliveryInserted, syncStats.stockInInserted, syncStats.shippingInserted);
+
+        LambdaQueryWrapper<ShippingInfo> qw = new LambdaQueryWrapper<ShippingInfo>().orderByAsc(ShippingInfo::getId);
+        if (!ONLY_SHIPPING_INFO_IDS.isEmpty()) {
+            qw.in(ShippingInfo::getId, ONLY_SHIPPING_INFO_IDS);
+        }
+        List<ShippingInfo> shippingRows = shippingInfoMapper.selectList(qw);
+
+        int outboundCount = 0;
+        int datePatchCount = 0;
+
+        for (ShippingInfo shipRow : shippingRows) {
+            SalesLedger ledger = salesLedgerMapper.selectById(shipRow.getSalesLedgerId());
+            if (ledger == null) {
+                continue;
+            }
+            if (!ONLY_SALES_CONTRACT_NOS.isEmpty()) {
+                String cno = ledger.getSalesContractNo();
+                if (cno == null || !ONLY_SALES_CONTRACT_NOS.contains(cno)) {
+                    continue;
+                }
+            }
+            if (ledger.getDeliveryDate() == null) {
+                continue;
+            }
+
+            LocalDate dayBeforeDelivery = ledger.getDeliveryDate().minusDays(1);
+            LocalDateTime alignedStart = LocalDateTime.of(dayBeforeDelivery, LocalTime.MIDNIGHT);
+            Timestamp alignedTs = Timestamp.valueOf(alignedStart);
+            java.sql.Date alignedSqlDate = java.sql.Date.valueOf(dayBeforeDelivery);
+
+            boolean hasShipOutRow = alreadyShipOutForShippingRow(named, shipRow.getId());
+            // 浠呬互銆屾槸鍚﹀凡鏈夋湰浜哄彴璐﹀搴旂殑鍙戣揣鍑哄簱銆嶄负鍑嗭紱涓嶅洜 delivery_status=5 璺宠繃锛屽惁鍒欎細婕忔帀銆屽凡鍙戣揣鍗存棤 13 璁板綍銆嶇殑鑴忔暟鎹�
+            if (hasShipOutRow) {
+                patchDatesForRow(named, ledger, shipRow, alignedTs, alignedSqlDate);
+                datePatchCount++;
+                continue;
+            }
+
+            SalesLedgerProduct product = salesLedgerProductMapper.selectById(shipRow.getSalesLedgerProductId());
+            if (product == null || !Objects.equals(product.getType(), SALE_PRODUCT_TYPE)) {
+                patchDatesForRow(named, ledger, shipRow, alignedTs, alignedSqlDate);
+                datePatchCount++;
+                continue;
+            }
+            if (!RELAX_PRODUCT_STOCK_STATUS_CHECK) {
+                if (product.getProductStockStatus() == null || product.getProductStockStatus() != 2) {
+                    patchDatesForRow(named, ledger, shipRow, alignedTs, alignedSqlDate);
+                    datePatchCount++;
+                    continue;
+                }
+            }
+            if (product.getProductModelId() == null) {
+                patchDatesForRow(named, ledger, shipRow, alignedTs, alignedSqlDate);
+                datePatchCount++;
+                continue;
+            }
+
+            product.fillRemainingQuantity();
+            BigDecimal outboundQty = resolveOutboundQuantity(product);
+            if (outboundQty == null || outboundQty.compareTo(BigDecimal.ZERO) <= 0) {
+                if (SHIP_DONE.equals(shipRow.getStatus())) {
+                    outboundQty = product.getQuantity() != null && product.getQuantity().compareTo(BigDecimal.ZERO) > 0
+                            ? product.getQuantity()
+                            : BigDecimal.ONE;
+                }
+            }
+            if (outboundQty == null || outboundQty.compareTo(BigDecimal.ZERO) <= 0) {
+                patchDatesForRow(named, ledger, shipRow, alignedTs, alignedSqlDate);
+                datePatchCount++;
+                continue;
+            }
+
+            // 鈶� 鍏堝啓鍙戣揣鏃ユ湡锛堜氦浠樻棩鍓嶄竴澶╋級锛涒憽 浠� INSERT 鍑哄簱鍙拌处璁板綍锛堜笉鎵e簱瀛樿〃锛�
+            patchShippingInfoDateFirst(named, shipRow.getId(), alignedTs);
+            insertSaleShipOutboundRecord(named, ledger, shipRow, product, outboundQty, alignedTs);
+
+            BigDecimal oldShipped = product.getShippedQuantity() == null ? BigDecimal.ZERO : product.getShippedQuantity();
+            product.setShippedQuantity(oldShipped.add(outboundQty));
+            product.fillRemainingQuantity();
+            salesLedgerProductMapper.updateById(product);
+
+            patchShippingInfoStatusShipped(named, shipRow.getId(), alignedTs);
+
+            patchStockRecordTimes(named, ledger.getId(), product.getId(), shipRow.getId(), alignedTs);
+            patchShipmentApprovalTimes(named, shipRow.getId(), alignedTs);
+            patchDeliveryApproveDates(named, ledger, alignedTs, alignedSqlDate);
+            patchStockInApproveDates(named, ledger, alignedTs, alignedSqlDate);
+
+            refreshSalesLedgerAggregate(ledger.getId());
+            outboundCount++;
+            datePatchCount++;
+        }
+
+        assertTrue(outboundCount >= 0 && datePatchCount >= 0,
+                "鍑哄簱澶勭悊琛屾暟: " + outboundCount + ", 鏃ユ湡瀵归綈娑夊強琛屾暟(鍚烦杩囦粎鏀规棩鏈�): " + datePatchCount);
+        log.warn(
+                "鍙戣揣/鍑哄簱鎵归噺浠诲姟宸叉彁浜ゃ�傚嚭搴� INSERT 琛屾暟={}, 鏃ユ湡瀵归綈琛屾暟={}, shipping_info 鎬昏鏁�={}",
+                outboundCount, datePatchCount, shippingRows.size());
+    }
+
+    private static final class LedgerSyncStats {
+        private int scannedLedgers;
+        private int deliveryInserted;
+        private int stockInInserted;
+        private int shippingInserted;
+    }
+
+    private static final class ApproveActor {
+        private final long userId;
+        private final String nickName;
+        private final long deptId;
+        private final String deptName;
+
+        private ApproveActor(long userId, String nickName, long deptId, String deptName) {
+            this.userId = userId;
+            this.nickName = nickName != null ? nickName : "";
+            this.deptId = deptId;
+            this.deptName = deptName != null && !deptName.isEmpty() ? deptName : "鎬诲叕鍙�";
+        }
+    }
+
+    /**
+     * 閬嶅巻閿�鍞彴璐︼細瀵瑰凡鍏ュ簱涓斿凡鍙戣揣鐨勪骇鍝佽琛ュ叏鍙戣揣瀹℃壒銆佸叆搴撳鎵癸紙涓嶅瓨鍦ㄥ垯鎻掑叆涓哄凡閫氳繃锛夛紝骞惰ˉ鍏ㄧ己澶辩殑 {@code shipping_info}銆�
+     */
+    private LedgerSyncStats syncMissingApprovalsAndShippingLedgers(NamedParameterJdbcTemplate named) {
+        LedgerSyncStats stats = new LedgerSyncStats();
+        LambdaQueryWrapper<SalesLedger> lqw = new LambdaQueryWrapper<SalesLedger>().orderByAsc(SalesLedger::getId);
+        if (!ONLY_SALES_CONTRACT_NOS.isEmpty()) {
+            lqw.in(SalesLedger::getSalesContractNo, ONLY_SALES_CONTRACT_NOS);
+        }
+        List<SalesLedger> ledgers = salesLedgerMapper.selectList(lqw);
+        for (SalesLedger ledger : ledgers) {
+            stats.scannedLedgers++;
+            if (ledger.getId() == null || ledger.getDeliveryDate() == null
+                    || ledger.getSalesContractNo() == null || ledger.getSalesContractNo().isEmpty()) {
+                continue;
+            }
+            LocalDate dayBefore = ledger.getDeliveryDate().minusDays(1);
+            LocalDateTime alignedStart = LocalDateTime.of(dayBefore, LocalTime.MIDNIGHT);
+            Timestamp alignedTs = Timestamp.valueOf(alignedStart);
+            java.sql.Date alignedSqlDate = java.sql.Date.valueOf(dayBefore);
+
+            List<SalesLedgerProduct> products = salesLedgerProductMapper.selectList(
+                    new LambdaQueryWrapper<SalesLedgerProduct>()
+                            .eq(SalesLedgerProduct::getSalesLedgerId, ledger.getId())
+                            .eq(SalesLedgerProduct::getType, SALE_PRODUCT_TYPE));
+            if (CollectionUtils.isEmpty(products)) {
+                continue;
+            }
+            List<SalesLedgerProduct> stockedAndShipped = products.stream()
+                    .filter(this::isProductStockedForSync)
+                    .filter(this::isProductShippedForSync)
+                    .collect(Collectors.toList());
+            if (stockedAndShipped.isEmpty()) {
+                continue;
+            }
+
+            ApproveActor actor = resolveApproveActor(ledger, named);
+            long tenant = ledger.getTenantId() != null ? ledger.getTenantId() : actor.deptId;
+
+            if (!deliveryApprovalExists(named, ledger.getSalesContractNo())) {
+                insertCompletedApproveProcess(named, ledger, alignedTs, alignedSqlDate, actor, tenant,
+                        DELIVERY_APPROVE_TYPE, "鍙戣揣瀹℃壒:" + ledger.getSalesContractNo(), null);
+                stats.deliveryInserted++;
+            }
+            if (!stockInApprovalExists(named, ledger.getSalesContractNo())) {
+                List<Long> stockedLineIds = products.stream()
+                        .filter(this::isProductStockedForSync)
+                        .map(SalesLedgerProduct::getId)
+                        .filter(Objects::nonNull)
+                        .sorted()
+                        .collect(Collectors.toList());
+                if (!stockedLineIds.isEmpty()) {
+                    String ids = stockedLineIds.stream().map(String::valueOf).collect(Collectors.joining(","));
+                    String remark = "salesStock:" + ledger.getId() + ":" + ids;
+                    insertCompletedApproveProcess(named, ledger, alignedTs, alignedSqlDate, actor, tenant,
+                            STOCK_IN_APPROVE_TYPE, "鍏ュ簱瀹℃壒:" + ledger.getSalesContractNo(), remark);
+                    stats.stockInInserted++;
+                }
+            }
+            for (SalesLedgerProduct line : stockedAndShipped) {
+                if (line.getId() == null) {
+                    continue;
+                }
+                long shipCnt = shippingInfoMapper.selectCount(
+                        new LambdaQueryWrapper<ShippingInfo>().eq(ShippingInfo::getSalesLedgerProductId, line.getId()));
+                if (shipCnt > 0) {
+                    continue;
+                }
+                insertSyntheticShippingInfo(named, ledger, line, alignedTs, actor, tenant);
+                stats.shippingInserted++;
+            }
+        }
+        return stats;
+    }
+
+    private boolean isProductStockedForSync(SalesLedgerProduct p) {
+        if (Objects.equals(p.getProductStockStatus(), 2)) {
+            return true;
+        }
+        BigDecimal sq = p.getStockedQuantity();
+        return sq != null && sq.compareTo(BigDecimal.ZERO) > 0;
+    }
+
+    private boolean isProductShippedForSync(SalesLedgerProduct p) {
+        BigDecimal sh = p.getShippedQuantity();
+        if (sh != null && sh.compareTo(BigDecimal.ZERO) > 0) {
+            return true;
+        }
+        List<ShippingInfo> rows = shippingInfoMapper.selectList(
+                new LambdaQueryWrapper<ShippingInfo>().eq(ShippingInfo::getSalesLedgerProductId, p.getId()));
+        return rows.stream().anyMatch(r -> SHIP_DONE.equals(r.getStatus()));
+    }
+
+    private boolean deliveryApprovalExists(NamedParameterJdbcTemplate named, String contractNo) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("cno", contractNo);
+        p.addValue("atype", DELIVERY_APPROVE_TYPE);
+        Integer cnt = named.queryForObject(
+                "SELECT COUNT(1) FROM approve_process WHERE approve_delete = 0 AND approve_type = :atype "
+                        + "AND approve_reason = CONCAT('鍙戣揣瀹℃壒:', :cno)",
+                p, Integer.class);
+        return cnt != null && cnt > 0;
+    }
+
+    private boolean stockInApprovalExists(NamedParameterJdbcTemplate named, String contractNo) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("cno", contractNo);
+        p.addValue("atype", STOCK_IN_APPROVE_TYPE);
+        Integer cnt = named.queryForObject(
+                "SELECT COUNT(1) FROM approve_process WHERE approve_delete = 0 AND approve_type = :atype AND ("
+                        + "approve_reason = CONCAT('鍏ュ簱瀹℃壒:', :cno) "
+                        + "OR approve_reason LIKE CONCAT('鍏ュ簱瀹℃壒:', :cno, ':%') "
+                        + "OR approve_reason = CONCAT('閿�鍞壂鐮佸悎鏍煎叆搴撳鎵�:', :cno) "
+                        + "OR approve_reason = CONCAT('閿�鍞壂鐮佷笉鍚堟牸鍏ュ簱瀹℃壒:', :cno))",
+                p, Integer.class);
+        return cnt != null && cnt > 0;
+    }
+
+    private ApproveActor resolveApproveActor(SalesLedger ledger, NamedParameterJdbcTemplate named) {
+        String ep = ledger.getEntryPerson();
+        if (ep != null && !ep.trim().isEmpty()) {
+            try {
+                return loadApproveActor(named, Long.parseLong(ep.trim()));
+            } catch (NumberFormatException ignored) {
+                // 褰曞叆浜哄彲鑳芥槸鏄电О绛夐潪鏁板瓧锛岄��鍥為粯璁よ处鍙�
+            }
+        }
+        return loadApproveActor(named, FALLBACK_SYS_USER_ID);
+    }
+
+    private ApproveActor loadApproveActor(NamedParameterJdbcTemplate named, long userId) {
+        MapSqlParameterSource p = new MapSqlParameterSource("id", userId);
+        List<ApproveActor> list = named.query(
+                "SELECT u.user_id AS userId, u.nick_name AS nickName, "
+                        + "(SELECT sud.dept_id FROM sys_user_dept sud WHERE sud.user_id = u.user_id ORDER BY sud.id ASC LIMIT 1) AS deptId, "
+                        + "(SELECT d.dept_name FROM sys_user_dept sud INNER JOIN sys_dept d ON d.dept_id = sud.dept_id "
+                        + "WHERE sud.user_id = u.user_id AND (d.del_flag = '0' OR d.del_flag IS NULL) ORDER BY sud.id ASC LIMIT 1) AS deptName "
+                        + "FROM sys_user u "
+                        + "WHERE u.user_id = :id AND (u.del_flag = '0' OR u.del_flag IS NULL) LIMIT 1",
+                p, (rs, i) -> {
+                    long uid = rs.getLong("userId");
+                    String nick = rs.getString("nickName");
+                    Long dId = rs.getObject("deptId") != null ? rs.getLong("deptId") : 100L;
+                    String dName = rs.getString("deptName");
+                    return new ApproveActor(uid, nick, dId, dName != null ? dName : "鎬诲叕鍙�");
+                });
+        if (list.isEmpty()) {
+            return new ApproveActor(FALLBACK_SYS_USER_ID, "绠$悊鍛樿处鍙�", 100L, "鎬诲叕鍙�");
+        }
+        return list.get(0);
+    }
+
+    private String nextSyntheticApproveId(NamedParameterJdbcTemplate named, LocalDate logicalDay) {
+        String pfx = logicalDay.format(DateTimeFormatter.BASIC_ISO_DATE);
+        MapSqlParameterSource p = new MapSqlParameterSource("pfx", pfx);
+        Integer maxSuffix = named.queryForObject(
+                "SELECT IFNULL(MAX(CAST(RIGHT(approve_id, 3) AS UNSIGNED)), 0) FROM approve_process "
+                        + "WHERE approve_delete = 0 AND CHAR_LENGTH(approve_id) = 11 AND approve_id LIKE CONCAT(:pfx, '%')",
+                p, Integer.class);
+        int n = (maxSuffix == null ? 0 : maxSuffix) + 1;
+        if (n > 999) {
+            n = 1;
+        }
+        return pfx + String.format("%03d", n);
+    }
+
+    private void insertCompletedApproveProcess(NamedParameterJdbcTemplate named, SalesLedger ledger,
+                                               Timestamp alignedTs, java.sql.Date alignedSqlDate,
+                                               ApproveActor actor, long tenant, int approveType,
+                                               String reason, String remark) {
+        LocalDate logicalDay = alignedSqlDate.toLocalDate();
+        String approveId = nextSyntheticApproveId(named, logicalDay);
+        String uidStr = String.valueOf(actor.userId);
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("approveId", approveId);
+        p.addValue("uid", actor.userId);
+        p.addValue("nick", actor.nickName);
+        p.addValue("deptId", actor.deptId);
+        p.addValue("deptName", actor.deptName);
+        p.addValue("uidStr", uidStr);
+        p.addValue("reason", reason);
+        p.addValue("remark", remark);
+        p.addValue("dt", alignedTs);
+        p.addValue("d", alignedSqlDate);
+        p.addValue("tenant", tenant);
+        p.addValue("atype", approveType);
+        p.addValue("st", APPROVE_STATUS_COMPLETED);
+        named.update(
+                "INSERT INTO approve_process (approve_id, approve_user, approve_user_name, approve_dept_id, approve_dept_name, "
+                        + "approve_user_ids, approve_user_names, approve_user_current_id, approve_user_current_name, "
+                        + "approve_reason, approve_time, approve_over_time, approve_status, approve_delete, tenant_id, approve_type, "
+                        + "approve_remark, create_time, start_date, end_date) "
+                        + "VALUES (:approveId, :uid, :nick, :deptId, :deptName, :uidStr, :nick, :uid, :nick, "
+                        + ":reason, :dt, :dt, :st, 0, :tenant, :atype, :remark, :dt, :d, :d)",
+                p);
+        insertSingleCompletedApproveNode(named, approveId, alignedTs, actor);
+    }
+
+    private void insertSingleCompletedApproveNode(NamedParameterJdbcTemplate named, String approveId,
+                                                  Timestamp alignedTs, ApproveActor actor) {
+        LocalDateTime now = LocalDateTime.now();
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("apid", approveId);
+        p.addValue("uid", actor.userId);
+        p.addValue("nick", actor.nickName);
+        p.addValue("ts", alignedTs);
+        p.addValue("tenant", actor.deptId);
+        p.addValue("nst", APPROVE_NODE_STATUS_AGREE);
+        p.addValue("now", Timestamp.valueOf(now));
+        named.update(
+                "INSERT INTO approve_node (approve_process_id, approve_node_order, approve_node_user_id, approve_node_user, "
+                        + "approve_node_time, approve_node_status, tenant_id, delete_flag, create_time, update_time, create_user, update_user) "
+                        + "VALUES (:apid, 1, :uid, :nick, :ts, :nst, :tenant, 0, :now, :now, :uid, :uid)",
+                p);
+    }
+
+    private void insertSyntheticShippingInfo(NamedParameterJdbcTemplate named, SalesLedger ledger,
+                                             SalesLedgerProduct line, Timestamp alignedTs,
+                                             ApproveActor actor, long tenant) {
+        String shippingNo = "SH-SYNC-" + line.getId() + "-" + System.currentTimeMillis();
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("slid", ledger.getId());
+        p.addValue("slpid", line.getId());
+        p.addValue("sd", alignedTs);
+        p.addValue("tenant", tenant);
+        p.addValue("uid", actor.userId);
+        p.addValue("sno", shippingNo);
+        named.update(
+                "INSERT INTO shipping_info (sales_ledger_id, sales_ledger_product_id, shipping_date, status, tenant_id, "
+                        + "create_time, update_time, create_user, update_user, shipping_no, type) "
+                        + "VALUES (:slid, :slpid, :sd, '宸插彂璐�', :tenant, :sd, :sd, :uid, :uid, :sno, '璐ц溅')",
+                p);
+    }
+
+    /**
+     * 瑙f瀽鏈搴旇ˉ褰曠殑鍑哄簱鏁伴噺锛堜笉纰板簱瀛樿〃锛夈�傞渶鍏堝 {@code product} 璋冪敤杩� {@link SalesLedgerProduct#fillRemainingQuantity()}銆�
+     */
+    private BigDecimal resolveOutboundQuantity(SalesLedgerProduct product) {
+        BigDecimal rem = product.getRemainingShippedQuantity();
+        if (rem != null && rem.compareTo(BigDecimal.ZERO) > 0) {
+            return rem;
+        }
+        BigDecimal stocked = product.getStockedQuantity() == null ? BigDecimal.ZERO : product.getStockedQuantity();
+        BigDecimal shipped = product.getShippedQuantity() == null ? BigDecimal.ZERO : product.getShippedQuantity();
+        BigDecimal gap = stocked.subtract(shipped);
+        if (gap.compareTo(BigDecimal.ZERO) > 0) {
+            return gap;
+        }
+        if (USE_LINE_QUANTITY_WHEN_REMAINING_ZERO && product.getQuantity() != null
+                && product.getQuantity().compareTo(BigDecimal.ZERO) > 0) {
+            return product.getQuantity();
+        }
+        return BigDecimal.ZERO;
+    }
+
+    /**
+     * 鎻掑叆銆岄攢鍞�-鍙戣揣鍑哄簱銆嶈褰曪細涓嶅啓搴撳瓨鏁伴噺锛屼粎淇濊瘉鍑哄簱鍙拌处鍒楄〃鍙寜鍚堝悓鍙锋煡鍒般��
+     */
+    private void insertSaleShipOutboundRecord(NamedParameterJdbcTemplate named, SalesLedger ledger,
+                                              ShippingInfo shipRow, SalesLedgerProduct product, BigDecimal outboundQty,
+                                              Timestamp alignedTs) {
+        String batchNo = "CK-DIRECT-" + shipRow.getId() + "-" + System.currentTimeMillis();
+        int createUser = shipRow.getCreateUser() != null ? shipRow.getCreateUser() : 1;
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("batch", batchNo);
+        p.addValue("qty", outboundQty);
+        p.addValue("rid", shipRow.getId());
+        p.addValue("rt", StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode());
+        p.addValue("pmid", product.getProductModelId());
+        p.addValue("ts", alignedTs);
+        p.addValue("cuser", createUser);
+        p.addValue("slid", ledger.getId());
+        p.addValue("slpid", product.getId());
+        named.update(
+                "INSERT INTO stock_out_record (outbound_batches, stock_out_num, record_id, record_type, product_model_id, "
+                        + "create_time, update_time, create_user, update_user, type, sales_ledger_id, sales_ledger_product_id) "
+                        + "VALUES (:batch, :qty, :rid, :rt, :pmid, :ts, :ts, :cuser, :cuser, '0', :slid, :slpid)",
+                p);
+    }
+
+    private boolean alreadyShipOutForShippingRow(NamedParameterJdbcTemplate named, Long shippingInfoId) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("rid", shippingInfoId);
+        p.addValue("rt", StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode());
+        Integer cnt = named.queryForObject(
+                "SELECT COUNT(1) FROM stock_out_record WHERE record_id = :rid AND record_type = :rt",
+                p, Integer.class);
+        return cnt != null && cnt > 0;
+    }
+
+    /**
+     * 鈶� 鍙戣揣鍙拌处 鈫� 鈶� 鍏ュ嚭搴撹褰� 鈫� 鈶� shipment_approval 鈫� 鈶� 鍙戣揣瀹℃壒锛堝潎涓轰氦浠樻棩鍓嶄竴澶� 00:00:00锛夈��
+     */
+    private void patchDatesForRow(NamedParameterJdbcTemplate named, SalesLedger ledger, ShippingInfo shipRow,
+                                  Timestamp alignedTs, java.sql.Date alignedSqlDate) {
+        patchShippingInfoDateFirst(named, shipRow.getId(), alignedTs);
+        if (ledger.getId() != null && shipRow.getSalesLedgerProductId() != null) {
+            patchStockRecordTimes(named, ledger.getId(), shipRow.getSalesLedgerProductId(), shipRow.getId(), alignedTs);
+        }
+        patchShipmentApprovalTimes(named, shipRow.getId(), alignedTs);
+        patchDeliveryApproveDates(named, ledger, alignedTs, alignedSqlDate);
+        patchStockInApproveDates(named, ledger, alignedTs, alignedSqlDate);
+    }
+
+    /**
+     * 鈶� 鍏堟洿鏂板彂璐у彴璐� {@code shipping_date}锛堝強 {@code update_time}锛夈��
+     */
+    private void patchShippingInfoDateFirst(NamedParameterJdbcTemplate named, Long shippingInfoId, Timestamp alignedTs) {
+        MapSqlParameterSource ship = new MapSqlParameterSource();
+        ship.addValue("sd", alignedTs);
+        ship.addValue("sid", shippingInfoId);
+        named.update(
+                "UPDATE shipping_info SET shipping_date = :sd, update_time = :sd WHERE id = :sid",
+                ship);
+    }
+
+    /**
+     * 鍑哄簱瀹屾垚鍚庡皢鍙戣揣鍙拌处鐘舵�佺疆涓哄凡鍙戣揣锛堝彂璐ф棩鏈熷凡鍦ㄦ墸搴撳墠鍐欏叆锛夈��
+     */
+    private void patchShippingInfoStatusShipped(NamedParameterJdbcTemplate named, Long shippingInfoId, Timestamp alignedTs) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("st", SHIP_DONE);
+        p.addValue("ts", alignedTs);
+        p.addValue("sid", shippingInfoId);
+        named.update(
+                "UPDATE shipping_info SET status = :st, update_time = :ts WHERE id = :sid",
+                p);
+    }
+
+    private void patchStockRecordTimes(NamedParameterJdbcTemplate named, Long salesLedgerId, Long salesLedgerProductId,
+                                       Long shippingInfoId, Timestamp alignedTs) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("ts", alignedTs);
+        p.addValue("sid", salesLedgerId);
+        p.addValue("spid", salesLedgerProductId);
+        named.update(
+                "UPDATE stock_in_record SET create_time = :ts, update_time = :ts "
+                        + "WHERE sales_ledger_id = :sid AND sales_ledger_product_id = :spid",
+                p);
+
+        MapSqlParameterSource p2 = new MapSqlParameterSource();
+        p2.addValue("ts", alignedTs);
+        p2.addValue("sid", salesLedgerId);
+        p2.addValue("spid", salesLedgerProductId);
+        named.update(
+                "UPDATE stock_out_record SET create_time = :ts, update_time = :ts "
+                        + "WHERE sales_ledger_id = :sid AND sales_ledger_product_id = :spid",
+                p2);
+
+        MapSqlParameterSource p3 = new MapSqlParameterSource();
+        p3.addValue("ts", alignedTs);
+        p3.addValue("rid", shippingInfoId);
+        p3.addValue("rt", StockOutQualifiedRecordTypeEnum.SALE_SHIP_STOCK_OUT.getCode());
+        named.update(
+                "UPDATE stock_out_record SET create_time = :ts, update_time = :ts "
+                        + "WHERE record_id = :rid AND record_type = :rt",
+                p3);
+    }
+
+    /**
+     * 涓� hbtmblc 鑴氭湰涓�鑷达細{@code approve_time}/{@code approve_over_time} 涓� datetime锛泏@code start_date}/{@code end_date} 涓� date銆�
+     */
+    private void patchDeliveryApproveDates(NamedParameterJdbcTemplate named, SalesLedger ledger,
+                                           Timestamp alignedDateTime, java.sql.Date alignedSqlDate) {
+        if (ledger.getSalesContractNo() == null || ledger.getSalesContractNo().isEmpty()) {
+            return;
+        }
+        String reason = "鍙戣揣瀹℃壒:" + ledger.getSalesContractNo();
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("dt", alignedDateTime);
+        p.addValue("d", alignedSqlDate);
+        p.addValue("reason", reason);
+        p.addValue("atype", DELIVERY_APPROVE_TYPE);
+        named.update(
+                "UPDATE approve_process SET approve_time = :dt, approve_over_time = :dt, start_date = :d, end_date = :d "
+                        + "WHERE approve_delete = 0 AND approve_type = :atype AND approve_reason = :reason",
+                p);
+    }
+
+    /**
+     * 閿�鍞鍗曠浉鍏冲叆搴撳鎵癸細{@link ApproveTypeEnum#STOCK_IN}銆備簨鐢卞墠缂�涓庝笟鍔′唬鐮佷竴鑷达紙瑙� {@code SalesLedgerServiceImpl#salesStock} 绛夛級銆�
+     * 浣跨敤绮剧‘浜嬬敱鎴� {@code 鍏ュ簱瀹℃壒:鍚堝悓鍙�:...} 鎵╁睍鏍煎紡锛岄伩鍏嶅悎鍚屽彿浜掍负鍓嶇紑鏃� {@code LIKE '...%'} 璇尮閰嶃��
+     */
+    private void patchStockInApproveDates(NamedParameterJdbcTemplate named, SalesLedger ledger,
+                                          Timestamp alignedDateTime, java.sql.Date alignedSqlDate) {
+        if (ledger.getSalesContractNo() == null || ledger.getSalesContractNo().isEmpty()) {
+            return;
+        }
+        String cno = ledger.getSalesContractNo();
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("dt", alignedDateTime);
+        p.addValue("d", alignedSqlDate);
+        p.addValue("atype", STOCK_IN_APPROVE_TYPE);
+        p.addValue("cno", cno);
+        named.update(
+                "UPDATE approve_process SET approve_time = :dt, approve_over_time = :dt, start_date = :d, end_date = :d "
+                        + "WHERE approve_delete = 0 AND approve_type = :atype AND ("
+                        + "approve_reason = CONCAT('鍏ュ簱瀹℃壒:', :cno) "
+                        + "OR approve_reason LIKE CONCAT('鍏ュ簱瀹℃壒:', :cno, ':%') "
+                        + "OR approve_reason = CONCAT('閿�鍞壂鐮佸悎鏍煎叆搴撳鎵�:', :cno) "
+                        + "OR approve_reason = CONCAT('閿�鍞壂鐮佷笉鍚堟牸鍏ュ簱瀹℃壒:', :cno))",
+                p);
+    }
+
+    /**
+     * 鑴氭湰涓殑 {@code shipment_approval} 琛紙鑻ユ湁鏁版嵁锛夋寜鍙戣揣淇℃伅 id 瀵归綈鏃堕棿銆�
+     */
+    private void patchShipmentApprovalTimes(NamedParameterJdbcTemplate named, Long shippingInfoId, Timestamp alignedTs) {
+        MapSqlParameterSource p = new MapSqlParameterSource();
+        p.addValue("ts", alignedTs);
+        p.addValue("sid", shippingInfoId);
+        named.update(
+                "UPDATE shipment_approval SET create_time = :ts, update_time = :ts WHERE shipping_info_id = :sid",
+                p);
+    }
+
+    private void refreshSalesLedgerAggregate(Long salesLedgerId) {
+        SalesLedger salesLedger = salesLedgerMapper.selectById(salesLedgerId);
+        if (salesLedger == null) {
+            return;
+        }
+        List<ShippingInfo> unsent = shippingInfoMapper.selectList(new LambdaQueryWrapper<ShippingInfo>()
+                .eq(ShippingInfo::getSalesLedgerId, salesLedgerId)
+                .ne(ShippingInfo::getStatus, SHIP_DONE));
+        if (CollectionUtils.isEmpty(unsent) && !Integer.valueOf(5).equals(salesLedger.getDeliveryStatus())) {
+            salesLedger.setDeliveryStatus(5);
+            salesLedgerMapper.updateById(salesLedger);
+        }
+
+        List<SalesLedgerProduct> ledgerAllProducts = salesLedgerProductMapper.selectList(
+                new LambdaQueryWrapper<SalesLedgerProduct>().eq(SalesLedgerProduct::getSalesLedgerId, salesLedgerId));
+        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));
+        salesLedgerMapper.updateById(salesLedger);
+    }
+}

--
Gitblit v1.9.3