gongchunyi
4 天以前 9f281fb21c139b96cd2be8809e1c704782868c95
test: 测试类提交
已添加4个文件
1212 ■■■■■ 文件已修改
src/test/java/com/ruoyi/sales/DeliveryApproveExtraCleanupTest.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/ruoyi/sales/InvoiceLedgerPendingReceiptBatchTest.java 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/ruoyi/sales/SalesProductApproveStatusSyncTest.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/test/java/com/ruoyi/sales/ShippingLedgerOutboundAndDatesBatchTest.java 720 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>:VM 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;
        }
    }
}
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};null è¡¨ç¤ºä¸æŒ‰ç§Ÿæˆ·è¿‡æ»¤ã€‚
     */
    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;
    }
}
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);
    }
}
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},补全审批时按此结构解析申请人部门。
 * <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>否则在满足产品已入库等条件时,按出库数量解析规则写入一条出库记录({@code record_type=13},{@code record_id=shipping_info.id}),
 *       å¹¶å›žå†™äº§å“ {@code shipped_quantity};<b>不调用</b>库存扣减逻辑。</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>未打开提交开关(见下方「如何真正写库」)时本用例不会执行,数据库不会有任何变更。</li>
 *   <li>加了参数但 Maven/IDE è¿žçš„不是浏览器同一套数据源,界面仍显示旧数据。</li>
 *   <li>产品行 {@code product_stock_status != 2}(未标记全部入库)时原逻辑会 {@code continue},已用 {@link #RELAX_PRODUCT_STOCK_STATUS_CHECK} æ”¾å®½ã€‚</li>
 *   <li>解析出的出库数量为 0 æ—¶åŽŸå…ˆåªæ”¹æ—¥æœŸä¸ INSERT——已对「发货状态=已发货」兜底仍插入一条。</li>
 *   <li>合格出库列表会带 {@code type=0},插入时已写 {@code type='0'}。</li>
 * </ul>
 * <p><b>如何真正写库</b>(满足其一即可):
 * <ul>
 *   <li>IDE:Run Configuration â†’ VM options å¢žåŠ  {@code -Druoyi.shippingBatch.commit=true}</li>
 *   <li>环境变量:{@code RUOYI_SHIPPING_BATCH_COMMIT=true}(Windows å¯åœ¨è¿è¡Œå‰ {@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 å‡ºåº“台账记录(不扣库存表)
            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);
    }
    /**
     * è§£æžæœ¬è¡Œåº”补录的出库数量(不碰库存表)。需先对 {@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);
    }
}