Эх сурвалжийг харах

036 换/改支付方式对齐661渠道核实口径并拆专属提示

换渠道或改到付前逐笔向渠道核实在途支付(OMG reconcile、LINE 本地状态+捕获查询):
确定未付放行改选、已收款提示已支付、结果未知维持点名渠道的专属拒绝文案;
核实发生在批次行锁之前,核实后又有新发起保守拒绝,迟到实收由重复实收退款兜底。
新增 MultiStoreCheckoutCaptureVerifier、multi.store.checkout.payment.dispatched 六语言文案,
复用 no.pay.switch.already.paid;同步 spec/data-model/api/接入文档并登记 T040。
规格文档同时带入协作会话未提交的逐店付款描述更新。
qmj 2 өдөр өмнө
parent
commit
fd3105caee
18 өөрчлөгдсөн 468 нэмэгдсэн , 73 устгасан
  1. 104 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCaptureVerifier.java
  2. 19 4
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCodService.java
  3. 19 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionService.java
  4. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRules.java
  5. 1 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  6. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  7. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  8. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  9. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  10. 1 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  11. 171 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCaptureVerifierTest.java
  12. 31 4
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCodServiceTest.java
  13. 34 5
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionServiceTest.java
  14. 5 5
      specs/036-multi-store-checkout/contracts/api.md
  15. 7 7
      specs/036-multi-store-checkout/data-model.md
  16. 39 20
      specs/036-multi-store-checkout/frontend-app-integration.md
  17. 18 18
      specs/036-multi-store-checkout/spec.md
  18. 10 5
      specs/036-multi-store-checkout/tasks.md

+ 104 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCaptureVerifier.java

@@ -0,0 +1,104 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.omgpay.OmgPaymentQueryService;
+import com.ruoyi.app.pay.LinePayService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 036 多店子单换或改支付方式前的在途支付核实,口径与 661 普通单换到付一致:
+ * 已收款立即阻止并提示已支付;查证失败或结果未知保持专属拒绝;
+ * 全部在途尝试经渠道确定未付才放行,核实通过后的迟到实收由 DUPLICATE_PAYMENT 退款兜底。
+ */
+@Service
+public class MultiStoreCheckoutCaptureVerifier {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final IPosOrderLinePaymentService linePayments;
+    private final IOmgPaymentAttemptService omgAttempts;
+    private final OmgPaymentQueryService omgQueryService;
+    private final LinePayService linePayService;
+
+    public MultiStoreCheckoutCaptureVerifier(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                             IPosOrderLinePaymentService linePayments,
+                                             IOmgPaymentAttemptService omgAttempts,
+                                             OmgPaymentQueryService omgQueryService,
+                                             LinePayService linePayService) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.linePayments = linePayments;
+        this.omgAttempts = omgAttempts;
+        this.omgQueryService = omgQueryService;
+        this.linePayService = linePayService;
+    }
+
+    /**
+     * 取批次行锁前的预检:无锁读成员与在途计数,向渠道核实确定未付。
+     * 返回已核实的在途尝试数,锁定后按该计数放行;数据竞争由锁定段的完整校验收口。
+     */
+    public int verifyInFlightBeforeLock(Long batchId, Long orderId, Long userId) {
+        OrderCheckoutBatch batch = checkout.selectBatchById(batchId);
+        if (batch == null || !userId.equals(batch.getUserId())) return 0;
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        OrderCheckoutMember member = members == null ? null : members.stream()
+                .filter(item -> orderId.equals(item.getPosOrderId())).findFirst().orElse(null);
+        if (member == null) return 0;
+        if (checkout.countPaymentAttemptsByMemberId(member.getId()) == 0) return 0;
+        PosOrder order = orders.selectById(orderId);
+        // 子单号缺失视作未核实,锁定后由在途计数保守拒绝。
+        if (order == null || order.getDdId() == null || order.getDdId().isBlank()) return 0;
+        verifyNoCapturedPayment(order.getDdId(), member.getPayType());
+        return (int) checkout.countPaymentAttemptsByMemberId(member.getId());
+    }
+
+    /** 按子单号核实 LINE 与 OMG 两渠道在途支付;确定未付正常返回,否则抛出可操作提示。 */
+    public void verifyNoCapturedPayment(String orderNo, String memberPayType) {
+        List<PosOrderLinePayment> payments = linePayments.getByDdId(orderNo);
+        if (payments != null) {
+            for (PosOrderLinePayment payment : payments) {
+                if ("PAID".equals(payment.getStatus())) throw alreadyPaid();
+                if (List.of("FAILED", "CANCELLED_OR_EXPIRED", "REFUNDED").contains(payment.getStatus())) {
+                    continue;
+                }
+                if ("MANUAL_REVIEW".equals(payment.getStatus())
+                        || "CONFIRM_UNKNOWN".equals(payment.getStatus())) {
+                    throw dispatched(memberPayType);
+                }
+                try {
+                    if (linePayService.isCapturedForSwitch(payment)) throw alreadyPaid();
+                } catch (ServiceException known) {
+                    throw known;
+                } catch (RuntimeException inconclusive) {
+                    throw dispatched(memberPayType);
+                }
+            }
+        }
+        if (omgAttempts.selectPaidByDdId(orderNo) != null) throw alreadyPaid();
+        OmgPaymentAttempt created = omgAttempts.selectActiveCreatedByDdId(orderNo);
+        if (created != null) {
+            String status = omgQueryService.reconcile(created).status();
+            if (!"UNPAID".equals(status) && !"FAILED".equals(status)) throw dispatched(memberPayType);
+        }
+    }
+
+    private ServiceException alreadyPaid() {
+        return new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+    }
+
+    private ServiceException dispatched(String payType) {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.payment.dispatched",
+                MultiStoreCheckoutRules.onlineChannelName(payType)));
+    }
+}

+ 19 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCodService.java

@@ -25,22 +25,27 @@ public class MultiStoreCheckoutCodService {
     private final PaymentMethodGateService payments;
     private final MultiStoreCheckoutCancellationService cancellation;
     private final MultiStoreCheckoutActivationTrigger activationTrigger;
+    private final MultiStoreCheckoutCaptureVerifier captures;
 
     public MultiStoreCheckoutCodService(OrderCheckoutMapper checkout, PosOrderMapper orders,
                                         PosStoreMapper stores, PaymentMethodGateService payments,
                                         MultiStoreCheckoutCancellationService cancellation,
-                                        MultiStoreCheckoutActivationTrigger activationTrigger) {
+                                        MultiStoreCheckoutActivationTrigger activationTrigger,
+                                        MultiStoreCheckoutCaptureVerifier captures) {
         this.checkout = checkout;
         this.orders = orders;
         this.stores = stores;
         this.payments = payments;
         this.cancellation = cancellation;
         this.activationTrigger = activationTrigger;
+        this.captures = captures;
     }
 
     @Transactional(rollbackFor = Exception.class)
     public String confirm(Long batchId, Long orderId, Long userId) {
         if (batchId == null || orderId == null || userId == null) throw invalid();
+        // 先在无批次行锁时向渠道核实在途支付(网关 HTTP 不得与行锁同持,口径同 661 换到付)。
+        int verifiedInFlight = captures.verifyInFlightBeforeLock(batchId, orderId, userId);
         // 所有逐店付款事实先锁父批次;激活、取消和回调也必须共用这把数据库行锁。
         OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
         if (batch == null || !userId.equals(batch.getUserId())
@@ -70,9 +75,13 @@ public class MultiStoreCheckoutCodService {
         if ("ONLINE_PENDING".equals(selected.getPaymentState())) {
             // 未发起网关交易才可改为到付;批次行锁与回调/发起支付互斥。
             if (selected.getPayType() == null || !selected.getPayType().equals(order.getPayType())
-                    || !Long.valueOf(0L).equals(order.getPayStatus())
-                    || checkout.countPaymentAttemptsByMemberId(selected.getId()) != 0
-                    || checkout.switchMemberPayment(selected.getId(), "ONLINE_PENDING", selected.getPayType(),
+                    || !Long.valueOf(0L).equals(order.getPayStatus())) throw invalid();
+            // 在途尝试须已向渠道核实确定未付才可改到付;核实后又新发起的保守拒绝并告知去向,
+            // 核实通过后的迟到实收由 DUPLICATE_PAYMENT 退款兜底。
+            if (checkout.countPaymentAttemptsByMemberId(selected.getId()) > verifiedInFlight) {
+                throw dispatched(selected.getPayType());
+            }
+            if (checkout.switchMemberPayment(selected.getId(), "ONLINE_PENDING", selected.getPayType(),
                     "COD_CONFIRMED", "1", now) != 1
                     || checkout.switchOrderPayment(orderId, selected.getPayType(), 0L, "1", 1L) != 1) throw invalid();
         } else if (checkout.updateMemberCodReady(selected.getId(), now) != 1
@@ -99,6 +108,12 @@ public class MultiStoreCheckoutCodService {
         return "ACTIVE";
     }
 
+    /** 已有网关收银台会话时改到付的专属提示:点名渠道并给出可操作路径。 */
+    private ServiceException dispatched(String payType) {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.payment.dispatched",
+                MultiStoreCheckoutRules.onlineChannelName(payType)));
+    }
+
     private ServiceException invalid() {
         return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
     }

+ 19 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionService.java

@@ -23,19 +23,24 @@ public class MultiStoreCheckoutOnlineSelectionService {
     private final PosOrderMapper orders;
     private final PosStoreMapper stores;
     private final PaymentMethodGateService payments;
+    private final MultiStoreCheckoutCaptureVerifier captures;
 
     public MultiStoreCheckoutOnlineSelectionService(OrderCheckoutMapper checkout, PosOrderMapper orders,
-                                                    PosStoreMapper stores, PaymentMethodGateService payments) {
+                                                    PosStoreMapper stores, PaymentMethodGateService payments,
+                                                    MultiStoreCheckoutCaptureVerifier captures) {
         this.checkout = checkout;
         this.orders = orders;
         this.stores = stores;
         this.payments = payments;
+        this.captures = captures;
     }
 
     @Transactional(rollbackFor = Exception.class)
     public String select(Long batchId, Long orderId, Long userId, String payType) {
         if (batchId == null || orderId == null || userId == null
                 || !("2".equals(payType) || "3".equals(payType) || "5".equals(payType))) throw invalid();
+        // 先在无批次行锁时向渠道核实在途支付(网关 HTTP 不得与行锁同持,口径同 661 换到付)。
+        int verifiedInFlight = captures.verifyInFlightBeforeLock(batchId, orderId, userId);
         // 与到付确认、将来的网关实收及取消共用批次行锁;普通订单不调用此服务。
         OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
         if (batch == null || !userId.equals(batch.getUserId())
@@ -64,10 +69,13 @@ public class MultiStoreCheckoutOnlineSelectionService {
         if (("ONLINE_PENDING".equals(member.getPaymentState()) || "COD_CONFIRMED".equals(member.getPaymentState()))
                 && member.getPayType() != null && member.getPayType().equals(order.getPayType())) {
             Long oldPayStatus = "COD_CONFIRMED".equals(member.getPaymentState()) ? 1L : 0L;
-            // 仅仅选过渠道可以改;一旦请求发往网关,回调可能迟到,必须拒绝切换。
-            if (!oldPayStatus.equals(order.getPayStatus())
-                    || checkout.countPaymentAttemptsByMemberId(member.getId()) != 0
-                    || checkout.switchMemberPayment(member.getId(), member.getPaymentState(),
+            if (!oldPayStatus.equals(order.getPayStatus())) throw invalid();
+            // 在途尝试须已向渠道核实确定未付才可换;核实后又新发起的保守拒绝并告知去向,
+            // 核实通过后的迟到实收由 DUPLICATE_PAYMENT 退款兜底。
+            if (checkout.countPaymentAttemptsByMemberId(member.getId()) > verifiedInFlight) {
+                throw dispatched(member.getPayType());
+            }
+            if (checkout.switchMemberPayment(member.getId(), member.getPaymentState(),
                     member.getPayType(), "ONLINE_PENDING", payType, null) != 1
                     || checkout.switchOrderPayment(orderId, member.getPayType(), oldPayStatus, payType, 0L) != 1) {
                 throw invalid();
@@ -82,6 +90,12 @@ public class MultiStoreCheckoutOnlineSelectionService {
         return "ONLINE_PENDING";
     }
 
+    /** 已有网关收银台会话时换渠道的专属提示:点名渠道并给出可操作路径。 */
+    private ServiceException dispatched(String payType) {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.payment.dispatched",
+                MultiStoreCheckoutRules.onlineChannelName(payType)));
+    }
+
     private ServiceException invalid() {
         return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
     }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRules.java

@@ -131,4 +131,9 @@ public final class MultiStoreCheckoutRules {
             return false;
         }
     }
+
+    /** 网关渠道对外展示名;仅用于面向用户的提示文案,不参与任何业务判断。 */
+    public static String onlineChannelName(String payType) {
+        return "3".equals(payType) ? "LINE Pay" : "OMG";
+    }
 }

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -465,6 +465,7 @@ multi.store.config.conflict=Cấu hình đã thay đổi hoặc chưa được k
 multi.store.checkout.disabled=Thanh toán nhiều cửa hàng chưa được mở
 multi.store.checkout.upgrade.required=Vui lòng cập nhật ứng dụng để sử dụng thanh toán nhiều cửa hàng và nhóm giao hàng
 multi.store.checkout.invalid=Yêu cầu thanh toán nhiều cửa hàng không hợp lệ hoặc quá ít cửa hàng tham gia
+multi.store.checkout.payment.dispatched=该店已发起{0}支付请求,完成前不能更换支付方式;请返回完成支付,或取消本次多店结算重新下单
 multi.store.checkout.group.accept.required=Phải nhận toàn bộ nhóm giao hàng nhiều cửa hàng cùng lúc
 multi.store.checkout.batch.cancel.required=Vui lòng hủy qua quy trình đơn hàng nhiều cửa hàng
 multi.store.checkout.admin.read.only=Vui lòng xử lý trạng thái đơn nhiều cửa hàng qua quy trình riêng

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -451,6 +451,7 @@ multi.store.config.conflict=Configuration changed or is not initialized. Refresh
 multi.store.checkout.disabled=Multi-store checkout is not available yet
 multi.store.checkout.upgrade.required=Update the app to use multi-store checkout and delivery groups
 multi.store.checkout.invalid=Invalid multi-store checkout request or too few participating stores
+multi.store.checkout.payment.dispatched=A {0} payment request has already been sent for this store and the payment method cannot be changed. Please go back and complete that payment, or cancel this checkout and start a new order.
 multi.store.checkout.group.accept.required=Accept the entire multi-store delivery group together
 multi.store.checkout.batch.cancel.required=Cancel this order through the multi-store checkout flow
 multi.store.checkout.admin.read.only=Manage this multi-store member through the batch workflow

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -452,6 +452,7 @@ multi.store.config.conflict=การตั้งค่าเปลี่ยน
 multi.store.checkout.disabled=ยังไม่เปิดใช้การชำระเงินหลายร้าน
 multi.store.checkout.upgrade.required=โปรดอัปเดตแอปเพื่อใช้การชำระเงินหลายร้านและรับงานจัดส่งเป็นกลุ่ม
 multi.store.checkout.invalid=คำขอชำระเงินหลายร้านไม่ถูกต้องหรือมีร้านที่เข้าร่วมน้อยเกินไป
+multi.store.checkout.payment.dispatched=ร้านนี้ได้ส่งคำขอชำระเงินผ่าน {0} แล้วและเปลี่ยนวิธีชำระเงินไม่ได้ กรุณากลับไปชำระให้เสร็จ หรือยกเลิกการสั่งซื้อหลายร้านนี้แล้วสั่งใหม่
 multi.store.checkout.group.accept.required=ต้องรับงานจัดส่งหลายร้านทั้งกลุ่มพร้อมกัน
 multi.store.checkout.batch.cancel.required=โปรดยกเลิกผ่านขั้นตอนคำสั่งซื้อหลายร้าน
 multi.store.checkout.admin.read.only=โปรดจัดการสถานะคำสั่งซื้อหลายร้านผ่านขั้นตอนของชุดคำสั่งซื้อ

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -24,3 +24,4 @@ multi.store.checkout.upgrade.required=Vui lòng cập nhật ứng dụng để
 multi.store.checkout.stores.excluded=Các cửa hàng sau không thể tham gia giao hàng đa cửa hàng: {0}. Số cửa hàng tham gia ít hơn 2, vui lòng điều chỉnh và thử lại.
 multi.store.checkout.excluded.outside.fence=ngoài phạm vi giao hàng tính từ cửa hàng đầu tiên
 multi.store.checkout.excluded.no.payment=không có phương thức thanh toán khả dụng
+multi.store.checkout.payment.dispatched=Cửa hàng này đã gửi yêu cầu thanh toán {0} nên không thể đổi phương thức thanh toán. Vui lòng quay lại hoàn tất thanh toán, hoặc hủy giao dịch nhiều cửa hàng này và đặt lại.

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -453,6 +453,7 @@ multi.store.config.conflict=配置已变化或尚未初始化,请刷新或先
 multi.store.checkout.disabled=多店结算暂未开放
 multi.store.checkout.upgrade.required=请更新应用后使用多店结算和配送组功能
 multi.store.checkout.invalid=多店结算请求无效或参与店铺不足
+multi.store.checkout.payment.dispatched=该店已发起{0}支付请求,完成前不能更换支付方式;请返回完成支付,或取消本次多店结算重新下单
 multi.store.checkout.group.accept.required=多店配送组必须整组接单
 multi.store.checkout.batch.cancel.required=请通过多店批次流程取消订单
 multi.store.checkout.admin.read.only=多店子单不能在普通订单状态入口修改,请通过批次流程处理

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -452,6 +452,7 @@ multi.store.config.conflict=設定已變更或尚未初始化,請重新整理
 multi.store.checkout.disabled=多店結算暫未開放
 multi.store.checkout.upgrade.required=請更新應用程式後使用多店結算與配送組功能
 multi.store.checkout.invalid=多店結算請求無效或參與店鋪不足
+multi.store.checkout.payment.dispatched=該店已發起{0}付款請求,完成前不能更換付款方式;請返回完成付款,或取消本次多店結算重新下單
 multi.store.checkout.group.accept.required=多店配送組必須整組接單
 multi.store.checkout.batch.cancel.required=請透過多店批次流程取消訂單
 multi.store.checkout.admin.read.only=多店子單不能在一般訂單狀態入口修改,請透過批次流程處理

+ 171 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCaptureVerifierTest.java

@@ -0,0 +1,171 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.omgpay.OmgPaymentQueryService;
+import com.ruoyi.app.omgpay.dto.OmgQueryPaymentResponse;
+import com.ruoyi.app.pay.LinePayService;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutCaptureVerifierTest {
+    private static final String ALREADY_PAID = "no.pay.switch.already.paid";
+    private static final String DISPATCHED = "multi.store.checkout.payment.dispatched";
+
+    private MockedStatic<MessageUtils> messages;
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final IPosOrderLinePaymentService linePayments = mock(IPosOrderLinePaymentService.class);
+    private final IOmgPaymentAttemptService omgAttempts = mock(IOmgPaymentAttemptService.class);
+    private final OmgPaymentQueryService omgQueryService = mock(OmgPaymentQueryService.class);
+    private final LinePayService linePayService = mock(LinePayService.class);
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        messages.when(() -> MessageUtils.message(anyString(), any())).thenAnswer(call -> call.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private MultiStoreCheckoutCaptureVerifier subject() {
+        return new MultiStoreCheckoutCaptureVerifier(checkout, orders, linePayments,
+                omgAttempts, omgQueryService, linePayService);
+    }
+
+    private PosOrderLinePayment linePayment(String status) {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setStatus(status);
+        return payment;
+    }
+
+    private OmgQueryPaymentResponse omgStatus(String status) {
+        return new OmgQueryPaymentResponse(status, null, null, null, 0, null, null, null, null, null);
+    }
+
+    @Test
+    void linePaidOrCapturedStopsWithAlreadyPaid() {
+        when(linePayments.getByDdId("DD1")).thenReturn(List.of(linePayment("PAID")));
+        assertEquals(ALREADY_PAID, assertThrows(ServiceException.class,
+                () -> subject().verifyNoCapturedPayment("DD1", "3")).getMessage());
+
+        when(linePayments.getByDdId("DD1")).thenReturn(List.of(linePayment("PENDING")));
+        when(linePayService.isCapturedForSwitch(any(PosOrderLinePayment.class))).thenReturn(true);
+        assertEquals(ALREADY_PAID, assertThrows(ServiceException.class,
+                () -> subject().verifyNoCapturedPayment("DD1", "3")).getMessage());
+    }
+
+    @Test
+    void lineManualReviewOrInconclusiveQueryKeepsDispatchedRefusal() {
+        when(linePayments.getByDdId("DD1")).thenReturn(List.of(linePayment("MANUAL_REVIEW")));
+        assertEquals(DISPATCHED, assertThrows(ServiceException.class,
+                () -> subject().verifyNoCapturedPayment("DD1", "3")).getMessage());
+
+        when(linePayments.getByDdId("DD1")).thenReturn(List.of(linePayment("PENDING")));
+        when(linePayService.isCapturedForSwitch(any(PosOrderLinePayment.class)))
+                .thenThrow(new IllegalStateException("credential missing"));
+        assertEquals(DISPATCHED, assertThrows(ServiceException.class,
+                () -> subject().verifyNoCapturedPayment("DD1", "3")).getMessage());
+    }
+
+    @Test
+    void lineTerminalUnpaidAndOmgVerifiedUnpaidAllowPassing() {
+        when(linePayments.getByDdId("DD1")).thenReturn(List.of(linePayment("FAILED")));
+        when(linePayService.isCapturedForSwitch(any(PosOrderLinePayment.class))).thenReturn(false);
+        OmgPaymentAttempt created = mock(OmgPaymentAttempt.class);
+        when(omgAttempts.selectActiveCreatedByDdId("DD1")).thenReturn(created);
+        when(omgQueryService.reconcile(created)).thenReturn(omgStatus("UNPAID"));
+        assertDoesNotThrow(() -> subject().verifyNoCapturedPayment("DD1", "2"));
+    }
+
+    @Test
+    void omgLocalPaidOrGatewayUnknownStopsSwitch() {
+        when(linePayments.getByDdId("DD1")).thenReturn(List.of());
+        when(omgAttempts.selectPaidByDdId("DD1")).thenReturn(mock(OmgPaymentAttempt.class));
+        assertEquals(ALREADY_PAID, assertThrows(ServiceException.class,
+                () -> subject().verifyNoCapturedPayment("DD1", "2")).getMessage());
+
+        when(omgAttempts.selectPaidByDdId("DD1")).thenReturn(null);
+        OmgPaymentAttempt created = mock(OmgPaymentAttempt.class);
+        when(omgAttempts.selectActiveCreatedByDdId("DD1")).thenReturn(created);
+        when(omgQueryService.reconcile(created)).thenReturn(omgStatus("UNKNOWN"));
+        assertEquals(DISPATCHED, assertThrows(ServiceException.class,
+                () -> subject().verifyNoCapturedPayment("DD1", "2")).getMessage());
+    }
+
+    @Test
+    void preLockCheckSkipsGatewayWhenNoInFlightAttempts() {
+        arrangeMember(0L);
+        assertEquals(0, subject().verifyInFlightBeforeLock(99L, 100L, 42L));
+        verify(linePayments, never()).getByDdId(anyString());
+        verify(omgQueryService, never()).reconcile(any(OmgPaymentAttempt.class));
+    }
+
+    @Test
+    void preLockCheckVerifiesGatewayAndReturnsVerifiedCount() {
+        arrangeMember(2L);
+        when(linePayments.getByDdId("DD100")).thenReturn(List.of());
+        when(omgAttempts.selectActiveCreatedByDdId("DD100")).thenReturn(null);
+        assertEquals(2, subject().verifyInFlightBeforeLock(99L, 100L, 42L));
+        verify(linePayments).getByDdId("DD100");
+    }
+
+    @Test
+    void preLockCheckTreatsForeignBatchOrMissingOrderAsUnverified() {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(99L); batch.setUserId(42L);
+        when(checkout.selectBatchById(99L)).thenReturn(batch);
+        when(checkout.selectMembersByBatchId(99L)).thenReturn(List.of());
+        assertEquals(0, subject().verifyInFlightBeforeLock(99L, 100L, 42L));
+
+        arrangeMember(1L);
+        when(orders.selectById(100L)).thenReturn(null);
+        assertEquals(0, subject().verifyInFlightBeforeLock(99L, 100L, 42L));
+        verify(linePayments, never()).getByDdId(anyString());
+    }
+
+    private void arrangeMember(long attempts) {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(99L); batch.setUserId(42L);
+        batch.setCheckoutStatus("PENDING");
+        batch.setOperationDeadlineAt(LocalDateTime.now().plusMinutes(2));
+        when(checkout.selectBatchById(99L)).thenReturn(batch);
+        OrderCheckoutMember member = new OrderCheckoutMember();
+        member.setId(1L); member.setBatchId(99L); member.setPosOrderId(100L);
+        member.setStoreId(1L); member.setPaymentState("ONLINE_PENDING"); member.setPayType("3");
+        when(checkout.selectMembersByBatchId(99L)).thenReturn(List.of(member));
+        when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(attempts);
+        PosOrder order = new PosOrder();
+        order.setId(100L); order.setDdId("DD100");
+        when(orders.selectById(100L)).thenReturn(order);
+    }
+}

+ 31 - 4
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCodServiceTest.java

@@ -38,11 +38,13 @@ class MultiStoreCheckoutCodServiceTest {
     private final PaymentMethodGateService payments = mock(PaymentMethodGateService.class);
     private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
     private final MultiStoreCheckoutActivationTrigger activationTrigger = mock(MultiStoreCheckoutActivationTrigger.class);
+    private final MultiStoreCheckoutCaptureVerifier captures = mock(MultiStoreCheckoutCaptureVerifier.class);
 
     @BeforeEach
     void setUp() {
         messages = mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        messages.when(() -> MessageUtils.message(anyString(), any())).thenAnswer(call -> call.getArgument(0));
     }
 
     @AfterEach
@@ -51,7 +53,7 @@ class MultiStoreCheckoutCodServiceTest {
     }
 
     private MultiStoreCheckoutCodService subject() {
-        return new MultiStoreCheckoutCodService(checkout, orders, stores, payments, cancellation, activationTrigger);
+        return new MultiStoreCheckoutCodService(checkout, orders, stores, payments, cancellation, activationTrigger, captures);
     }
 
     @Test
@@ -77,7 +79,7 @@ class MultiStoreCheckoutCodServiceTest {
     }
 
     @Test
-    void expiryAndUnresolvedOnlineAttemptCannotBeTurnedIntoCod() {
+    void expiryRejectedButVerifiedUnpaidAttemptCanBecomeCod() {
         arrange(List.of(member(1, "UNSELECTED"), member(2, "UNSELECTED")),
                 LocalDateTime.now().minusSeconds(1));
         assertThrows(ServiceException.class, () -> subject().confirm(99L, 100L, 42L));
@@ -86,11 +88,35 @@ class MultiStoreCheckoutCodServiceTest {
                 LocalDateTime.now().plusMinutes(1));
         checkout.selectMembersByBatchId(99L).get(0).setPayType("3");
         orders.selectById(100L).setPayType("3");
+        orders.selectById(100L).setPayStatus(0L);
         when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(1L);
-        assertThrows(ServiceException.class, () -> subject().confirm(99L, 100L, 42L));
+        when(captures.verifyInFlightBeforeLock(99L, 100L, 42L)).thenReturn(1);
+        when(checkout.switchMemberPayment(eq(1L), eq("ONLINE_PENDING"), eq("3"),
+                eq("COD_CONFIRMED"), eq("1"), any())).thenReturn(1);
+        when(checkout.switchOrderPayment(100L, "3", 0L, "1", 1L)).thenReturn(1);
+
+        assertEquals("PENDING", subject().confirm(99L, 100L, 42L));
+        verify(captures, org.mockito.Mockito.times(2)).verifyInFlightBeforeLock(99L, 100L, 42L);
         verify(checkout, never()).activateIfPending(eq(99L), any());
     }
 
+    @Test
+    void codSwitchStopsWhenGatewayCaptureFound() {
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                LocalDateTime.now().plusMinutes(1));
+        checkout.selectMembersByBatchId(99L).get(0).setPayType("3");
+        orders.selectById(100L).setPayType("3");
+        orders.selectById(100L).setPayStatus(0L);
+        when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(1L);
+        org.mockito.Mockito.doThrow(new ServiceException("no.pay.switch.already.paid"))
+                .when(captures).verifyInFlightBeforeLock(99L, 100L, 42L);
+
+        ServiceException alreadyPaid = assertThrows(ServiceException.class, () -> subject().confirm(99L, 100L, 42L));
+        assertEquals("no.pay.switch.already.paid", alreadyPaid.getMessage());
+        verify(checkout, never()).switchMemberPayment(eq(1L), eq("ONLINE_PENDING"), eq("3"),
+                eq("COD_CONFIRMED"), eq("1"), any());
+    }
+
     @Test
     void selectedOnlineMethodCanBecomeCodOnlyBeforeGatewayAttempt() {
         OrderCheckoutMember online = member(1, "ONLINE_PENDING");
@@ -125,11 +151,12 @@ class MultiStoreCheckoutCodServiceTest {
         OrderCheckoutBatch batch = new OrderCheckoutBatch();
         batch.setId(99L); batch.setUserId(42L); batch.setCheckoutStatus("PENDING");
         batch.setOperationDeadlineAt(deadline); batch.setFinalDeadlineAt(deadline.plusMinutes(5));
+        when(checkout.selectBatchById(99L)).thenReturn(batch);
         when(checkout.selectBatchByIdForUpdate(99L)).thenReturn(batch);
         when(checkout.selectMembersByBatchId(99L)).thenReturn(members);
         for (OrderCheckoutMember member : members) {
             PosOrder order = new PosOrder(); order.setId(member.getPosOrderId());
-            order.setUserId(42L); order.setMdId(member.getStoreId());
+            order.setUserId(42L); order.setMdId(member.getStoreId()); order.setDdId("DD" + member.getPosOrderId());
             when(orders.selectById(member.getPosOrderId())).thenReturn(order);
             PosStore store = new PosStore(); store.setId(member.getStoreId().intValue());
             store.setUserId(member.getStoreId());

+ 34 - 5
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionServiceTest.java

@@ -20,6 +20,7 @@ import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
@@ -33,11 +34,13 @@ class MultiStoreCheckoutOnlineSelectionServiceTest {
     private final PosOrderMapper orders = mock(PosOrderMapper.class);
     private final PosStoreMapper stores = mock(PosStoreMapper.class);
     private final PaymentMethodGateService payments = mock(PaymentMethodGateService.class);
+    private final MultiStoreCheckoutCaptureVerifier captures = mock(MultiStoreCheckoutCaptureVerifier.class);
 
     @BeforeEach
     void setUp() {
         messages = mockStatic(MessageUtils.class);
         messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+        messages.when(() -> MessageUtils.message(anyString(), any())).thenAnswer(call -> call.getArgument(0));
     }
 
     @AfterEach
@@ -46,7 +49,7 @@ class MultiStoreCheckoutOnlineSelectionServiceTest {
     }
 
     private MultiStoreCheckoutOnlineSelectionService subject() {
-        return new MultiStoreCheckoutOnlineSelectionService(checkout, orders, stores, payments);
+        return new MultiStoreCheckoutOnlineSelectionService(checkout, orders, stores, payments, captures);
     }
 
     @Test
@@ -63,15 +66,40 @@ class MultiStoreCheckoutOnlineSelectionServiceTest {
     }
 
     @Test
-    void rejectsExpiredSelectionAndUnresolvedAttemptMethodSwitch() {
+    void rejectsExpiredSelectionButAllowsSwitchAfterGatewayVerifiedUnpaid() {
         arrange("UNSELECTED", null, LocalDateTime.now().minusSeconds(1));
         assertThrows(ServiceException.class, () -> subject().select(99L, 100L, 42L, "3"));
         verify(checkout, never()).updateMemberOnlinePending(1L, "3");
 
         arrange("ONLINE_PENDING", "3", LocalDateTime.now().plusMinutes(2));
         when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(1L);
-        assertThrows(ServiceException.class, () -> subject().select(99L, 100L, 42L, "2"));
-        verify(checkout, never()).updateMemberOnlinePending(1L, "2");
+        when(captures.verifyInFlightBeforeLock(99L, 100L, 42L)).thenReturn(1);
+        when(checkout.switchMemberPayment(1L, "ONLINE_PENDING", "3", "ONLINE_PENDING", "2", null)).thenReturn(1);
+        when(checkout.switchOrderPayment(100L, "3", 0L, "2", 0L)).thenReturn(1);
+
+        assertEquals("ONLINE_PENDING", subject().select(99L, 100L, 42L, "2"));
+        verify(captures, org.mockito.Mockito.times(2)).verifyInFlightBeforeLock(99L, 100L, 42L);
+        verify(checkout).switchOrderPayment(100L, "3", 0L, "2", 0L);
+    }
+
+    @Test
+    void propagatesAlreadyPaidAndRefusesAttemptsGrownAfterVerification() {
+        arrange("ONLINE_PENDING", "3", LocalDateTime.now().plusMinutes(2));
+        when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(1L);
+        org.mockito.Mockito.doThrow(new ServiceException("no.pay.switch.already.paid"))
+                .when(captures).verifyInFlightBeforeLock(99L, 100L, 42L);
+        ServiceException alreadyPaid = assertThrows(ServiceException.class,
+                () -> subject().select(99L, 100L, 42L, "2"));
+        assertEquals("no.pay.switch.already.paid", alreadyPaid.getMessage());
+        verify(checkout, never()).switchMemberPayment(1L, "ONLINE_PENDING", "3", "ONLINE_PENDING", "2", null);
+
+        arrange("ONLINE_PENDING", "3", LocalDateTime.now().plusMinutes(2));
+        when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(2L);
+        org.mockito.Mockito.doReturn(1).when(captures).verifyInFlightBeforeLock(99L, 100L, 42L);
+        ServiceException dispatched = assertThrows(ServiceException.class,
+                () -> subject().select(99L, 100L, 42L, "2"));
+        assertEquals("multi.store.checkout.payment.dispatched", dispatched.getMessage());
+        verify(checkout, never()).switchOrderPayment(100L, "3", 0L, "2", 0L);
     }
 
     @Test
@@ -108,6 +136,7 @@ class MultiStoreCheckoutOnlineSelectionServiceTest {
         OrderCheckoutBatch batch = new OrderCheckoutBatch();
         batch.setId(99L); batch.setUserId(42L); batch.setCheckoutStatus("PENDING");
         batch.setOperationDeadlineAt(deadline);
+        when(checkout.selectBatchById(99L)).thenReturn(batch);
         when(checkout.selectBatchByIdForUpdate(99L)).thenReturn(batch);
         OrderCheckoutMember member = new OrderCheckoutMember();
         member.setId(1L); member.setBatchId(99L); member.setPosOrderId(100L);
@@ -115,7 +144,7 @@ class MultiStoreCheckoutOnlineSelectionServiceTest {
         when(checkout.selectMembersByBatchId(99L)).thenReturn(List.of(member));
         PosOrder order = new PosOrder();
         order.setId(100L); order.setUserId(42L); order.setMdId(1L);
-        order.setPayStatus(0L); order.setState(0L); order.setPayType(payType);
+        order.setPayStatus(0L); order.setState(0L); order.setPayType(payType); order.setDdId("DD100");
         when(orders.selectById(100L)).thenReturn(order);
         PosStore store = new PosStore();
         store.setId(1); store.setUserId(1L);

+ 5 - 5
specs/036-multi-store-checkout/contracts/api.md

@@ -7,9 +7,9 @@
 | 方法与路径 | 请求关键字段 | 成功响应 | 拒绝条件 |
 |---|---|---|---|
 | `POST /quote` | 地址 ID、顺序候选店与商品/规格/数量、同骑手、各店即时/预约时段 | `quoteToken`、有效至、锚点/围栏、参与店及逐店原运费/折后运费/商品价/实付/可选支付方式、圈外店和不兼容原因、规则版本 | 店/商品/配送资格不合格、支付方式仅转账、报价配置非法;圈外店仅报告不创建 |
-| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]` | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;含线上为 `PENDING`,全到付为 `ACTIVE` | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/不可用、权限不符;同用户同 requestKey 返回同批或拒绝载荷冲突 |
+| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]` | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;含未付线上子单为 `PENDING`,全到付为 `ACTIVE`,但 `ACTIVE` 不等于已开放 | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/夹带非参与店/不可用、权限不符;同用户同 requestKey 且载荷相同返回原批次,否则拒绝冲突 |
 | `GET /batches/{batchId}` | token + 路径批次 ID | 批次/逐店支付选择、真实实收、到付确认、退款与履约状态、截止时间、关联订单 | 非本人批次拒绝,不暴露其他用户父/子/组 |
-| `POST /batches/members/payment` | body `{batchId,orderId,payType,clientRequestId}`,`payType` = 1/2/3/5 | 新批次的安全改选:到付返回批次状态,线上返回 `ONLINE_PENDING`;网关创建仍走原接口 | 批次已生效/过操作截止、该店已有任何网关尝试或实收、商家不可用、归属不符;6 转账始终不支持 |
+| `POST /batches/members/payment` | body `{batchId,orderId,payType,clientRequestId}`,`payType` = 1/2/3/5 | 新批次的安全改选:到付返回批次状态,线上返回 `ONLINE_PENDING`;网关创建仍走原接口 | 批次已生效/过操作截止、该店在途支付已实收或渠道核实未通过、商家不可用、归属不符;6 转账始终不支持。存在在途尝试时服务端先逐笔向渠道核实,确定未付(OMG UNPAID/FAILED、LINE 未捕获)即放行改选 |
 | `POST /batches/cancel` | body `{batchId,reason:"USER_CANCEL",clientRequestId}` | 当前入口只返回是否成功关闭未生效批次的布尔值;退款真实进度待详情接口返回 | 已 `ACTIVE` 须走子单取消规则;退款处理中不伪称退款成功 |
 | `POST /batches/members/cancel` | body `{batchId,orderId,reason:"USER_CANCEL",clientRequestId}` | 当前入口只返回是否成功取消该子单;退款真实进度待详情接口返回,其他成员费用快照不变 | 未生效不允许单店取消;骑手已接/取餐拒绝用户取消;并发接组只能一方成功 |
 
@@ -33,7 +33,7 @@ LINE 创建使用 `{ddId:orderNo}`;OMG 创建使用 `{orderId:orderNo,paymentM
 
 ## 时间和兼容示例
 
-14:00 创建:14:15 前可发起线上/确认到付;14:14 已实际发起的线上尝试可在 14:20 前完成付款及可信确认;14:16 不可新发起或改到付;14:20 仍未全部就绪则关闭,迟到实收只退款不激活。截止在创建时快照,不因配置变更或重试延长。
+14:00 创建:建批请求已逐店选方式,到付当场确认;14:15 前可发起线上支付,未有网关尝试的待生效成员可合法改选。14:14 已实际发起的线上尝试可在 14:20 前完成付款及可信确认;14:16 不可新发起、重试或改选;14:20 仍未全部就绪则关闭,迟到实收只退款不激活。截止在创建时快照,不因配置变更或重试延长。
 
 普通单仍调用现有 `/createOrder`、LINE/OMG/到付入口并保持原校验与状态;新接口不得仅凭客户端传入的批次号放宽原入口。预约订单不重定义旧时间语义;2026-10-16 已确认新多店接入 037 现有无人接单规则,按父批次统一生效时间及实际开放时点判断,不以逐子单支付时间开始。
 
@@ -46,7 +46,7 @@ LINE 创建使用 `{ddId:orderNo}`;OMG 创建使用 `{orderId:orderNo,paymentM
 
 ## 组合列表、角标和详情的实施目标(2026-10-19)
 
-本节定义前端与 Claude Code 的目标契约,尚未上线。新用户端调用 `GET /user/cards?page=1&size=10&tab=all`,同一外卖订单列表返回普通单与 036 批次卡;新骑手端调用 `GET /rider/tasks?page=1&size=10&tab=newTask&longitude=...&latitude=...`,返回普通外卖任务与同骑手配送组卡。骑手角标调用 `GET /rider/taskCount?longitude=...&latitude=...`;用户各主阶段数量如页面需要,由 `GET /user/cardCount` 同口径返回。两端均使用 token 确认身份,参数显式声明,不接受传入用户/骑手 ID。`GET /batches/{batchId}` 与 `GET /groups/{groupId}` 为组合详情;普通单点击仍走现有详情。退款/售后是补充筛选,可以与一个主流程页签交叉,不改变唯一主阶段。
+本节定义前端接入契约,尚未上线。新用户端调用 `GET /user/cards?page=1&size=10&tab=all`,同一外卖订单列表返回普通单与 036 批次卡;新骑手端调用 `GET /rider/tasks?page=1&size=10&tab=newTask&longitude=...&latitude=...`,返回普通外卖任务与同骑手配送组卡。骑手角标调用 `GET /rider/taskCount?longitude=...&latitude=...`;用户各主阶段数量如页面需要,由 `GET /user/cardCount` 同口径返回。两端均使用 token 确认身份,参数显式声明,不接受传入用户/骑手 ID。`GET /batches/{batchId}` 与 `GET /groups/{groupId}` 为组合详情;普通单点击仍走现有详情。退款/售后是补充筛选,可以与一个主流程页签交叉,不改变唯一主阶段。骑手列表/角标方法已归原骑手控制器,对外 URL 与响应不变;待抢组须实际开放并写入 `openedAt` 才可见,不能仅凭 `ACTIVE` 判断。
 
 统一列表响应沿现有骑手列表习惯使用 `data.records`、`data.total`、`data.current`、`data.size`;`page` 从 1 起,`size` 限定为正数并设置服务端上限。`total` 是过滤后的卡片数,不是子单数。先按归属、Tab、开放/资格和组合关系得到卡片集合,再排序、分页,最后批量填充本页成员摘要;不能先分页 `pos_order` 再在 App 拼组。相同排序键以 `cardKey` 稳定打破平局;新任务有有效骑手坐标时按首店距离升序,其余按现有普通外卖 Tab 的创建时间方向。角标与列表共享同一过滤和组去重规则,刷新/并发变化以服务端最新结果为准。
 
@@ -66,7 +66,7 @@ LINE 创建使用 `{ddId:orderNo}`;OMG 创建使用 `{orderId:orderNo,paymentM
 
 旧用户 `/system/order/getorderlist` 与旧骑手单笔列表/角标均不展示 036 子单,不能先对子单分页再让旧 App 拼组。旧 `/system/userOrder/getParentOrderDetail` 对真实 036 父单返回 `UPGRADE_REQUIRED`,新 App 应调用带归属校验的 `GET /batches/{batchId}`;普通父单仍走原接口。
 
-用户主 Tab:`all/pending/active/completed/cancelled`;有退款或售后记录时另支持 `refund` 筛选,同一卡可同时在 `active` 和 `refund` 查询中出现,但在 `all` 仅出现一次。骑手沿原外卖 `newTask/toPickup/delivering/completed/cancelled/refund` 参数:未取完的有效子单存在时始终为 `toPickup`,即使另一子单已送达;有效子单均已取且仍有待送/待处置时为 `delivering`;全部有效成员结束后进入历史。待抢组按 `ACTIVE`、原预约开放和资格过滤,且只计一张卡。业务状态、可操作性和角标由服务端返回,不以 App 页签推导权限。
+用户主 Tab:`all/pending/active/completed/cancelled`;有退款或售后记录时另支持 `refund` 筛选,同一卡可同时在 `active` 和 `refund` 查询中出现,但在 `all` 仅出现一次。骑手沿原外卖 `newTask/toPickup/delivering/completed/cancelled/refund` 参数:未取完的有效子单存在时始终为 `toPickup`,即使另一子单已送达;有效子单均已取且仍有待送/待处置时为 `delivering`;全部有效成员结束后进入历史。待抢组按 `ACTIVE`、实际开放 `openedAt`、原预约时点和资格过滤,且只计一张卡。业务状态、可操作性和角标由服务端返回,不以 App 页签推导权限。
 
 用户已确认旧版 App 须升级后才能使用多店:发布前确认两端最低兼容版本和强制更新入口,配置开关在两端准备就绪前保持关闭。旧版不能发起新批次、继续逐店收银或承接组;已有批次/已接组遇旧版须引导升级,旧单笔支付/取消/接单接口仍对新批次或组成员实施服务端保护。普通单、历史单、单店转账、自取及夜市自配送继续沿旧接口和原规则;普通平台配送预约只补既有时点开放扫描,不套 036 截止。升级策略只是兼容门禁,不替代 token、订单归属、支付事实、版本和骑手锁校验。
 

+ 7 - 7
specs/036-multi-store-checkout/data-model.md

@@ -1,6 +1,6 @@
 # 多店结算数据模型(实施版)
 
-日期:2026-10-13。仅适用于服务端新建的多店批次;未关联批次的普通及历史订单保持原语义。金额统一以整数 TWD 存业务快照,渠道需要最小货币单位时只在网关适配层转换,不回写订单金额。
+更新:2026-10-21。仅适用于服务端新建的多店批次;未关联批次的普通及历史订单保持原语义。金额统一以整数 TWD 存业务快照,渠道需要最小货币单位时只在网关适配层转换,不回写订单金额。
 
 ## 1. 持久化实体
 
@@ -9,11 +9,11 @@
 | `order_checkout_config` | 固定 `id=1`;`enabled`、`fence_meters`、`max_stores`、`confirmation_buffer_minutes`、`tiers_json`、`version` | 平台全局配置专表,固定项用独立列,动态折扣阶梯用 JSON;版本用于报价指纹和后台乐观锁,不接 `sys_config`。 |
 | `order_checkout_batch` | `id`;`parent_dd_id` 唯一;`(user_id,request_key)` 唯一;专用 `checkout_status`、`version`、`create_time`、`operation_deadline_at`、`final_deadline_at`、`confirmation_buffer_minutes` | 服务端批次标识、幂等创建、15 分钟发起截止和创建时缓冲快照;专用状态只表示统一履约资格,不代表退款到账。 |
 | `order_checkout_batch` 规则列 | `anchor_store_id`、`address_id`、`same_rider`、`fence_meters`、`max_store_count`、`rule_version`、`matched_min_stores`、`discount_ratio`、`total_amount` | 创建时冻结本次真实参与集合与计价规则;配置变化不追改。 |
-| `order_checkout_batch` 生效时间(待 T023 实现) | `activated_at` | 最后有效成员合法就绪、批次首次原子生效时写入,不能用某子单实收时间或后续 `update_time` 代替;生效后不因部分取消/重试改写。 |
+| `order_checkout_batch` 生效时间 | `activated_at` | 最后有效成员合法就绪(全到付可在建批时就绪)、批次首次原子生效时写入,不能用某子单实收时间或后续 `update_time` 代替;生效后不因部分取消/重试改写。 |
 | `order_checkout_member` | `id`;`pos_order_id` 唯一;`(batch_id,store_id)` 唯一;`sort_no`、`payment_state`、`pay_type`、`ready_at`、`accepted_payment_attempt_id`、`original_freight`、`discounted_freight`、`food_amount`、`amount`、`coupon_id` | 每店子单金额和付款/到付确认事实。`accepted_payment_attempt_id` 固定首笔获准线上实收,重放同一交易幂等、第二笔实收进入退款;到付确认不算到账。 |
 | `order_checkout_payment_attempt` | `id`;`(channel,merchant_order_no)` 唯一;`member_id`、`pay_type`、`state`、`requested_at`、`dispatched_at`、`gateway_transaction_id`、`gateway_paid_at`、`verified_at` | 保存第 15 分钟前服务端发起的证据及其后缓冲内可信结果。请求预留不是实际发起;回调、查询及重试按原交易号对账。 |
 | `order_delivery_group` | `id`;`batch_id` 唯一;`rider_id`、`status`、`version` | 仅同骑手批次创建;组是一个抢单任务,不是合并收款或强制同步取送状态。 |
-| `order_delivery_group` 开放时间(待 T023 实现) | `opened_at` | 首次实际开放给骑手时原子写入并创建通知意图,不能从预约计划时点推算,也不因重试/部分取消改写。无配送组成员使用原 `display_time` 记录首次实际开放。 |
+| `order_delivery_group` 开放时间 | `opened_at` | 首次实际开放给骑手时原子写入并更新持久开放事件,不能从批次`ACTIVE`或预约计划时点推算,也不因重试/部分取消改写。即时/已到点批次在激活事务提交后立即尝试开放;分钟任务处理预约到点、失败重试和重启补偿。 |
 | `order_checkout_outbox` | `id`;`(event_type,aggregate_id)` 唯一;`status`、`next_retry_at`、`attempts` | `PENDING` 待旧时点开放,`OPENED` 已原子开放但尚未入推送队列,`SENDING` 消费者已认领,`SENT` 已提交到既有异步推送通道(不代表设备送达);失败及超时认领可重试,取消后不可再开放。推送载荷带 `eventId/groupId` 供新 App 去重和跳转。 |
 | `order_checkout_refund_intent` | `id`;`(channel,merchant_order_no)` 唯一;`member_id`、`reason`、`amount`、`gateway_transaction_id`、`status`、`next_retry_at` | 每笔真实渠道交易最多一个退款意图,避免不同取消原因重复执行;意图创建不等于到账。到付未收不建网关退款意图。 |
 
@@ -21,16 +21,16 @@
 
 不在 `pos_order` 增加多店标志:以 `order_checkout_member.pos_order_id` 的唯一关联作为不可伪造服务端标识;老订单无关联即沿旧流程。容量统计与组列表通过成员表关联 `order_delivery_group`,避免为纯读取再加一列。父单 `order_parent` 沿用现有结构和子单号关联,其 `orderStatus` 不复用为批次生效或实收状态。
 
-2026-10-16 已确认的时间列是目标模型,当前 Entity/Mapper/SQL 尚待 T023 一批实现;本文更新不代表数据库已有这些列。地址与坐标使用建批时复制到父/子订单的快照;`address_id` 只保留原地址关联,不在付款、列表、取送时回读地址簿覆盖快照,旧单笔改址入口也不能改组成员。
+时间列已纳入036实体、Mapper与登记的SQL;测试库已迁移,生产库仍需开发者手动执行,本文不代表生产库已有这些列。地址与坐标使用建批时复制到父/子订单的快照;`address_id`只保留原地址关联,不在付款、列表、取送时回读地址簿覆盖快照,旧单笔改址入口也不能改组成员。
 
 ## 2. 状态和不变量
 
 - 批次:`PENDING → ACTIVE` 或 `PENDING → CANCELLED`;`CANCELLED` 永不重开。到最终确认截止仍未全部合法就绪时关批次,截止后收到真实付款也只进入退款/对账,不转 `ACTIVE`。
-- 成员:`UNSELECTED → ONLINE_PENDING → ONLINE_PAID`,或 `UNSELECTED → COD_CONFIRMED`;渠道失败/未知不得标记就绪。未决在线尝试不能改选到付。`ONLINE_PAID` 与 `COD_CONFIRMED` 均计就绪,但仅前者计已实收。
-- 发起线上尝试、重试、换方式、到付确认均要求 `now < operation_deadline_at`;已有实际发起且可核实的在线尝试允许在 `now <= final_deadline_at` 完成。缓冲内不新建尝试。
+- 成员:建批时须逐店选择,初态为`ONLINE_PENDING`或`COD_CONFIRMED`,新批次不产生`UNSELECTED`;线上可信实收后`ONLINE_PAID`。历史`UNSELECTED`仅供兼容展示。批次仍`PENDING`、未过操作截止时可安全改选:该成员从未有网关尝试,或在途尝试已逐笔向渠道核实确定未付(同 661 口径,OMG 为 UNPAID/FAILED、LINE 为未捕获);核实已收款或结果未知拒绝,核实后迟到实收由重复实收退款兜底;渠道失败/未知不得标记就绪。`ONLINE_PAID`与`COD_CONFIRMED`均计就绪,但仅前者计已实收。
+- 发起线上尝试、重试、方式改选均要求 `now < operation_deadline_at`;到付在建批时确认,合法改选到付亦受该截止限制。已有实际发起且可核实的在线尝试允许在 `now <= final_deadline_at` 完成。缓冲内不新建尝试。
 - 同一父批次读写必须以批次行锁或状态版本 CAS 保护;激活/取消必须在同一事务内重新读取全部成员事实。外部网关 HTTP 不持有数据库事务锁。
 - 组接单:骑手锁 → 组行锁 → 成员子单按 ID 加锁;资格、现有任务容量、组门店上限和全部成员可接状态在锁内校验;全部分配或全部失败。单笔接单入口发现组成员必须拒绝拆抢。
-- 子单创建时写当前业务字段快照,但 `isDisplay` 不等于批次准入;骑手列表、直接接单、商家操作及新单通知均校验批次已 `ACTIVE` 和预约旧时点已开放。普通订单不套批次期限。
+- 子单创建时写已选支付方式及业务快照,但`isDisplay`不等于批次准入;骑手待抢列表、直接接单、商家操作及新单通知均校验批次已`ACTIVE`、预约旧时点和组`opened_at`等实际开放事实。普通订单不套批次期限。
 - 组任务只有全部有效成员都履约结束或合法取消后释放。独立取餐、独立送达、独立结算/通知按子单进行,部分取消不重算余单折扣。
 - 已确认接入 037:无人接单基准为 `max(activated_at, opened_at, 预约开始)`(即时忽略预约);部分子单先付款不启动计时,部分取消不重置。超时与接组在统一锁/事务规则下互斥,未承接组取消剩余有效成员并关联现有渠道退款事实;原有效期内且仍绑定本子单的券 CAS 返还,不延长有效期。无组成员使用父批次生效与本人实际开放时间按独立订单判断。
 - 尚未生效预约批次在激活时已到/超过原结束时间则转 `CANCELLED` 并退款,不自动改即时;恢复扫描补处理。已经 `ACTIVE` 的任务不因预约结束由此路径取消,继续依履约及 037 无人接单规则处理。

+ 39 - 20
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -2,7 +2,7 @@
 
 本文档供用户端 App 和骑手端 App 接入多店合并下单功能(036)使用,以当前后端实现为准。多店功能的全部业务规则均写在本文档内,接入不需要再查阅其他契约文件。文档只描述接口契约和业务规则,不包含 App 前端实现代码;本仓库不修改 App 源码。平台后台、商家端的 036 管理接口不在本文范围。
 
-> 更新时间:2026-10-20。036 数据库迁移已在测试库执行并核对表结构;生产库尚未执行。平台多店开关仍关闭、6个相关定时任务保持暂停:接口已通过本地测试,真实支付/退款与App端到端联调尚未验证。测试库无需重复迁移;后续按联调计划部署兼容版本并开启配置和相应任务,生产库须另行执行迁移。
+> 更新时间:2026-10-21。036 数据库迁移已在测试库执行并核对表结构;生产库尚未执行。平台多店开关仍关闭、6个相关定时任务保持暂停:接口已通过本地测试,真实支付/退款与App端到端联调尚未验证。测试库无需重复迁移;后续按联调计划部署兼容版本并开启配置和相应任务,生产库须另行执行迁移。
 
 核心概念(先读这段):
 
@@ -25,7 +25,7 @@
 当前不包含(继续走原流程,036 不改动):
 
 - 普通单店外卖、闪送、自取、堂食现金、夜市自配送、单店转账的下单与取消。
-- 在线支付新渠道:线上付款仍调用现有 LINE/OMG 创建接口,036 只新增"选择支付方式"步骤。
+- 在线支付新渠道:建批时逐店提交 `payType`,线上付款仍调用现有 LINE/OMG 创建接口;首次付款不需要另调 5.3 的改选接口。
 - 多店改址:批次创建后收货地址冻结,换地址须取消后重新报价下单。
 
 ## 2. 通用约定
@@ -75,9 +75,10 @@
 ### 3.1 生命周期
 
 ```text
-报价(quote) → 建批并提交逐店 payType → 线上逐店支付 → 全部就绪 ⇒ ACTIVE(开放履约,生成配送组 WAITING)
+报价(quote) → 建批并提交逐店 payType(配送组创建为 WAITING)→ 线上子单逐店支付(如有)→ 全部就绪 ⇒ ACTIVE
                                   └→ 超时/整批取消/预约过期 ⇒ CANCELLED
-配送组:WAITING(待抢)→ ACCEPTED(已接,逐店取送)→ FINISHED / CANCELLED
+开放:ACTIVE 且即时/已到预约开放时点 → openedAt 有值、子单可见 → 骑手可抢
+配送组:WAITING(建批时即存在,openedAt 有值后可抢)→ ACCEPTED(已接,逐店取送)→ FINISHED / CANCELLED
 ```
 
 ### 3.2 批次状态 `checkoutStatus`
@@ -85,10 +86,10 @@
 | 值 | 含义 |
 |---|---|
 | `PENDING` | 未生效:处于逐店收银与缓冲核实期,订单不可履约、不可抢 |
-| `ACTIVE` | 已生效:全部子单合法就绪,开放履约,生成待抢配送组 |
+| `ACTIVE` | 全部子单合法就绪;配送组在建批时已创建,须等 `openedAt` 有值才进入骑手待抢列表 |
 | `CANCELLED` | 已关闭:未全部就绪超时、用户整批取消或预约过期关闭 |
 
-`PENDING` 不代表任何子单已付款;`ACTIVE` 之前骑手/商家不会收到履约新单通知。
+`PENDING` 可能已有部分子单实际付款,须逐店看 `paymentState`。`ACTIVE` 只表示批次就绪,不保证已经开放或推送:即时单/已到开放时点的预约单在事务提交后立即尝试开放;未到时点的预约单等待任务扫描。骑手端以组卡的 `openedAt` 和任务列表为准,不能仅凭 `checkoutStatus=ACTIVE` 显示可抢。
 
 ### 3.3 子单付款状态 `paymentState`
 
@@ -166,8 +167,8 @@
 ### 4.1 用户端
 
 1. 按顺序选择至少两家店与商品 → `POST /system/multiStoreCheckout/quote`。
-2. 展示参与店金额与 `excludedStores[]`(圈外店不创建订单,引导剔除或另行单店下单);用户确认参与店 → `POST /system/multiStoreCheckout/batches`,保存 `batchId` 与各店 `orderId/orderNo`。
-3. 逐店 `POST /system/multiStoreCheckout/batches/members/payment`:选到付即完成确认;选线上拿到 `ONLINE_PENDING` 后,用该店 `orderNo` 调原渠道创建接口发起付款。
+2. 展示参与店金额、各店 `availablePayTypes` 与 `excludedStores[]`;用户为**每家参与店**选择付款方式,按 `storeId` 组成 `paymentMethods` 后一次提交 `POST /system/multiStoreCheckout/batches`,保存 `batchId` 与各店 `orderId/orderNo`。圈外店不创建订单,引导剔除或另行单店下单。
+3. 建批返回的到付子单已是 `COD_CONFIRMED`,无需再次确认;线上子单为 `ONLINE_PENDING`,用该店 `orderNo` 调原渠道创建接口发起付款。仅在用户随后改选方式时调用 `POST /system/multiStoreCheckout/batches/members/payment`(见 5.3)。
 4. 支付回跳后刷新 `GET /system/multiStoreCheckout/batches/{batchId}`,以服务端状态为准。
 5. 订单列表用 `GET /user/cards` + `GET /user/cardCount`;不要调旧列表接口看多店单(旧接口不返回 036 子单/批次)。
 
@@ -246,16 +247,25 @@ POST /system/multiStoreCheckout/batches
 请求体:
 
 ```json
-{ "requestKey": "本次提交固定唯一键", "quoteToken": "报价返回值", "participatingStoreIds": [101, 102],
-  "paymentMethods": [{"storeId":101,"payType":"3"},{"storeId":102,"payType":"1"}] }
+{
+  "requestKey": "本次提交固定唯一键",
+  "quoteToken": "报价返回值",
+  "participatingStoreIds": [101, 102],
+  "paymentMethods": [
+    { "storeId": 101, "payType": "3" },
+    { "storeId": 102, "payType": "1" }
+  ]
+}
 ```
 
 | 字段 | 类型 | 必填 | 说明 |
 |---|---|---:|---|
-| `requestKey` | string | 是 | 幂等键:网络重试沿用同一键返回同一批次;换新键会创建新批次 |
+| `requestKey` | string | 是 | 幂等键:网络重试须沿用同一键、同一报价与逐店支付选择,才返回原批次;同键改动载荷会被拒,换新键可能创建新批次 |
 | `quoteToken` | string | 是 | 5.1 返回的报价令牌;过期报"报价已过期,请重新获取" |
 | `participatingStoreIds` | long[] | 是 | 用户明确确认参与的门店,须是报价参与店的子集且 ≥2 家;夹带圈外店会被拒 |
-| `paymentMethods` | array | 是 | 与参与店一一对应的 `{storeId,payType}`;每店恰好一条,只能选择该店报价提供的 `availablePayTypes`,不支持转账 `"6"` |
+| `paymentMethods` | array | 是 | 与参与店一一对应的 `{storeId,payType}`;每店恰好一条,`payType` 是字符串且须在该店报价的 `availablePayTypes` 内;不支持转账 `"6"` |
+
+不要只传普通单店请求的顶层 `paymentMethod` 或顶层 `payType`:多店建批只读取逐店的 `paymentMethods`,缺少、重复、夹带非参与店或选了该店不可用的方式都会被拒。网络重试须保持同一个 `requestKey`、`quoteToken`、参与店和逐店方式;**创建成功后要改方式,调用 5.3,不要用同一个 `requestKey` 重发不同的 `paymentMethods`**。
 
 响应 `data`:
 
@@ -263,7 +273,7 @@ POST /system/multiStoreCheckout/batches
 |---|---|---|
 | `batchId` | long | 批次 ID,后续接口都用它 |
 | `parentOrderNo` | string | 父批次号 |
-| `checkoutStatus` | string | 含线上付款时为 `PENDING`;全到付时创建即为 `ACTIVE`,实际开放由出站流程处理 |
+| `checkoutStatus` | string | 含未付款线上子单时为 `PENDING`;全到付时创建即为 `ACTIVE`。`ACTIVE` 不等于骑手已可抢,实际开放看组 `openedAt` 与骑手列表 |
 | `totalAmount` | int | 批次总金额(TWD 元) |
 | `operationDeadlineAt` | string | 操作截止(创建后 15 分钟,见 8.1) |
 | `finalDeadlineAt` | string | 最终截止(创建后 20 分钟,见 8.1) |
@@ -271,6 +281,10 @@ POST /system/multiStoreCheckout/batches
 
 注意:幂等重试返回的 `availablePayTypes` 可能为空数组,**不能**当作门店永久不支持付款;以最新报价和付款接口校验为准。
 
+建批响应中的各店 `payType` / `paymentState` 是本次已保存的选择。全到付批次会在建批事务提交后立即尝试开放即时单;若是未到开放时点的预约单,仍保持 `ACTIVE`、等待开放,App 不要因 `ACTIVE` 提前展示可抢或已推送。
+
+以上示例中,101 店返回 `payType="3"`、`paymentState="ONLINE_PENDING"`,须继续发起 LINE Pay;102 店返回 `payType="1"`、`paymentState="COD_CONFIRMED"`,不再调 5.3 确认到付。只有两店均选到付时,建批结果才可直接为 `ACTIVE`;此时不能再改选支付方式。
+
 ### 5.3 逐店安全改选支付方式
 
 ```http
@@ -293,12 +307,12 @@ POST /system/multiStoreCheckout/batches/members/payment
 
 | 场景 | 返回 |
 |---|---|
-| `payType="1"` 到付确认 | 该店确认后的批次状态:`"PENDING"`(还有店没就绪)/ `"ACTIVE"`(全部就绪已生效)/ `"CANCELLED"`(预约已过期被关闭) |
+| `payType="1"` 改选到付 | 该店改选后的批次状态:`"PENDING"`(还有店没就绪)/ `"ACTIVE"`(全部就绪已生效)/ `"CANCELLED"`(预约已过期被关闭) |
 | `payType="2"/"3"/"5"` 线上选择 | 固定 `"ONLINE_PENDING"`,随后调 5.4 发起真实付款 |
 
 规则:
 
-- 新批次已在 5.2 保存逐店方式,首次支付无需调用本接口;本接口仅用于用户修改方式。批次须 `PENDING` 且未过 `operationDeadlineAt`,`ACTIVE` 后不可再改。
+- 新批次已在 5.2 保存逐店方式,首次支付无需调用本接口;本接口仅用于用户修改方式。批次须 `PENDING` 且未过 `operationDeadlineAt`,`ACTIVE` 后不可再改;改选成功后刷新 5.5 批次详情。
 - 线上→线上、线上→到付、到付→线上只在该店**从未发起网关交易**时允许;一旦有网关尝试记录,含结果未明的尝试,拒绝改选,先沿原渠道查询或完成支付。已真实付款不可改。相同方式重试保持幂等。
 - 该方式必须在门店 `availablePayTypes` 内;不可用会被拒。
 
@@ -396,7 +410,7 @@ GET /system/multiStoreCheckout/rider/tasks?page=1&size=10&tab=newTask&longitude=
 | `tab` | 否 | 默认 `newTask`,取值见 3.6 |
 | `longitude` / `latitude` | 否 | 骑手坐标。**只用于普通单排序兼容**,不能替代服务端位置;待抢组资格以服务端最近位置与首店距离为准 |
 
-响应 `data` 为分页结构,`records[]` 中 `GROUP` 卡一张 = 一组(角标与列表同口径,组只计一次)。待抢组只在批次已生效且到预约开放时点后才可见;新任务有有效坐标时按首店距离升序,其余按创建时间方向。2026-10-21 起,最后成员就绪并提交后会立即尝试开放即时/已到点批次,预约未到仍由分钟任务到点开放;App 不需等待固定一分钟才查询。此 URL 与下方角标 URL 保持不变,服务端方法归原骑手控制器。
+响应 `data` 为分页结构,`records[]` 中 `GROUP` 卡一张 = 一组(角标与列表同口径,组只计一次)。待抢组只在批次已生效且达到原预约开放时点、组 `openedAt` 已写入后可见;新任务有有效坐标时按首店距离升序,其余按创建时间方向。最后成员就绪并提交后,服务端立即尝试开放即时/已到点批次;若失败,由原分钟任务重试,预约未到也由该任务到点开放。App 不依赖固定一分钟延迟,付款后可刷新列表,未出现时以服务端状态为准;对外 URL 保持不变。
 
 ### 6.2 各页签任务数(角标)
 
@@ -510,7 +524,7 @@ POST /system/orderQsOprate/deliverOrder                          -- body: { "id"
 |---|---|---|
 | `orderId` / `orderNo` | long/string | 子单 ID / 单号 |
 | `storeId` / `storeName` | long/string | 门店 |
-| `payType` | string | 该店已选支付方式,未选为 null |
+| `payType` | string | 该店已选支付方式;新建批次必有值,旧批次未选时可能为 null |
 | `paymentState` | string | 见 3.3 |
 | `orderAmount` | int | 该店订单金额 |
 | `discountedFreight` | int | 该店折后运费 |
@@ -529,8 +543,9 @@ POST /system/orderQsOprate/deliverOrder                          -- body: { "id"
 
 批次创建时快照两个截止时间(默认创建 +15 分钟 / +20 分钟,以服务端返回为准),不因配置变更或重试延长:
 
-- 14:00 创建:14:15 前可发起新线上交易或确认到付;14:14 已实际发起的线上尝试可在 14:20 前完成付款及可信确认;14:16 起不能新发起、换方式、重试或确认到付;14:20 仍未全部就绪则批次关闭(`CANCELLED`),迟到实收只退款不激活。
+- 14:00 创建:建批时已逐店选定方式,到付店当场确认;14:15 前可对未发起过网关交易的子单改选方式或发起线上交易。14:14 已实际发起的线上尝试可在 14:20 前完成付款及可信确认;14:16 起不能新发起、换方式或重试;14:20 仍未全部就绪则批次关闭(`CANCELLED`),迟到实收只退款不激活。
 - **全部店合法就绪才变 `ACTIVE`**(线上须 ONLINE_PAID、到付须 COD_CONFIRMED);部分就绪不开放履约。
+- 全到付在建批时所有成员已确认,可直接返回 `ACTIVE`;仍需按 5.2 的开放规则判断何时可抢。`ACTIVE` 后不可用 5.3 改方式。
 - 未生效预约批次在激活时点预约已到/超过结束时间则取消,返回"原预约已过期"业务结果与真实退款进度,不自动改即时;已生效任务不由此取消。
 
 ### 8.2 取消规则
@@ -567,6 +582,7 @@ POST /system/orderQsOprate/deliverOrder                          -- body: { "id"
 
 ### 8.7 推送
 
+- 开放和推送是两个阶段:批次变为 `ACTIVE` 时登记持久事件,组真正开放后事件转为 `OPENED`,独立通知任务随后处理;不保证支付回调返回时设备已收到推送。预约未到开放时点、部分店未就绪时不推送。骑手端以 6.1 列表/6.2 角标判断可抢任务,不以推送到达时间判断。
 - 036 组新任务推送额外携带 `groupId`、`eventId`:用 `groupId` 打开组详情,按 `eventId` 对重试推送去重。
 - 后端推送状态 `SENT` 只表示已提交到现有异步通道,不等于设备已送达。
 
@@ -579,6 +595,8 @@ msg 文案随 App 语言本地化,下表按简体中文文案识别(按语
 | 请更新应用后使用多店结算和配送组功能(`errorCode=UPGRADE_REQUIRED`) | 能力头缺失/错误,或旧版 App 调了旧父单详情 | 引导升级;新接口补齐 `X-Multi-Store-Capability: batch-cards-v1` |
 | 多店结算暂未开放 | 平台开关未开或 036 迁移未执行 | 提示暂不可用,隐藏多店入口 |
 | 多店结算请求无效或参与店铺不足 | 参数缺失/少于 2 店/状态不允许/幂等键冲突 | 检查参数后重试;仍失败刷新详情 |
+| 该店已发起OMG/LINE Pay支付请求,完成前不能更换支付方式;请返回完成支付,或取消本次多店结算重新下单 | 在途收银台会话经渠道核实未通过或结果未知(换方式前服务端逐笔查询渠道状态) | 引导返回原收银台完成支付;或走 5.8/5.9 取消批次后重新下单 |
+| 订单已支付,无法更换支付方式 | 该店在途支付经渠道核实已收款 | 引导刷新支付结果,进批次详情查看就绪进度 |
 | 收货地址不存在或不属于当前用户 | addressId 无效或非本人 | 让用户重新选择地址 |
 | 门店不可参与当前多店配送 | 店在围栏外或不支持合并配送 | 按 excludedStores 剔除该店 |
 | 商品、规格或优惠信息已变化,请重新结算 | 建批时服务端重算与报价不一致 | 回到购物车重新报价 |
@@ -594,9 +612,10 @@ msg 文案随 App 语言本地化,下表按简体中文文案识别(按语
 
 ## 10. 接入验收清单
 
-- 两店线上/到付混合、均线上、均到付:部分就绪不开放履约,全部就绪才生效;付款回跳后刷新真实状态而非本地置为已付。
+- 两店线上/到付混合、均线上、均到付:建批时每店必须选方式,到付当场确认、全到付立即 `ACTIVE`;有线上未付时不开放履约,全部就绪才生效;付款回跳后刷新真实状态而非本地置为已付。
+- 建批参数:遗漏某店、重复 `storeId`、夹带非参与店、传 `"6"` 或该店不可用方式均被拒;用相同 `requestKey` 改传不同方式不能覆盖原批次,创建后改选走 5.3。
 - 时间窗:14:15/14:20 两截止行为符合 8.1;过期后所有付款入口置灰。
-- 预约批次:预约过期不激活、只退实收;生效与组开放以 `activatedAt`/`openedAt` 展示。
+- 即时/预约批次:最后成员就绪后即时单可立即开放,未到时点的预约单即使 `ACTIVE` 也不可抢、不推送;预约过期不激活、只退实收;生效与组开放分别以 `activatedAt`/`openedAt` 展示。
 - 无人接单超时:列表和角标只在实际开放后展示,取消后不重新可抢;迟到实收只跟踪退款。
 - 组进度:A 已送达、B 未取餐时组只在 toPickup,双进度与组容量正确;部分取消不追补余店运费。
 - 到付展示:组/成员 `codToCollectAmount`、`codToMerchantAmount` 按 6.6 展示,线上单不计现金待收。

+ 18 - 18
specs/036-multi-store-checkout/spec.md

@@ -17,16 +17,16 @@
 已确认:
 
 - 一次多店结算按店生成独立子订单,款项分别付给对应商家,不改为平台代收。
-- 新批次创建时各子单待收银确认,不预先认定付款方式;线上须服务端确认支付成功,到付须用户选择并由服务端校验确认。全部成员就绪前,商家/骑手不能提前履约。
+- 新批次创建时须按参与门店逐店提交付款方式:线上子单待真实付款,到付子单由本次明确选择当场确认;服务端不默认到付。全部成员就绪前,商家/骑手不能提前履约。
 - 首版纳入即时配送、预约配送和到付;夜市自配送、自取沿用原流程,不加入本次多店批次规则。
 - 新预约时间语义与倒推最迟接单算法仍留后续;2026-10-16 已确认本轮接入 037 现有无人接单超时规则,不再将这项接入延期。普通平台配送外卖和新多店预约沿原有效开放时点持久扫描,支持重启补扫、幂等开放与通知重试;新批次额外要求已生效,普通预约不套用多店期限。夜市自配送、自取不纳入。
 - 各商家可独立设置支付方式,多店不要求共同渠道;本功能仅开放各店已可用的LINE Pay、OMG和到付,不擅自改变商家设置。
-- 同批可按店混用LINE Pay、OMG和到付,逐笔收银时选择;即使最终全部到付,也须逐单完成到付确认,不能创建即生效或因旧payStatus=1默认就绪。
-- 全部新批次共用创建起15分钟支付发起/到付确认期限,支付下一店、重试/回页面不延长;支付完成与服务端确认缓冲默认5分钟、平台可配置,创建时固化时长和最终确认截止。第15分钟停止新的支付发起、重试、切换方式及到付确认;截止前服务端已记录并实际发起的线上支付尝试,允许在缓冲期继续完成用户认证、渠道付款和服务端确认。此口径经用户确认,取代严格要求第15分钟前完成付款的旧口径;并非第15分钟后仍可发起新交易。全部真实成员经服务端确认合法就绪立即生效,无需等满缓冲;最终确认截止仍未就绪整批取消,真实实收全退含运费、未收款到付只取消。已取消后迟到实收退款、不重开。
+- 同批可按店混用LINE Pay、OMG和到付,建批时逐店选择并保存;全到付在建批时全部确认,可创建即生效,但须另满足即时/预约开放条件才能让骑手抢单。旧`payStatus=1`不能代替用户本次明确选择。
+- 全部新批次共用创建起15分钟线上支付发起/方式改选期限,支付下一店、重试/回页面不延长;支付完成与服务端确认缓冲默认5分钟、平台可配置,创建时固化时长和最终确认截止。第15分钟停止新的支付发起、重试及方式改选;截止前服务端已记录并实际发起的线上支付尝试,允许在缓冲期继续完成用户认证、渠道付款和服务端确认。此口径取代严格要求第15分钟前完成付款的旧口径;并非第15分钟后仍可发起新交易。全部真实成员经服务端确认合法就绪立即生效,无需等满缓冲;最终确认截止仍未就绪整批取消,真实实收全退含运费、未收款到付只取消。已取消后迟到实收退款、不重开。
 - 新多店批次不支持线下转账(payType=6),无论是否选择同骑手;原单店转账订单、入口和商家配置保持不变。本轮不新增转账核款/人工退款流程,不关闭全系统转账。
 - 同骑手资格以用户选择的第一家店为圆心,按门店坐标计算首店到后续各店的直线距离,不按道路路线距离;范围默认1公里、在平台合并配置中可调整,不能在代码写死1公里。圈外店不进入本次合并结算父单/配送组,不创建其子单、不等待其支付、不计入合并折扣或本批次金额;A/B合并不受圈外C付款影响。
 - 即时单与预约单不能进入同一合并配送组;预约单仅预约日期、开始时间和结束时间完全一致时才能同组,不因同用户/地址/围栏而放宽,仅时段重叠也不能合并。不同日期的同一钟点亦不合并;仍支持符合原有效规则的同一预约时段多店,预约时间处理改造仍按既定范围延期。
-- 新多店批次创建时完整校验门店、商品、配送资格及金额,成功后固化商品/价格/折后运费快照;付款不因普通改价、上下架或停止接新单/营业时间变化重新算价、自动取消批次,仍可在15分钟内发起支付/确认到付,既有线上交易可在缓冲内完成。付款继续检查归属、订单/批次未取消未过期、方式及凭证可用;快照不是库存保证,实际不能出餐按此前确认的子单规则处理,不新增库存预占体系。
+- 新多店批次创建时完整校验门店、商品、配送资格、逐店付款方式及金额,成功后固化商品/价格/折后运费快照;付款不因普通改价、上下架或停止接新单/营业时间变化重新算价、自动取消批次,仍可在15分钟内发起线上支付或在合法条件下改选方式,既有线上交易可在缓冲内完成。付款继续检查归属、订单/批次未取消未过期、方式及凭证可用;快照不是库存保证,实际不能出餐按此前确认的子单规则处理,不新增库存预占体系。
 - 新批次准入/期限、权威算价快照及取消返券仅用于服务端明确标识的新多店批次,不全局改变普通单/历史单的付款、取消、优惠促销及发票规则。本次明确批准普通平台配送预约按原时点补充后续开放扫描,不引入新预约语义或批次15分钟限制,普通单保持037现有无人接单规则。夜市自配送、自取及其他旧流程不变,新标识缺失不能误判为未生效批次,共享入口不能全局移除原校验。
 - 圈外店处理建议:预结算明确列出未纳入店及原因,用户确认本次参与集合后再提交;C保留购物车供另行结算,不自动另建订单、不静默丢掉购物车商品。购物车保留方案属于接入建议,App由前端人员实现。
 - 骑手接单数量沿用现有外卖/闪送/总接单数字典:普通独立外卖单算1单,合并配送组整体算1单,不新增骑手实际子单总量上限、相关字典或配置项。同父单的独立配送订单不能合计为1单;闪送原计数与加急独占不变。此前双层容量/子单上限方案已撤回。
@@ -71,10 +71,10 @@
 2. 重复门店不增加合并店数,也不能制造阶梯优惠;同店商品汇总为一条子单。
 3. 用户不选同骑手时,各店按原运费计费;选同骑手时,仅参与配送组的店铺享合并优惠。
 4. 服务端重新校验报价、商品/门店归属、地址归属和金额,不信任客户端传入的运费/折扣/合并资格。
-5. 相同用户、相同请求号的网络重试返回原批次,不重复建单或重复占用优惠券/库存。
-6. 创建前校验每个参与店至少有一种本批次支持且就绪的方式,不要求用户已选定。逐单收银选择时重验具体方式;到付只要求该店允许到付,不要求具备线上凭证;创建阶段不得默认选择到付。
+5. 相同用户、相同请求号且报价、参与店及逐店付款方式相同的网络重试返回原批次;同键改动载荷拒绝,不重复建单或重复占用优惠券/库存。
+6. 创建前校验每个参与店至少有一种本批次支持且就绪的方式;建批请求须对每家参与店明确选择一种报价可用方式,缺漏、重复、夹带非参与店或不可用方式均拒绝。到付只要求该店允许到付,不要求具备线上凭证;服务端不得默认选择到付。
 7. 预约配送校验所有参与门店在所选时段可营业、可配送;即时与预约不能同组,预约日期和起止时间不同也不能同组。预结算明确不兼容、创建拒绝混合,不静默改时间或自动拆组;同一日期/时段的合法预约多店仍支持。
-8. 按每店查询平台开关、商家选择和凭证就绪的可用方式,不取跨店交集;子单在逐笔收银确认时保存独立payType,创建时保留待确认语义,不能由父单唯一方式覆盖。
+8. 按每店查询平台开关、商家选择和凭证就绪的可用方式,不取跨店交集;建批时逐店保存独立payType,线上待真实付款、到付当场确认,不能由父单唯一方式覆盖。
 9. 新批次可选方式为各店当前真实可用方式与本功能支持的LINE/OMG/到付的交集。仅支持转账或没有本功能就绪方式的店明确提示不能参加多店结算,用户可另走原单店流程;预结算和提交前均校验,不静默移除门店、不自动拆单、不擅自改成到付,不在已付部分后才发现无法支付。
 10. 预结算区分购物车候选集合、本次合并参与集合和不符合店铺清单。首店A/B圈内、C圈外时,仅A/B参与本批;创建接口若夹带C或成员与确认报价不一致,明确拒绝,不在提交事务里静默删C或自动另建C单。
 11. 删除首店或改变参与店、地址等会重算围栏资格和报价,重新确认成员;圈内可参与店不足2家时不形成多店同骑手组。
@@ -82,7 +82,7 @@
 
 ### US2 各店独立支付与批次统一生效(P1)
 
-用户逐笔进入各店收银台,选择并完成线上支付或到付确认;就绪进度由服务端事实汇总,不需要创建时提前知道最终是否都是到付。
+用户建批时逐店选择付款方式;线上子单再逐笔进入对应渠道完成支付,到付子单已在建批时确认。就绪进度由服务端事实汇总;建批后在批次待生效、未过操作截止时允许安全改选:该店没有任何网关尝试,或既有在途尝试已逐笔向渠道核实确定未付(口径同 661 换到付);核实发现已收款或结果未知拒绝,核实通过后的迟到实收由重复实收退款兜底。
 
 **Independent Test**: A/B为本次合并批次、C圈外未纳入:A付款后父批次仍未就绪;B也满足付款条件后仅生效一次,C未支付甚至未建单不阻断A/B。三家都参与的批次才等待三家条件全部满足。
 
@@ -94,8 +94,8 @@
 4. 父批次生效与取消互斥;重复回调、查询补单、服务重启不重复生效或重复通知。
 5. 支付发起截止后、最终确认截止前,仍未取消的批次允许截止前服务端已记录并实际发起的线上尝试继续完成和核实,全部合法成员就绪才生效;不得在该缓冲内新发起支付、重试、切换方式或确认到付。批次已关闭或最终确认截止后发现实收,只记录收款事实并退款,不重新开放履约。
 6. 未付齐时,用户仍可看到订单并继续支付,不能把订单从用户待付款列表隐藏。
-7. 每子单就绪条件为:已选择线上且可信付款成功,或已选择到付且服务端确认该选择合法;未选、支付失败/结果未知、仅客户端回跳均不就绪。到付确认不是实收,已付款数量和已就绪数量分别表达。
-8. 三单依次确认到付时,前两单就绪后仍不开放,第三单确认后才生效;用户离开而未确认最后一单,15分钟到期整批取消且无网关退款。创建时不自动生效。
+7. 每子单就绪条件为:已选择线上且可信付款成功,或建批/合法改选时明确选择到付且服务端确认该选择合法;支付失败/结果未知、仅客户端回跳均不就绪。历史未选状态不作为新建批次正常初态。到付确认不是实收,已付款数量和已就绪数量分别表达。
+8. 三单在建批时均明确选择到付,则全部成员当场就绪、批次可立即生效;即使批次已`ACTIVE`,预约未到开放时点或开放事件尚未成功处理时,组仍不可抢且不推新任务。混合方式须等线上子单真实付款后生效。
 
 ### US3 未付齐时整批取消与真实退款(P1)
 
@@ -108,7 +108,7 @@
 1. 未生效批次不支持单独删掉已提交子单后继续付其余单;修改购物车需先取消原批次。
 2. 取消与退款是两个事实:订单可以已取消而退款处理中;仅真实渠道证据允许标记已退款。
 3. 退款请求持久化,事务提交后由后台处理;退款超时、响应丢失先查询核实,不盲目重复退款。
-4. 持久扫描全部未生效批次的支付发起/到付确认截止及最终确认截止,包含尚未选择/待确认到付及支付待核实,重启/Redis事件丢失仍补处理。第15分钟只截止新操作,保留已记录并实际发起的线上尝试继续完成和核实;最终截止未就绪才取消并继续追踪迟到实收退款,不依赖App倒计时。
+4. 持久扫描全部未生效批次的线上支付发起/方式改选截止及最终确认截止,包含线上未付及支付待核实;兼容历史未选记录,重启/Redis事件丢失仍补处理。第15分钟只截止新操作,保留已记录并实际发起的线上尝试继续完成和核实;最终截止未就绪才取消并继续追踪迟到实收退款,不依赖App倒计时。
 5. 取消时逐子单对账;迟到付款、迟到回调、旧支付尝试仍有明确补偿,不把本地未支付当成绝不会扣款。
 6. LINE Pay 与 OMG 正式退款均纳入;OMG 测试环境明确返回不能执行,不伪造退款事实、不外呼正式 API。
 
@@ -182,7 +182,7 @@
 - **FR-010** 用户主动取消在未接单阶段按真实交易全额退款含运费,已接单不走用户取消退款;不以旧稿“商品款退、运费保留”默认要求部分退款扩展。异常售后如需部分退款须另确认,任何退款累计不得超实收、部分退款不得标成全额退款。
 - **FR-011** 非适用单店/历史订单及夜市自配送、自取保持原流程;新多店到付和预约属于本功能,标识明确区分适用范围。唯一相关补齐为本次批准的普通平台配送预约按旧时点补扫,不套新批次期限。
 - **FR-012** 按项目规范补注释、四语言、SQL 登记、测试和 App 接入文档;按实施任务交付,规格确认不代表代码已实现。
-- **FR-013** 创建、选择方式、子单就绪、真实实收、批次生效和预约开放分别表达;到付确认参加结算期限但不算线上实收,未收款取消不产生虚构退款。旧单店到付创建约定不直接应用新批次。
+- **FR-013** 建批时逐店选择方式、子单就绪、真实实收、批次生效和实际开放分别表达;明确选择到付即确认但不算线上实收,未收款取消不产生虚构退款。不能依赖旧单店到付默认状态替代本次选择。
 - **FR-014(后续独立事项)** 新预约送达时段语义与倒推开放/最迟接单算法留后续;本轮基本开放扫描按 FR-037、多店接入 037 已有无人接单规则按 FR-042,不再延期。激活前预约失效检查按 FR-043,不改原预约语义,不复用废弃 TestTask。
 - **FR-015** 每店独立查询、选择、校验并保存支付方式;批次不要求共同渠道,允许LINE/OMG/到付按店混用,受当前平台/商家/凭证闸门共同约束。只在新批次上下文过滤转账,不改现有单店支付方式查询和商家设置。
 - **FR-016** 新多店预结算、创建及后续换方式拒绝payType=6,原单店和历史转账仍走原流程;本轮不新增核款时限、待核款提醒、人工转账退款或补偿入口。仅转账门店提交前明确拒绝其参与本批,不擅自转到付/自动拆单/静默移除,也不关闭平台全局转账开关。
@@ -196,7 +196,7 @@
 - **FR-024** 已生效批次骑手尚未接单时允许取消单个子单,完整实收金额含折后运费按原交易退款;未实收到付不虚构退款。剩余成员继续履约且不补差,取消同步组成员/版本、待抢可用性与待收汇总;全组取消结束任务。取消与接组串行/CAS校验实际分配状态,不能以过期列表当作未接单证据。
 - **FR-025** 本功能用户取消按服务端骑手承接事实校验,已接组或其子单拒绝用户主动取消单店/整组,不因尚未取餐或商家尚未接单而放行,不新增申请取消或商家审核流程。现有UserOrderController.cancelOrder不能仅以state=0判定可取消;新批次须同时检查配送组分配事实、子单qsId和deliveryStatus,任一已有承接证据即拒绝。组/子单未承接条件必须参加事务内更新/CAS校验,不能只在读取后判断,避免检查后骑手接单仍被取消。拒绝请求无订单/组/容量/退款副作用;商家/平台异常处置不是此用户入口的绕过方式,其权限、原因和退款另行确认。当前仅确认和记录方案,不提前修改普通非多店订单行为。
 - **FR-026** 骑手已接且该店未取餐时,有权限商家因无法供餐可取消本店子单,线上原交易全额退实收含运费;到付不能供餐时尚无当面垫款,仅移除待收,不新增返款分支。其他单继续不补差,取消同步组成员/站点、待收/容量及骑手通知;与取餐串行/CAS校验,已取不得走此路径。空跑是否补偿另定,不默认追加支付。
-- **FR-027** 新批次创建只确认参与集合/报价和各店至少有一种就绪可用方式,不强制携带已选payType;逐单收银入口以用户、子单、所选方式及服务端闸门确认,不接受客户端已支付/已就绪标志。具体字段在data-model/contracts定义,保留旧单店接口兼容。
+- **FR-027** 新批次创建除确认参与集合/报价外,必须提交与参与店一一对应的`paymentMethods`,逐店选择报价可用方式;缺漏、重复、非参与店或不可用方式拒绝。服务端固化选择与到付确认,不接受客户端已支付/已就绪标志;后续仅在合法条件下通过专用入口改选。旧单店接口保持兼容。
 - **FR-028** 创建时固化支付发起/到付确认截止=创建起15分钟、缓冲时长及最终确认截止=前者+缓冲,配置改动/重试/逐店付款不延长既有批次。第15分钟停止新的支付发起/重试/换方式/到付确认,但允许合法既有线上尝试继续完成付款和核实,默认缓冲到第20分钟;最后合法成员就绪可立即生效。最终截止仍未就绪取消整批,未知支付继续对账/补偿,实收全退且取消后不可重开。核销、补单、生效和取消采用同一父批次并发规则。
 - **FR-029** 平台动态维护最少店数与折扣阶梯,服务端按实际合并去重店数选择满足条件的最高门槛档,各店原配送费分别按该折扣计算,舍入按FR-031。门槛为不小于2的整数且不重复,折扣系数须大于0且不大于1;缺失/非法配置沿US5保守关闭合并报价。规则及命中档位随创建固化,配置修改或允许的部分取消不追溯重算。
 - **FR-030** 首版规则使用全局配置,最大合并门店数默认5家且可配置,初始折扣档为最少2家0.9、最少3家0.8,不默认启用4家七折,不新增城市/区域覆盖规则。预结算、创建及接组检查有效店数上限,接单同时满足现有任务字典,不新增子单容量。不自动拆组或放宽缺失/非法配置;调整上限不直接取消已承接任务,待接组按FR-019处理。
@@ -206,9 +206,9 @@
 - **FR-034** 新多店创建全量校验并固化商品/价格/运费等快照,后续付款不因普通菜单/营业变化重算或自动整批取消,仍校验所有必要付款条件、15分钟发起/到付确认截止及最终确认截止;不新增库存预占。必须以服务端新批次标识限定适用范围,现有非多店订单和其他非适用旧流程行为不变;共享支付/回调/查询/通知/取消扫描/履约入口只定向处理新批次,不全局放开多店支付或强加新期限/就绪条件。具体标识字段在data-model确定,未标识订单按原流程。
 - **FR-035** 平台合并配置页面维护支付完成与服务端确认缓冲时长,单位分钟,默认5,保存只接受正整数;缺失/非法/读取异常按5分钟安全回退,不能回退为无限等待。创建快照后不因改配置改变旧批次最终截止。发起资格以服务端持久记录的原支付尝试、实际发起事实及时间为准,不能用前端点击/回跳或事后补写时间证明期限内发起;收款须核实渠道真实交易、金额、币种、商家及签名/查询事实。LINE认证/确认/扣款按实际分阶段流程处理,认证成功不算实收;不再要求证明用户认证/扣款均在第15分钟前完成,不以本地记账时间冒充渠道付款时间。
 
-- **FR-036** 新批次付款事实先落库,逐单成功/到付确认不直接发履约新单通知;全部合法就绪后生效并持久记录开放事件。满足预约条件且提交后发送,合并组按组、独立单按子单幂等,失败可重试,取消终态不复活。用户支付结果通知保持真实事实,后续取餐/送达逐子单沿原流程。
+- **FR-036** 新批次付款事实先落库,逐单成功/到付确认不直接发履约新单通知;全部合法就绪后生效并持久记录开放事件。事务提交后立即独立尝试开放即时/已到预约开放时点的批次,未到时点由原分钟任务处理,失败/重启亦由该任务补偿;开放原子写子单显示、组`opened_at`及事件`OPENED`。独立通知消费者随后按组/子单幂等推送,失败可重试,取消终态不复活;开放不等于设备已收到推送。用户支付结果通知与后续逐子单取餐/送达通知保持原职责。
 - **FR-037** 补充普通平台配送外卖及新多店预约的持久开放扫描,沿原时间判断/sys_appointorder_handletime,不新增倒推最迟接单算法;现有无人接单规则的多店接入另按 FR-042。创建先设预约信息再判显示;扫描校验原开放时点、未取消未分配和原付款/履约资格,新批次额外已生效,普通单不检查批次期限。重启数据库补扫,开放状态与通知意图原子幂等并支持重试;直接接单不可绕过。自取/夜市自配送不纳入,不批量改写历史已开放单。
-- **FR-038** 用户原订单列表与统一列表识别新批次:未选、部分付款、全部未就绪或缓冲核实中保留可见进度/继续结算信息,不因payType空值漏单或payStatus=1误标可履约。真实已付与到付确认分开,生效后逐子单普通进度不被覆盖;覆盖本批LINE、OMG信用卡/Apple Pay及到付,旧非多店分类不全局改写,App只交付接入文档。
+- **FR-038** 用户原订单列表与统一列表识别新批次:线上未付、部分付款或缓冲核实中保留可见进度/继续结算信息;新建成员均已选方式,历史未选记录也不能漏单,不因payStatus=1误标可履约。真实已付与到付确认分开,生效后逐子单普通进度不被覆盖;覆盖本批LINE、OMG信用卡/Apple Pay及到付,旧非多店分类不全局改写,App只交付接入文档。
 - **FR-039** 新多店预结算/创建服务端读取真实门店商品/规格价格、数量与优惠规则,计算餐费、原/折后运费及实付并固化。商品可售条件须与单店报价及菜单一致:`to_examine=1`为审核通过、`stacking_up=0`为上架;未通过或缺失审核状态必须拒绝。拒绝跨店商品/规格、非法数量和报价/金额不一致,不能直接保存客户端amount/freight/foodAmount或仅警告放行。明确金额单位与渠道转换,逐店HALF_UP及支付/退款/到付/账单同快照不变,原普通算价不改。
 - **FR-040** 新批次整批或合法子单取消释放/返还对应取消成员占用/核销的券,不返仍履约成员的券。保留原有效期,仍有效恢复可用、过期不续期;原券/原子单绑定及状态参加并发幂等,重复取消/退款/扫描不重复返券,旧取消不得释放已由新单使用的券。创建同一券不能并发重复使用,返券不是渠道退款成功;普通非多店券规则不改。
 - **FR-041** 新批次详情、付款进度、关联订单、支付/取消按token确定用户并校验父/子/组归属及关联,不能仅凭传入ID返回信息或操作;现有getParentOrderDetail涉及新批次不得原样复用无用户过滤查询。商家仅访问授权子单,骑手沿现有待抢/本人承接权限,不放宽其他商家或用户敏感信息。未授权无款项/状态/退款/返券副作用。
@@ -224,7 +224,7 @@
 ## Key Entities
 
 - 结算批次:复用OrderParent;成员、创建起15分钟用户操作截止、确认缓冲时长快照/最终确认截止、逐单已就绪/实收及待核实进度、预约信息、收费快照及生效/取消状态。新批次均有用户截止,不设全到付例外;缓冲到期不是已生效预约任务取消时间。
-- 门店子单:复用PosOrder;门店、待选/逐单所选payType、收银就绪事实、商品/运费快照、实际交易与独立履约状态。就绪的到付仍不是实收。
+- 门店子单:复用PosOrder;门店、建批时逐店所选payType、收银就绪事实、商品/运费快照、实际交易与独立履约状态。新建批次不产生待选成员;就绪的到付仍不是实收。
 - 配送组:参与同骑手配送的子单集合;不是收款账户或跨商家支付交易。
 - 骑手接单容量:任务按配送组/独立外卖单及闪送任务统计,沿用现有三个接单字典,异常回退1;合并门店数独立校验。不新增骑手子单上限字典、字段或容量实体。
 - 支付与退款事实:各子单自己的门店凭证/交易/退款金额及状态;复用或扩展当前有效 LINE、OMG 机制,不启用废弃支付代码。
@@ -264,9 +264,9 @@
 - **SC-022** 验证新批次创建后改价/上下架/停止接新单/普通营业时间变化时,期限内支付仍取原商品及金额快照,已付其他店不被自动取消;归属不符、取消、超时、支付方式/凭证不可用仍拦截。旧非多店订单的价格、支付、取消等规则保持不变;普通平台配送预约仅按SC-025补齐旧时点开放扫描,不能因同样挂父单而套用新批次规则;旧支付限制不被统一删除,只有合法新批次成员可定向放行。
 - **SC-023** 14:00创建、默认发起/到付确认截止14:15及最终确认截止14:20;14:14由服务端记录并实际发起、14:17真实付款并核实且其余成员合法就绪可生效。14:16新发起/重试/切换方式/确认到付拒绝;仅前端声称14:14已发起、伪造时间、只有请求或认证成功均不能当作实收就绪。最终截止仍未知则取消并持续核查迟到实收退款。验证缓冲配置5改8只影响新批次、非法回退5、回调/查询/扫描竞争、重复核销、LINE分阶段及非多店无新增期限。
 
-- **SC-024** A已付/B未付或未选不推履约新单且不可抢,全部合法就绪即时开放一次、预约未到不推。重复回调/查询/到付不重复事件,事务回滚不发送;失败可重试、取消后不复活,单笔送达通知不等其他单。
+- **SC-024** A已付/B线上未付时不推履约新单且不可抢,全部合法就绪并提交后即时尝试开放一次,预约未到不开放、不推送。开放事件失败由分钟任务补偿,推送由独立通知任务处理;重复回调/查询/到付不重复事件,事务回滚不发送,取消后不复活,单笔送达通知不等其他单。
 - **SC-025** 旧配置提前60分钟、预约18:00–18:30时17:00前不开放、17:00后原普通合法平台配送预约可开放;新批次未生效仍不开放,随后生效才开放。停机跨时点后补扫,重复扫描不重复开放/生成通知,取消/接单竞争不复活;普通单不套批次15分钟条件,自取/自配送不纳入;新预约语义/倒推最迟接单不纳入本轮,037接入按SC-030验证。
-- **SC-026** 新批次未选、A已付/B未付、混合到付、Apple Pay未付及缓冲核实中可查进度并恢复合法结算;批次未就绪时已付子单不能误标可履约,到付不标实收,生效后逐单普通进度不被组覆盖;旧非多店分类回归不变。
+- **SC-026** 新批次均已选方式,A已付/B线上未付、混合到付、Apple Pay未付及缓冲核实中可查进度并恢复合法结算;历史未选记录也须可见。批次未就绪时已付子单不能误标可履约,到付不标实收,生效后逐单普通进度不被组覆盖;旧非多店分类回归不变。
 - **SC-027** 篡改amount/freight/foodAmount、跨店商品/规格、非法数量不能改变收费;提交报价差异拒绝/重报价而非仅警告。服务端快照与渠道/退款/到付/账单一致,创建后改价仍按旧快照,原单店算价不改。
 - **SC-028** 未生效整批取消返对应券、生效取消C只返C券;重复取消/退款/扫描只返一次,仍有效可用、过期不续期。并发创建同一券不能重复核销,旧取消回调不释放新单所用券;返券不伪造退款成功,普通单券规则不改。
 - **SC-029** 用户A传B父单号/子单ID/组ID或伪造关联/标识,不能看B详情/进度或支付/取消,失败无状态/款项/返券副作用;本人合法查询正常,商家/骑手仅现有授权范围,不因关联组暴露其他敏感数据。

+ 10 - 5
specs/036-multi-store-checkout/tasks.md

@@ -8,15 +8,15 @@
 - [x] T002 实现新多店全局配置读取/校验/保存及 PC 配置页、四语言文案;默认围栏 1000 米、最多 5 店、缓冲 5 分钟、2 店九折/3 店八折;普通骑手字典不增加第二套限制。配置改为专表 `order_checkout_config`,不接 `sys_config`。
 - [ ] T003 对真实店铺、商品/规格/价格、促销/优惠券、地址及现有运费进行服务端预结算;输出圈外店原因、本批参与集合、可选支付方式和报价指纹,不信任客户端金额。
 - [x] T004 在 `updatesql/sql.md` 登记 036 配置、批次、成员、支付尝试、配送组、通知与退款意图表及索引;不执行迁移。
-- [ ] T005 新批次幂等创建:重算报价/成员、原子写父子单与快照,写 15 分钟发起截止和缓冲快照,不创建圈外店订单、不默认到付就绪、不通知履约;普通 `/createOrder` 不走新规则。
+- [ ] T005 新批次幂等创建:重算报价/成员,逐店校验并固化`paymentMethods`,原子写父子单与快照及两个截止;不创建圈外店订单、不默认选择到付,不在创建事务内推送履约。全到付按T035进入激活/开放流程;普通`/createOrder`不走新规则。
 
 ## Phase 2:逐店收银与统一生效(US2)
 
-- [ ] T006 测试逐店支付选择、方式过滤、未决在线尝试、15 分钟与缓冲边界、真实收款和到付确认的差别、取消与回调竞争。
+- [ ] T006 测试建批逐店支付选择、方式过滤、全到付当场确认、安全改选、未决在线尝试、15分钟与缓冲边界、真实收款和到付确认的差别、取消与回调竞争。
 - [ ] T007 接入 LINE/OMG 既有支付入口,只对合法 036 成员放宽旧多店拒绝;记录实际发起证据、原交易、商家凭证与金额,非批次订单保留原校验。
-- [ ] T008 到付确认与 LINE/OMG 回调/查询/补单统一记成员事实,全部合法就绪才 CAS 激活;失败/未知和客户端回跳不能激活。
+- [ ] T008 建批或合法改选到付与LINE/OMG回调/查询/补单统一记成员事实,全部合法就绪才CAS激活;失败/未知和客户端回跳不能激活。
 - [ ] T009 新批次超时/迟到支付处理及重启可恢复扫描;最终截止未就绪整批关闭,迟到实收建退款意图不重开。
-- [ ] T010 持久幂等履约通知/失败重试,预约按旧规则时点开放;补普通平台配送预约的旧时点扫描,夜市自配送/自取及旧单期限不受影响。
+- [ ] T010 持久幂等开放与通知/失败重试:最后成员就绪提交后立即尝试开放即时/已到点批次,原分钟任务负责预约到点和失败/重启补偿;独立通知任务在开放后推送。补普通平台配送预约旧时点扫描,夜市自配送/自取及旧单期限不受影响。
 
 ## Phase 3:取消、退款与券(US3)
 
@@ -32,7 +32,7 @@
 
 ## Phase 5:查询、PC 与交付(US5)
 
-- [ ] T017 测试批次/子单/组归属及越权、用户列表未选/部分付款/Apple Pay/缓冲/退款状态,旧单分类不变。
+- [ ] T017 测试批次/子单/组归属及越权、用户列表已选方式/部分付款/Apple Pay/缓冲/退款状态,兼容历史未选记录,旧单分类不变。
 - [ ] T018 批次详情/进度/继续结算接口、平台退款跟进 PC 页四语言;配置页已在 T002 完成;用户端和骑手端兼容版本/强制更新门禁及原单笔入口防绕过;App 只按已写的 `frontend-app-integration.md` 接入,不在本仓库修改 App 源码。
 - [ ] T019 定向测试、受影响模块构建及回归普通 LINE/OMG/到付/转账、自取、夜市自配送、预约、促销券/发票;核验 SC-034~036 的组合分页/角标、首店接单距离及升级门禁;检查 SQL、编码、换行和差异范围,记录正式 OMG 退款未联调限制。
 
@@ -155,3 +155,8 @@ T035–T037验证:2026-10-21 10:44 使用 JDK21 执行`mvn -pl ruoyi-admin -am
 - [x] T039 将新版骑手列表和角标方法归到原骑手控制器,对外 URL、版本门禁及旧列表保持不变;更新接入文档并完成路由回归。
 
 T038–T039验证:2026-10-21 使用 JDK21 执行 `mvn -q -pl ruoyi-admin -am verify` 成功,Surefire 报告合计1429项、失败/错误/跳过均0;失败重试及路由等定向31项通过。`git diff --check` 通过。未连接真实数据库、未启用暂停的 Quartz 任务、未调用真实支付或推送渠道。
+## 2026-10-21 换方式对齐 661 渠道核实口径
+
+- [x] T040 逐店换渠道/改到付前逐笔核实渠道在途支付(OMG reconcile、LINE 本地状态+捕获查询):确定未付放行改选、已收款提示已支付、结果未知维持专属拒绝;核实发生在批次行锁之前,核实后又有新发起保守拒绝。新增 MultiStoreCheckoutCaptureVerifier、专属文案 multi.store.checkout.payment.dispatched(六语言)并复用 no.pay.switch.already.paid;同步 spec/data-model/api/接入文档。
+
+T040验证:2026-10-21 定向 23 项(核实器/两服务/选择入口)及 MultiStoreCheckout*、PayMethodSwitch* 全量回归 0 失败;未连接真实数据库与真实支付渠道。