Преглед изворни кода

完善多店逐店支付与即时开放并归口骑手查询

qmj пре 2 дана
родитељ
комит
766376882a
25 измењених фајлова са 570 додато и 100 уклоњено
  1. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationService.java
  2. 37 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationTrigger.java
  3. 62 8
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java
  4. 17 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCodService.java
  5. 5 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactService.java
  6. 16 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionService.java
  7. 0 32
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryController.java
  8. 55 8
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  9. 4 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreateRequest.java
  10. 2 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreatedView.java
  11. 42 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActivationServiceTest.java
  12. 68 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActivationTriggerTest.java
  13. 68 12
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java
  14. 23 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCodServiceTest.java
  15. 3 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutControllerTest.java
  16. 5 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactServiceTest.java
  17. 23 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionServiceTest.java
  18. 36 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreRiderRoutesTest.java
  19. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderCheckoutMapper.java
  20. 22 0
      ruoyi-system/src/main/resources/mapper/system/OrderCheckoutMapper.xml
  21. 3 3
      specs/036-multi-store-checkout/contracts/api.md
  22. 17 15
      specs/036-multi-store-checkout/frontend-app-integration.md
  23. 3 1
      specs/036-multi-store-checkout/plan.md
  24. 18 6
      specs/036-multi-store-checkout/spec.md
  25. 15 0
      specs/036-multi-store-checkout/tasks.md

+ 13 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationService.java

@@ -9,6 +9,7 @@ import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.mapper.OrderCheckoutMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
@@ -33,8 +34,20 @@ public class MultiStoreCheckoutActivationService {
         return checkout.selectPendingActivationEventIds(now, limit);
     }
 
+    /** 提交后的即时入口;以批次查询持久事件,并复用定时任务的同一开放条件。 */
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public boolean openActivatedBatch(Long batchId, LocalDateTime now) {
+        if (batchId == null || now == null) return false;
+        Long eventId = checkout.selectPendingActivationEventIdByBatchId(batchId);
+        return eventId != null && openActivationLocked(eventId, now);
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public boolean openActivation(Long eventId, LocalDateTime now) {
+        return openActivationLocked(eventId, now);
+    }
+
+    private boolean openActivationLocked(Long eventId, LocalDateTime now) {
         if (eventId == null || now == null) return false;
         OrderCheckoutOutboxEvent event = checkout.selectOutboxByIdForUpdate(eventId);
         if (event == null || !"CHECKOUT_ACTIVATED".equals(event.getEventType())

+ 37 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationTrigger.java

@@ -0,0 +1,37 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.LocalDateTime;
+
+/** 支付/建批事务只登记开放事件;提交成功后才用独立事务尝试开放。 */
+@Component
+public class MultiStoreCheckoutActivationTrigger {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutActivationTrigger.class);
+    private final MultiStoreCheckoutActivationService activation;
+
+    public MultiStoreCheckoutActivationTrigger(MultiStoreCheckoutActivationService activation) {
+        this.activation = activation;
+    }
+
+    public void scheduleAfterCommit(Long batchId) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            throw new IllegalStateException("Multi-store activation requires a transaction");
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                try {
+                    activation.openActivatedBatch(batchId, LocalDateTime.now());
+                } catch (RuntimeException error) {
+                    // 付款事实已经提交;保留 PENDING 出站事件交给分钟任务重试。
+                    log.error("Multi-store immediate open failed, batchId={}", batchId, error);
+                }
+            }
+        });
+    }
+}

+ 62 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java

@@ -35,6 +35,7 @@ import java.util.Date;
 import java.util.HexFormat;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.data.redis.core.script.DefaultRedisScript;
 
@@ -60,13 +61,15 @@ public class MultiStoreCheckoutBatchService {
     private final InfoAddressMapper addresses;
     private final PosStoreMapper stores;
     private final OrderPromotionHelper promotions;
+    private final MultiStoreCheckoutActivationTrigger activationTrigger;
 
     public MultiStoreCheckoutBatchService(MultiStoreCheckoutQuoteService quotes,
                                           MultiStoreCheckoutConfigMapper config, OrderCheckoutMapper checkout,
                                           OrderParentMapper parents, PosOrderMapper orders,
                                           InfoAddressMapper addresses, PosStoreMapper stores,
                                           OrderPromotionHelper promotions, StringRedisTemplate orderNumbers,
-                                          OrderNumberReservationService numberReservations) {
+                                          OrderNumberReservationService numberReservations,
+                                          MultiStoreCheckoutActivationTrigger activationTrigger) {
         this.quotes = quotes;
         this.config = config;
         this.checkout = checkout;
@@ -77,16 +80,22 @@ public class MultiStoreCheckoutBatchService {
         this.promotions = promotions;
         this.orderNumbers = orderNumbers;
         this.numberReservations = numberReservations;
+        this.activationTrigger = activationTrigger;
     }
 
     @Transactional(rollbackFor = Exception.class)
     public MultiStoreBatchCreatedView create(MultiStoreBatchCreateRequest input, Long userId) {
         if (userId == null || input == null || input.requestKey() == null
                 || input.requestKey().isBlank() || input.requestKey().length() > 64
-                || input.quoteToken() == null || input.participatingStoreIds() == null) {
+                || input.quoteToken() == null || input.participatingStoreIds() == null
+                || input.paymentMethods() == null || input.paymentMethods().size() != input.participatingStoreIds().size()) {
             throw invalid("multi.store.checkout.invalid");
         }
-        String requestHash = hash(input.quoteToken() + "|" + JSON.toJSONString(input.participatingStoreIds()));
+        Map<Long, String> selectedPayments = paymentMethods(input);
+        // 幂等摘要包含逐店支付选择;同一 requestKey 不可悄悄改写已创建子单的付款方式。
+        String requestHash = hash(input.quoteToken() + "|" + JSON.toJSONString(input.participatingStoreIds())
+                + "|" + JSON.toJSONString(input.participatingStoreIds().stream()
+                .map(storeId -> selectedPayments.get(storeId)).toList()));
         // 相同请求键的网络重试在报价过期后仍可取回原批次;不同载荷不能借键覆盖旧单。
         OrderCheckoutBatch existing = checkout.selectByUserAndRequestKey(userId, input.requestKey());
         if (existing != null) return existingView(existing, requestHash);
@@ -97,6 +106,11 @@ public class MultiStoreCheckoutBatchService {
                 || !quote.participatingStoreIds().equals(input.participatingStoreIds())) {
             throw invalid("multi.store.checkout.quote.expired");
         }
+        for (MultiStoreQuoteView.StorePrice price : quote.stores()) {
+            if (!price.availablePayTypes().contains(selectedPayments.get(price.storeId()))) {
+                throw invalid("multi.store.checkout.invalid");
+            }
+        }
         // 与配置下调使用同一数据库行锁;锁后核对版本及成员上限,禁止旧报价穿过并发配置变更。
         MultiStoreCheckoutConfig policy = MultiStoreCheckoutConfig.fromRow(config.selectCurrentForUpdate());
         if (!policy.enabled() || policy.version() != quote.ruleVersion()
@@ -157,6 +171,7 @@ public class MultiStoreCheckoutBatchService {
         if (parents.insert(parent) != 1) throw invalid("multi.store.checkout.invalid");
 
         List<MultiStoreBatchCreatedView.Member> created = new ArrayList<>();
+        boolean allCod = true;
         Map<Long, MultiStoreQuoteRequest.Store> selected = snapshot.request().stores().stream()
                 .collect(java.util.stream.Collectors.toMap(MultiStoreQuoteRequest.Store::storeId, item -> item));
         for (int index = 0; index < quote.stores().size(); index++) {
@@ -198,6 +213,15 @@ public class MultiStoreCheckoutBatchService {
             order.setState(0L);
             order.setDeliveryStatus(0L);
             order.setPayStatus(0L);
+            String payType = selectedPayments.get(price.storeId());
+            // 创建即固化用户已选付款方式;到付是确认方式,不代表现金已经交付。
+            order.setPayType(payType);
+            if ("1".equals(payType)) {
+                order.setPayStatus(1L);
+                order.setCollectPayment("1");
+            } else {
+                allCod = false;
+            }
             order.setAfterSaleStatus(0L);
             order.setIsDisplay(false);
             order.setSelfDelivery(0);
@@ -226,7 +250,9 @@ public class MultiStoreCheckoutBatchService {
             member.setPosOrderId(order.getId());
             member.setStoreId(price.storeId());
             member.setSortNo(index + 1);
-            member.setPaymentState("UNSELECTED");
+            member.setPayType(payType);
+            member.setPaymentState("1".equals(payType) ? "COD_CONFIRMED" : "ONLINE_PENDING");
+            if ("1".equals(payType)) member.setReadyAt(now);
             member.setPaidAmount(0);
             member.setOriginalFreight(integer(price.originalFreight()));
             member.setDiscountedFreight(integer(price.discountedFreight()));
@@ -237,7 +263,8 @@ public class MultiStoreCheckoutBatchService {
             member.setUpdateTime(now);
             if (checkout.insertMember(member) != 1) throw invalid("multi.store.checkout.invalid");
             created.add(new MultiStoreBatchCreatedView.Member(order.getId(), orderNo,
-                    price.storeId(), integer(price.amount()), price.availablePayTypes()));
+                    price.storeId(), integer(price.amount()), payType, member.getPaymentState(),
+                    price.availablePayTypes()));
         }
         OrderDeliveryGroup group = new OrderDeliveryGroup();
         group.setBatchId(batch.getId());
@@ -245,11 +272,37 @@ public class MultiStoreCheckoutBatchService {
         group.setCreateTime(now);
         group.setUpdateTime(now);
         if (checkout.insertGroup(group) != 1) throw invalid("multi.store.checkout.invalid");
-        // 这里不能开放履约或通知商家/骑手;等待各成员真实支付或到付确认后统一激活。
-        return new MultiStoreBatchCreatedView(batch.getId(), parentNo, "PENDING", total,
+        if (allCod) {
+            // 全到付在建批时已经逐店确认;只生成一次激活事件,实际开放仍由出站流程负责。
+            if (!MultiStoreCheckoutRules.canActivate(created.stream()
+                    .map(MultiStoreBatchCreatedView.Member::paymentState).toList(),
+                    snapshot.request().stores().get(0).deliveryWindow(), now)) {
+                throw invalid("multi.store.checkout.quote.expired");
+            }
+            if (checkout.activateIfPending(batch.getId(), now) != 1) throw invalid("multi.store.checkout.invalid");
+            checkout.insertActivationEvent(batch.getId(), now);
+            activationTrigger.scheduleAfterCommit(batch.getId());
+        }
+        return new MultiStoreBatchCreatedView(batch.getId(), parentNo, allCod ? "ACTIVE" : "PENDING", total,
                 batch.getOperationDeadlineAt(), batch.getFinalDeadlineAt(), created);
     }
 
+    private Map<Long, String> paymentMethods(MultiStoreBatchCreateRequest input) {
+        if (input.paymentMethods().stream().anyMatch(item -> item == null || item.storeId() == null
+                || !("1".equals(item.payType()) || "2".equals(item.payType())
+                || "3".equals(item.payType()) || "5".equals(item.payType())))) {
+            throw invalid("multi.store.checkout.invalid");
+        }
+        Map<Long, String> selected = input.paymentMethods().stream().collect(Collectors.toMap(
+                MultiStoreBatchCreateRequest.PaymentMethod::storeId,
+                MultiStoreBatchCreateRequest.PaymentMethod::payType,
+                (first, duplicate) -> { throw invalid("multi.store.checkout.invalid"); }));
+        if (!selected.keySet().equals(new java.util.HashSet<>(input.participatingStoreIds()))) {
+            throw invalid("multi.store.checkout.invalid");
+        }
+        return selected;
+    }
+
     private String allocateParentNumber(String owner) {
         for (int attempt = 0; attempt < 8; attempt++) {
             Long millis = orderNumbers.execute(NEXT_ORDER_MILLIS,
@@ -269,7 +322,8 @@ public class MultiStoreCheckoutBatchService {
                 .map(member -> {
                     PosOrder order = orders.selectById(member.getPosOrderId());
                     return new MultiStoreBatchCreatedView.Member(member.getPosOrderId(),
-                            order == null ? null : order.getDdId(), member.getStoreId(), member.getAmount(), List.of());
+                            order == null ? null : order.getDdId(), member.getStoreId(), member.getAmount(),
+                            member.getPayType(), member.getPaymentState(), List.of());
                 }).toList();
         return new MultiStoreBatchCreatedView(batch.getId(), batch.getParentDdId(), batch.getCheckoutStatus(),
                 batch.getTotalAmount(), batch.getOperationDeadlineAt(), batch.getFinalDeadlineAt(), members);

+ 17 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCodService.java

@@ -24,15 +24,18 @@ public class MultiStoreCheckoutCodService {
     private final PosStoreMapper stores;
     private final PaymentMethodGateService payments;
     private final MultiStoreCheckoutCancellationService cancellation;
+    private final MultiStoreCheckoutActivationTrigger activationTrigger;
 
     public MultiStoreCheckoutCodService(OrderCheckoutMapper checkout, PosOrderMapper orders,
                                         PosStoreMapper stores, PaymentMethodGateService payments,
-                                        MultiStoreCheckoutCancellationService cancellation) {
+                                        MultiStoreCheckoutCancellationService cancellation,
+                                        MultiStoreCheckoutActivationTrigger activationTrigger) {
         this.checkout = checkout;
         this.orders = orders;
         this.stores = stores;
         this.payments = payments;
         this.cancellation = cancellation;
+        this.activationTrigger = activationTrigger;
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -52,8 +55,8 @@ public class MultiStoreCheckoutCodService {
         LocalDateTime now = LocalDateTime.now();
         if (!"PENDING".equals(batch.getCheckoutStatus())
                 || !MultiStoreCheckoutRules.canStartPayment(now, batch.getOperationDeadlineAt())
-                || !"UNSELECTED".equals(selected.getPaymentState())) {
-            // 未决线上交易不得直接切到到付并误称已就绪。
+                || !("UNSELECTED".equals(selected.getPaymentState())
+                || "ONLINE_PENDING".equals(selected.getPaymentState()))) {
             throw invalid();
         }
         PosStore store = stores.selectById(selected.getStoreId());
@@ -64,7 +67,15 @@ public class MultiStoreCheckoutCodService {
         }
         payments.assertUsableForOrder("1", PaymentMethodGateService.GateScope.MERCHANT,
                 selected.getStoreId(), store.getUserId(), 0L);
-        if (checkout.updateMemberCodReady(selected.getId(), now) != 1
+        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(),
+                    "COD_CONFIRMED", "1", now) != 1
+                    || checkout.switchOrderPayment(orderId, selected.getPayType(), 0L, "1", 1L) != 1) throw invalid();
+        } else if (checkout.updateMemberCodReady(selected.getId(), now) != 1
                 || checkout.updateCodOrder(orderId) != 1) throw invalid();
         boolean allReady = members.stream().allMatch(member -> member == selected
                 || "ONLINE_PAID".equals(member.getPaymentState())
@@ -83,7 +94,8 @@ public class MultiStoreCheckoutCodService {
         }
         if (checkout.activateIfPending(batchId, now) != 1) throw invalid();
         checkout.insertActivationEvent(batchId, now);
-        // 出站事件后续按实际预约开放时点处理;这里不直接通知骑手或商家。
+        activationTrigger.scheduleAfterCommit(batchId);
+        // 仅提交后尝试开放;预约未到时保留出站事件,通知仍由独立消费者处理。
         return "ACTIVE";
     }
 

+ 5 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactService.java

@@ -20,12 +20,15 @@ public class MultiStoreCheckoutOnlineFactService {
     private final OrderCheckoutMapper checkout;
     private final PosOrderMapper orders;
     private final MultiStoreCheckoutCancellationService cancellation;
+    private final MultiStoreCheckoutActivationTrigger activationTrigger;
 
     public MultiStoreCheckoutOnlineFactService(OrderCheckoutMapper checkout, PosOrderMapper orders,
-                                              MultiStoreCheckoutCancellationService cancellation) {
+                                              MultiStoreCheckoutCancellationService cancellation,
+                                              MultiStoreCheckoutActivationTrigger activationTrigger) {
         this.checkout = checkout;
         this.orders = orders;
         this.cancellation = cancellation;
+        this.activationTrigger = activationTrigger;
     }
 
     /** 同号段普通外卖没有成员关联,不可被当作多店记录支付发起/实收。 */
@@ -165,6 +168,7 @@ public class MultiStoreCheckoutOnlineFactService {
         }
         if (checkout.activateIfPending(batchId, now) != 1) throw invalid();
         checkout.insertActivationEvent(batchId, now);
+        activationTrigger.scheduleAfterCommit(batchId);
         return "ACTIVE";
     }
 

+ 16 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionService.java

@@ -51,16 +51,30 @@ public class MultiStoreCheckoutOnlineSelectionService {
         PosStore store = stores.selectById(member.getStoreId());
         if (order == null || store == null || store.getUserId() == null
                 || !userId.equals(order.getUserId()) || !member.getStoreId().equals(order.getMdId())
-                || !Long.valueOf(0L).equals(order.getPayStatus())
                 || !Long.valueOf(0L).equals(order.getState())
                 || Boolean.TRUE.equals(order.getIsDisplay())) throw invalid();
         payments.assertUsableForOrder(payType, PaymentMethodGateService.GateScope.MERCHANT,
                 member.getStoreId(), store.getUserId(), 0L);
         if ("ONLINE_PENDING".equals(member.getPaymentState())
                 && payType.equals(member.getPayType()) && payType.equals(order.getPayType())) {
-            // 同渠道网络重试可继续查询原尝试;未决时不能改用另一个渠道。
+            // 同渠道网络重试可继续查询原尝试,不重复写支付状态。
+            if (!Long.valueOf(0L).equals(order.getPayStatus())) throw invalid();
             return "ONLINE_PENDING";
         }
+        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(),
+                    member.getPayType(), "ONLINE_PENDING", payType, null) != 1
+                    || checkout.switchOrderPayment(orderId, member.getPayType(), oldPayStatus, payType, 0L) != 1) {
+                throw invalid();
+            }
+            return "ONLINE_PENDING";
+        }
+        if (!Long.valueOf(0L).equals(order.getPayStatus())) throw invalid();
         if (!"UNSELECTED".equals(member.getPaymentState()) || member.getPayType() != null
                 || order.getPayType() != null
                 || checkout.updateMemberOnlinePending(member.getId(), payType) != 1

+ 0 - 32
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryController.java

@@ -14,7 +14,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
-import java.math.BigDecimal;
 import java.util.List;
 
 /** 新版 App 的组合只读入口;身份从 token 获取,不能由请求参数指定。 */
@@ -63,37 +62,6 @@ public class MultiStoreCheckoutQueryController extends BaseController {
         return AjaxResult.success(queries.batchDetail(batchId, identity(token)));
     }
 
-    @Anonymous
-    @Auth
-    @GetMapping("/rider/tasks")
-    public AjaxResult riderTasks(@RequestHeader String token,
-                                 @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
-                                 String capability,
-                                 @RequestParam(defaultValue = "1") int page,
-                                 @RequestParam(defaultValue = "10") int size,
-                                 @RequestParam(defaultValue = "newTask") String tab,
-                                 @RequestParam(required = false) BigDecimal longitude,
-                                 @RequestParam(required = false) BigDecimal latitude) {
-        if (!MultiStoreCheckoutAppCapability.supports(capability))
-            return MultiStoreCheckoutAppCapability.upgradeRequired();
-        return AjaxResult.success(queries.riderTasks(identity(token), page, size, tab,
-                longitude, latitude, newTaskDistanceLimitMeters()));
-    }
-
-    @Anonymous
-    @Auth
-    @GetMapping("/rider/taskCount")
-    public AjaxResult riderTaskCount(@RequestHeader String token,
-                                     @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
-                                     String capability,
-                                     @RequestParam(required = false) BigDecimal longitude,
-                                     @RequestParam(required = false) BigDecimal latitude) {
-        if (!MultiStoreCheckoutAppCapability.supports(capability))
-            return MultiStoreCheckoutAppCapability.upgradeRequired();
-        return AjaxResult.success(queries.riderTaskCount(identity(token), longitude, latitude,
-                newTaskDistanceLimitMeters()));
-    }
-
     @Anonymous
     @Auth
     @GetMapping("/groups/{groupId}")

+ 55 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -47,11 +47,10 @@ import java.util.stream.Collectors;
 import com.ruoyi.app.order.dto.PickupOrderInput;
 
 /**
- * 外卖订单骑手操作入口(2026-09-29 记录):接单/取餐/送达(按 deliveryStatus 1/2/3)均在此类实现;
+ * 外卖订单骑手操作入口(2026-09-29 记录):接单/取餐/送达(按 deliveryStatus 1/2/3)及新版任务查询在此类实现;
  * 勿在 PosOrderController 新增骑手操作。
  */
 @RestController
-@RequestMapping("/system/orderQsOprate")
 public class PosOrderQsOprateController extends BaseController {
     @Autowired
     private IPosOrderService posOrderService;
@@ -75,13 +74,15 @@ public class PosOrderQsOprateController extends BaseController {
     private OrderLifecycleService orderLifecycleService;
     @Autowired
     private MerchantNotificationRouter merchantNotificationRouter;
+    @Autowired
+    private MultiStoreCheckoutQueryService multiStoreQueries;
 
     /**
      * 骑手接单:校验 type=0 且 afterSaleStatus=0,设置 deliveryStatus=1
      */
     @Anonymous
     @Auth
-    @GetMapping("/acceptOrder")
+    @GetMapping("/system/orderQsOprate/acceptOrder")
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult acceptOrder(@RequestHeader String token, @RequestParam Long id) {
         JwtUtil jwtUtil = new JwtUtil();
@@ -123,7 +124,7 @@ public class PosOrderQsOprateController extends BaseController {
      */
     @Anonymous
     @Auth
-    @PostMapping("/pickupOrder")
+    @PostMapping("/system/orderQsOprate/pickupOrder")
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult pickupOrder(@RequestHeader String token, @RequestBody PickupOrderInput input) {
         JwtUtil jwtUtil = new JwtUtil();
@@ -158,7 +159,7 @@ public class PosOrderQsOprateController extends BaseController {
      */
     @Anonymous
     @Auth
-    @PostMapping("/deliverOrder")
+    @PostMapping("/system/orderQsOprate/deliverOrder")
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult deliverOrder(@RequestHeader String token, @RequestBody PickupOrderInput input) {
         JwtUtil jwtUtil = new JwtUtil();
@@ -342,12 +343,58 @@ public class PosOrderQsOprateController extends BaseController {
         return null;
     }
 
+    /** 新版骑手列表把普通单和多店组作为同一页;保留既有 URL 供 App 直接使用。 */
+    @Anonymous
+    @Auth
+    @GetMapping("/system/multiStoreCheckout/rider/tasks")
+    public AjaxResult riderTasks(@RequestHeader String token,
+                                 @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                                 String capability,
+                                 @RequestParam(defaultValue = "1") int page,
+                                 @RequestParam(defaultValue = "10") int size,
+                                 @RequestParam(defaultValue = "newTask") String tab,
+                                 @RequestParam(required = false) BigDecimal longitude,
+                                 @RequestParam(required = false) BigDecimal latitude) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability))
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        return AjaxResult.success(multiStoreQueries.riderTasks(Long.valueOf(new JwtUtil().getusid(token)), page, size,
+                tab, longitude, latitude, multiStoreNewTaskDistanceLimitMeters()));
+    }
+
+    @Anonymous
+    @Auth
+    @GetMapping("/system/multiStoreCheckout/rider/taskCount")
+    public AjaxResult riderTaskCount(@RequestHeader String token,
+                                     @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                                     String capability,
+                                     @RequestParam(required = false) BigDecimal longitude,
+                                     @RequestParam(required = false) BigDecimal latitude) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability))
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        return AjaxResult.success(multiStoreQueries.riderTaskCount(Long.valueOf(new JwtUtil().getusid(token)),
+                longitude, latitude, multiStoreNewTaskDistanceLimitMeters()));
+    }
+
+    /** 沿用旧列表的距离字典;缺失或非法时,多店待抢组不可见。 */
+    private Long multiStoreNewTaskDistanceLimitMeters() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("sys_qs_newtask_distance");
+            if (values != null && !values.isEmpty()) {
+                long kilometers = Long.parseLong(values.get(0).getDictValue());
+                if (kilometers > 0 && kilometers <= 100) return kilometers * 1000;
+            }
+        } catch (RuntimeException ignored) {
+            // 已接任务与历史仍可查;待抢资格在 SQL 中要求有效距离上限。
+        }
+        return null;
+    }
+
     /**
      * 骑手端订单列表(Tab分页)
      */
     @Anonymous
     @Auth
-    @GetMapping("/orderList")
+    @GetMapping("/system/orderQsOprate/orderList")
     public AjaxResult orderList(@RequestHeader String token, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String tab, @RequestParam(required = false) BigDecimal longitude, @RequestParam(required = false) BigDecimal latitude) {
         JwtUtil jwtUtil = new JwtUtil();
         String qsId = jwtUtil.getusid(token);
@@ -412,7 +459,7 @@ public class PosOrderQsOprateController extends BaseController {
      */
     @Anonymous
     @Auth
-    @GetMapping("/orderCount")
+    @GetMapping("/system/orderQsOprate/orderCount")
     public AjaxResult orderCount(@RequestHeader String token,
                                  @RequestParam(required = false) BigDecimal longitude,
                                  @RequestParam(required = false) BigDecimal latitude) {
@@ -515,7 +562,7 @@ public class PosOrderQsOprateController extends BaseController {
      */
     @Anonymous
     @Auth
-    @GetMapping("/getOrderDetial")
+    @GetMapping("/system/orderQsOprate/getOrderDetial")
     public AjaxResult getOrderDetial(@RequestHeader String token, @RequestParam String ddId) {
         JwtUtil jwtUtil = new JwtUtil();
         String qsId = jwtUtil.getusid(token);

+ 4 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreateRequest.java

@@ -2,7 +2,9 @@ package com.ruoyi.app.order.dto;
 
 import java.util.List;
 
-/** 客户端只确认参与集合;商品、费用和支付资格由报价快照及服务端重算决定。 */
+/** 客户端逐店选择支付方式;商品、费用和支付资格仍由报价快照及服务端重算决定。 */
 public record MultiStoreBatchCreateRequest(String requestKey, String quoteToken,
-                                           List<Long> participatingStoreIds) {
+                                           List<Long> participatingStoreIds, List<PaymentMethod> paymentMethods) {
+    public record PaymentMethod(Long storeId, String payType) {
+    }
 }

+ 2 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreatedView.java

@@ -7,7 +7,8 @@ import java.util.List;
 public record MultiStoreBatchCreatedView(Long batchId, String parentOrderNo, String checkoutStatus,
                                          int totalAmount, LocalDateTime operationDeadlineAt,
                                          LocalDateTime finalDeadlineAt, List<Member> members) {
-    public record Member(Long orderId, String orderNo, Long storeId, int amount,
+    public record Member(Long orderId, String orderNo, Long storeId, int amount, String payType,
+                         String paymentState,
                          List<String> availablePayTypes) {
     }
 }

+ 42 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActivationServiceTest.java

@@ -15,6 +15,7 @@ import java.time.LocalDateTime;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -30,6 +31,47 @@ class MultiStoreCheckoutActivationServiceTest {
     private final MultiStoreCheckoutActivationService service =
             new MultiStoreCheckoutActivationService(checkout, orders);
 
+    @Test
+    void committedImmediateBatchOpensThroughItsPersistentEvent() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 16, 0);
+        arrange(now, null);
+        when(checkout.selectPendingActivationEventIdByBatchId(9L)).thenReturn(10L);
+        when(checkout.openWaitingGroup(9L, now)).thenReturn(1);
+        when(checkout.openGroupMemberOrder(eq(101L), any(), eq(now))).thenReturn(1);
+        when(checkout.openGroupMemberOrder(eq(102L), any(), eq(now))).thenReturn(1);
+        when(checkout.markActivationOpened(10L, now)).thenReturn(1);
+
+        assertTrue(service.openActivatedBatch(9L, now));
+        verify(checkout).markActivationOpened(10L, now);
+    }
+
+    @Test
+    void committedFutureAppointmentLeavesEventPendingForScheduledOpen() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 16, 0);
+        arrange(now, "2026-10-20 18:00-18:30");
+        when(checkout.selectPendingActivationEventIdByBatchId(9L)).thenReturn(10L);
+        try (MockedStatic<DateUtil> dates = mockStatic(DateUtil.class)) {
+            dates.when(DateUtil::getAppoinmentOrderHandleTime).thenReturn(60);
+            assertFalse(service.openActivatedBatch(9L, now));
+        }
+        verify(checkout, never()).markActivationOpened(eq(10L), any());
+    }
+
+    @Test
+    void failedImmediateOpenCanBeRetriedByScheduledConsumer() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 16, 0);
+        arrange(now, null);
+        when(checkout.selectPendingActivationEventIdByBatchId(9L)).thenReturn(10L);
+        when(checkout.openGroupMemberOrder(eq(101L), any(), any())).thenReturn(0, 1);
+        when(checkout.openGroupMemberOrder(eq(102L), any(), any())).thenReturn(1);
+        when(checkout.openWaitingGroup(eq(9L), any())).thenReturn(1);
+        when(checkout.markActivationOpened(eq(10L), any())).thenReturn(1);
+
+        assertThrows(IllegalStateException.class, () -> service.openActivatedBatch(9L, now));
+        assertTrue(service.openActivation(10L, now.plusSeconds(1)));
+        verify(checkout).markActivationOpened(eq(10L), any());
+    }
+
     @Test
     void scheduledGroupWaitsForOriginalOpeningTime() {
         LocalDateTime now = LocalDateTime.of(2026, 10, 20, 16, 0);

+ 68 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActivationTriggerTest.java

@@ -0,0 +1,68 @@
+package com.ruoyi.app.order;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+class MultiStoreCheckoutActivationTriggerTest {
+    private final MultiStoreCheckoutActivationService activation = mock(MultiStoreCheckoutActivationService.class);
+    private final MultiStoreCheckoutActivationTrigger trigger = new MultiStoreCheckoutActivationTrigger(activation);
+
+    @AfterEach
+    void clearSynchronization() {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.clearSynchronization();
+        }
+    }
+
+    @Test
+    void opensOnlyAfterPaymentTransactionCommits() {
+        TransactionSynchronizationManager.initSynchronization();
+
+        trigger.scheduleAfterCommit(99L);
+        verifyNoInteractions(activation);
+        for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
+            synchronization.afterCommit();
+        }
+
+        verify(activation).openActivatedBatch(eq(99L), any());
+    }
+
+    @Test
+    void failedImmediateOpenDoesNotChangeCommittedPaymentResult() {
+        TransactionSynchronizationManager.initSynchronization();
+        doThrow(new IllegalStateException("retry later"))
+                .when(activation).openActivatedBatch(eq(99L), any());
+        trigger.scheduleAfterCommit(99L);
+
+        for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
+            assertDoesNotThrow(synchronization::afterCommit);
+        }
+    }
+
+    @Test
+    void activationMustRegisterInsideTransaction() {
+        assertThrows(IllegalStateException.class, () -> trigger.scheduleAfterCommit(99L));
+        verifyNoInteractions(activation);
+    }
+
+    @Test
+    void rolledBackPaymentNeverOpensGroup() {
+        TransactionSynchronizationManager.initSynchronization();
+        trigger.scheduleAfterCommit(99L);
+        for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
+            synchronization.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
+        }
+        verifyNoInteractions(activation);
+    }
+}

+ 68 - 12
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java

@@ -39,6 +39,7 @@ 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.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 class MultiStoreCheckoutBatchServiceTest {
@@ -53,6 +54,7 @@ class MultiStoreCheckoutBatchServiceTest {
     private final OrderPromotionHelper promotions = mock(OrderPromotionHelper.class);
     private final StringRedisTemplate orderNumbers = mock(StringRedisTemplate.class);
     private final OrderNumberReservationService numberReservations = mock(OrderNumberReservationService.class);
+    private final MultiStoreCheckoutActivationTrigger activationTrigger = mock(MultiStoreCheckoutActivationTrigger.class);
 
     @BeforeEach
     void setUp() {
@@ -70,7 +72,7 @@ class MultiStoreCheckoutBatchServiceTest {
 
     private MultiStoreCheckoutBatchService subject() {
         return new MultiStoreCheckoutBatchService(quotes, config, checkout, parents, orders,
-                addresses, stores, promotions, orderNumbers, numberReservations);
+                addresses, stores, promotions, orderNumbers, numberReservations, activationTrigger);
     }
 
     @Test
@@ -78,15 +80,45 @@ class MultiStoreCheckoutBatchServiceTest {
         arrangeQuote();
 
         assertThrows(ServiceException.class, () -> subject().create(
-                new MultiStoreBatchCreateRequest("retry-1", "quote-1", List.of(1L, 3L)), 42L));
+                new MultiStoreBatchCreateRequest("retry-1", "quote-1", List.of(1L, 3L), List.of(
+                        new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3"),
+                        new MultiStoreBatchCreateRequest.PaymentMethod(3L, "1"))), 42L));
 
         verify(checkout, never()).insertOrGetBatch(any());
         verify(parents, never()).insert(any(OrderParent.class));
     }
 
+    @Test
+    void rejectsMissingDuplicateAndUnsupportedPerStoreMethodsBeforeWriting() {
+        List<List<MultiStoreBatchCreateRequest.PaymentMethod>> invalid = List.of(
+                List.of(new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3")),
+                List.of(new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3"),
+                        new MultiStoreBatchCreateRequest.PaymentMethod(1L, "1")),
+                List.of(new MultiStoreBatchCreateRequest.PaymentMethod(1L, "6"),
+                        new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1")),
+                List.of(new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3"),
+                        new MultiStoreBatchCreateRequest.PaymentMethod(3L, "1")));
+        for (var methods : invalid) {
+            assertThrows(ServiceException.class, () -> subject().create(new MultiStoreBatchCreateRequest(
+                    "retry-1", "quote-1", List.of(1L, 2L), methods), 42L));
+        }
+        verify(checkout, never()).insertOrGetBatch(any());
+    }
+
+    @Test
+    void rejectsMethodUnavailableAtSelectedStoreBeforeWriting() {
+        arrangeQuote();
+        assertThrows(ServiceException.class, () -> subject().create(new MultiStoreBatchCreateRequest(
+                "retry-1", "quote-1", List.of(1L, 2L), List.of(
+                new MultiStoreBatchCreateRequest.PaymentMethod(1L, "2"),
+                new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1"))), 42L));
+        verify(checkout, never()).insertOrGetBatch(any());
+    }
+
     @org.junit.jupiter.params.ParameterizedTest
-    @org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
-    void createsPendingBatchWithServerPricesNoDefaultCodAndFrozenAddress(boolean firstNumberOccupied) {
+    @org.junit.jupiter.params.provider.CsvSource({"false,false", "true,false", "false,true"})
+    void createsBatchWithPerStorePaymentChoicesAndFrozenAddress(boolean firstNumberOccupied,
+                                                                  boolean allCod) throws Exception {
         if (firstNumberOccupied) {
             when(orderNumbers.execute(org.mockito.ArgumentMatchers.<RedisScript<Long>>any(), anyList(), anyString()))
                     .thenReturn(1789716730511L, 1789716730512L);
@@ -123,19 +155,25 @@ class MultiStoreCheckoutBatchServiceTest {
         when(parents.insert(any(OrderParent.class))).thenReturn(1);
         when(checkout.insertMember(any())).thenReturn(1);
         when(checkout.insertGroup(any())).thenReturn(1);
+        when(checkout.activateIfPending(org.mockito.ArgumentMatchers.eq(99L), any())).thenReturn(1);
         doAnswer(call -> { ((OrderCheckoutBatch) call.getArgument(0)).setId(99L); return 1; })
                 .when(checkout).insertOrGetBatch(any());
         doAnswer(call -> { ((PosOrder) call.getArgument(0)).setId(100L); return 1; })
                 .when(orders).insert(any(PosOrder.class));
 
-        var result = subject().create(new MultiStoreBatchCreateRequest(
-                "retry-1", "quote-1", List.of(1L, 2L)), 42L);
+        var request = new com.fasterxml.jackson.databind.ObjectMapper().readValue("""
+                {"requestKey":"retry-1","quoteToken":"quote-1","participatingStoreIds":[1,2],
+                 "paymentMethods":[{"storeId":1,"payType":"%s"},{"storeId":2,"payType":"1"}]}
+                """.formatted(allCod ? "1" : "3"), MultiStoreBatchCreateRequest.class);
+        var result = subject().create(request, 42L);
 
         assertEquals(99L, result.batchId());
         assertEquals("991789716730512", result.parentOrderNo());
-        assertEquals("PENDING", result.checkoutStatus());
+        assertEquals(allCod ? "ACTIVE" : "PENDING", result.checkoutStatus());
         assertEquals(172, result.totalAmount());
         assertEquals(2, result.members().size());
+        assertEquals(List.of(allCod ? "1" : "3", "1"), result.members().stream()
+                .map(com.ruoyi.app.order.dto.MultiStoreBatchCreatedView.Member::payType).toList());
         // 真实库pos_order.dd_id上限30,积分流水dd_id上限20;预留3位子单序号。
         assertTrue(result.parentOrderNo().matches("99[0-9]{13}"));
         assertTrue(result.parentOrderNo().length() <= 17,
@@ -158,12 +196,24 @@ class MultiStoreCheckoutBatchServiceTest {
             assertEquals(36.0, order.getFreight());
             assertEquals(new BigDecimal("36"), order.getCustomerDeliveryFee());
             assertEquals(BigDecimal.ZERO, order.getMerchantDeliverySubsidy());
-            assertEquals(0L, order.getPayStatus());
-            assertEquals(null, order.getPayType());
+            assertEquals(allCod ? 1L : sequence == 2 ? 0L : 1L, order.getPayStatus());
+            assertEquals(allCod ? "1" : sequence == 2 ? "3" : "1", order.getPayType());
             assertEquals(Boolean.FALSE, order.getIsDisplay());
             assertEquals(null, order.getEffectiveAt());
             assertTrue(order.getShAddress().contains("22.6000"));
         }
+        var members = org.mockito.ArgumentCaptor.forClass(com.ruoyi.system.domain.OrderCheckoutMember.class);
+        verify(checkout, org.mockito.Mockito.times(2)).insertMember(members.capture());
+        assertEquals(List.of(allCod ? "COD_CONFIRMED" : "ONLINE_PENDING", "COD_CONFIRMED"), members.getAllValues().stream()
+                .map(com.ruoyi.system.domain.OrderCheckoutMember::getPaymentState).toList());
+        if (allCod) {
+            verify(checkout).activateIfPending(org.mockito.ArgumentMatchers.eq(99L), any());
+            verify(checkout).insertActivationEvent(org.mockito.ArgumentMatchers.eq(99L), any());
+            verify(activationTrigger).scheduleAfterCommit(99L);
+        } else {
+            verify(checkout, never()).activateIfPending(org.mockito.ArgumentMatchers.eq(99L), any());
+            verifyNoInteractions(activationTrigger);
+        }
     }
 
     @Test
@@ -171,8 +221,8 @@ class MultiStoreCheckoutBatchServiceTest {
         OrderCheckoutBatch existing = new OrderCheckoutBatch();
         existing.setId(99L); existing.setParentDdId("MS-EXISTING");
         existing.setCheckoutStatus("PENDING"); existing.setTotalAmount(172);
-        // 使用同样的请求 token/参与集合产生稳定的幂等摘要。
-        String payload = "quote-1|[1,2]";
+        // 使用相同的报价、参与集合和逐店支付方式产生稳定的幂等摘要。
+        String payload = "quote-1|[1,2]|[\"3\",\"1\"]";
         try {
             byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
                     .digest(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
@@ -184,9 +234,15 @@ class MultiStoreCheckoutBatchServiceTest {
         when(checkout.selectMembersByBatchId(99L)).thenReturn(List.of());
 
         var result = subject().create(new MultiStoreBatchCreateRequest(
-                "retry-1", "quote-1", List.of(1L, 2L)), 42L);
+                "retry-1", "quote-1", List.of(1L, 2L), List.of(
+                        new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3"),
+                        new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1"))), 42L);
 
         assertEquals("MS-EXISTING", result.parentOrderNo());
+        assertThrows(ServiceException.class, () -> subject().create(new MultiStoreBatchCreateRequest(
+                "retry-1", "quote-1", List.of(1L, 2L), List.of(
+                new MultiStoreBatchCreateRequest.PaymentMethod(1L, "2"),
+                new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1"))), 42L));
         org.mockito.Mockito.verifyNoInteractions(orderNumbers);
         verify(quotes, never()).loadSnapshot(any(), any());
         verify(parents, never()).insert(any(OrderParent.class));

+ 23 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCodServiceTest.java

@@ -27,6 +27,7 @@ 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.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 class MultiStoreCheckoutCodServiceTest {
@@ -36,6 +37,7 @@ class MultiStoreCheckoutCodServiceTest {
     private final PosStoreMapper stores = mock(PosStoreMapper.class);
     private final PaymentMethodGateService payments = mock(PaymentMethodGateService.class);
     private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
+    private final MultiStoreCheckoutActivationTrigger activationTrigger = mock(MultiStoreCheckoutActivationTrigger.class);
 
     @BeforeEach
     void setUp() {
@@ -49,7 +51,7 @@ class MultiStoreCheckoutCodServiceTest {
     }
 
     private MultiStoreCheckoutCodService subject() {
-        return new MultiStoreCheckoutCodService(checkout, orders, stores, payments, cancellation);
+        return new MultiStoreCheckoutCodService(checkout, orders, stores, payments, cancellation, activationTrigger);
     }
 
     @Test
@@ -60,6 +62,7 @@ class MultiStoreCheckoutCodServiceTest {
         assertEquals("PENDING", subject().confirm(99L, 100L, 42L));
         verify(checkout, never()).activateIfPending(eq(99L), any());
         verify(checkout, never()).insertActivationEvent(eq(99L), any());
+        verifyNoInteractions(activationTrigger);
     }
 
     @Test
@@ -70,6 +73,7 @@ class MultiStoreCheckoutCodServiceTest {
         assertEquals("ACTIVE", subject().confirm(99L, 200L, 42L));
         verify(checkout).activateIfPending(eq(99L), any(LocalDateTime.class));
         verify(checkout).insertActivationEvent(eq(99L), any(LocalDateTime.class));
+        verify(activationTrigger).scheduleAfterCommit(99L);
     }
 
     @Test
@@ -80,10 +84,28 @@ class MultiStoreCheckoutCodServiceTest {
         verify(checkout, never()).updateMemberCodReady(any(), any());
         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");
+        when(checkout.countPaymentAttemptsByMemberId(1L)).thenReturn(1L);
         assertThrows(ServiceException.class, () -> subject().confirm(99L, 100L, 42L));
         verify(checkout, never()).activateIfPending(eq(99L), any());
     }
 
+    @Test
+    void selectedOnlineMethodCanBecomeCodOnlyBeforeGatewayAttempt() {
+        OrderCheckoutMember online = member(1, "ONLINE_PENDING");
+        online.setPayType("3");
+        arrange(List.of(online, member(2, "UNSELECTED")), LocalDateTime.now().plusMinutes(1));
+        PosOrder order = orders.selectById(100L);
+        order.setPayType("3"); order.setPayStatus(0L);
+        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(checkout).switchOrderPayment(100L, "3", 0L, "1", 1L);
+    }
+
     @Test
     void fullyReadyButExpiredAppointmentCannotActivate() {
         arrange(List.of(member(1, "COD_CONFIRMED"), member(2, "UNSELECTED")),

+ 3 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutControllerTest.java

@@ -32,7 +32,9 @@ class MultiStoreCheckoutControllerTest {
 
     @Test
     void createAndCancelBatchUseAuthenticatedUser() {
-        MultiStoreBatchCreateRequest create = new MultiStoreBatchCreateRequest("key-1", "quote-1", List.of(1L, 2L));
+        MultiStoreBatchCreateRequest create = new MultiStoreBatchCreateRequest("key-1", "quote-1", List.of(1L, 2L),
+                List.of(new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3"),
+                        new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1")));
         MultiStoreBatchCreatedView created = mock(MultiStoreBatchCreatedView.class);
         when(batches.create(create, 33L)).thenReturn(created);
         when(cancellation.cancelByUser(org.mockito.ArgumentMatchers.eq(9L),

+ 5 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactServiceTest.java

@@ -25,6 +25,7 @@ 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.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 class MultiStoreCheckoutOnlineFactServiceTest {
@@ -32,6 +33,7 @@ class MultiStoreCheckoutOnlineFactServiceTest {
     private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
     private final PosOrderMapper orders = mock(PosOrderMapper.class);
     private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
+    private final MultiStoreCheckoutActivationTrigger activationTrigger = mock(MultiStoreCheckoutActivationTrigger.class);
 
     @BeforeEach
     void setUp() {
@@ -45,7 +47,7 @@ class MultiStoreCheckoutOnlineFactServiceTest {
     }
 
     private MultiStoreCheckoutOnlineFactService subject() {
-        return new MultiStoreCheckoutOnlineFactService(checkout, orders, cancellation);
+        return new MultiStoreCheckoutOnlineFactService(checkout, orders, cancellation, activationTrigger);
     }
 
     @Test
@@ -104,12 +106,14 @@ class MultiStoreCheckoutOnlineFactServiceTest {
 
         assertEquals("PENDING", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86));
         verify(checkout, never()).activateIfPending(eq(99L), any());
+        verifyNoInteractions(activationTrigger);
 
         arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "COD_CONFIRMED")),
                 "PENDING", now.plusMinutes(1), now.plusMinutes(6));
         when(checkout.activateIfPending(eq(99L), any())).thenReturn(1);
         assertEquals("ACTIVE", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86));
         verify(checkout).insertActivationEvent(eq(99L), any());
+        verify(activationTrigger).scheduleAfterCommit(99L);
     }
 
     @Test

+ 23 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionServiceTest.java

@@ -69,10 +69,32 @@ class MultiStoreCheckoutOnlineSelectionServiceTest {
         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");
     }
 
+    @Test
+    void changesSelectedOnlineMethodBeforeAnyGatewayDispatch() {
+        arrange("ONLINE_PENDING", "3", LocalDateTime.now().plusMinutes(2));
+        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(checkout).switchOrderPayment(100L, "3", 0L, "2", 0L);
+    }
+
+    @Test
+    void changesUnactivatedCodMemberBackToOnlineWithoutClaimingPayment() {
+        arrange("COD_CONFIRMED", "1", LocalDateTime.now().plusMinutes(2));
+        orders.selectById(100L).setPayStatus(1L);
+        when(checkout.switchMemberPayment(1L, "COD_CONFIRMED", "1", "ONLINE_PENDING", "3", null)).thenReturn(1);
+        when(checkout.switchOrderPayment(100L, "1", 1L, "3", 0L)).thenReturn(1);
+
+        assertEquals("ONLINE_PENDING", subject().select(99L, 100L, 42L, "3"));
+        verify(checkout).switchOrderPayment(100L, "1", 1L, "3", 0L);
+    }
+
     @Test
     void refusesTransferAndForeignBatchBeforeAnyPaymentWrite() {
         arrange("UNSELECTED", null, LocalDateTime.now().plusMinutes(2));
@@ -93,7 +115,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.setPayStatus(0L); order.setState(0L); order.setPayType(payType);
         when(orders.selectById(100L)).thenReturn(order);
         PosStore store = new PosStore();
         store.setId(1); store.setUserId(1L);

+ 36 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreRiderRoutesTest.java

@@ -0,0 +1,36 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.mockito.Mockito.mockStatic;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class MultiStoreRiderRoutesTest {
+    private final MockMvc mvc = MockMvcBuilders.standaloneSetup(new PosOrderQsOprateController()).build();
+
+    @Test
+    void existingMultiStoreListUrlsReachRiderControllerAndKeepUpgradeGate() throws Exception {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.upgrade.required"))
+                    .thenReturn("upgrade");
+            mvc.perform(get("/system/multiStoreCheckout/rider/tasks").header("token", "test"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.errorCode").value("UPGRADE_REQUIRED"));
+            mvc.perform(get("/system/multiStoreCheckout/rider/taskCount").header("token", "test"))
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.errorCode").value("UPGRADE_REQUIRED"));
+        }
+    }
+
+    @Test
+    void legacyRiderListUrlStillResolves() throws Exception {
+        mvc.perform(get("/system/orderQsOprate/orderList"))
+                .andExpect(status().isBadRequest());
+    }
+}

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderCheckoutMapper.java

@@ -36,10 +36,23 @@ public interface OrderCheckoutMapper {
 
     int updateOnlineOrderType(@Param("orderId") Long orderId, @Param("payType") String payType);
 
+    /** 仅批次待生效且未发起过网关交易的成员允许改选;调用方须先持有批次行锁。 */
+    long countPaymentAttemptsByMemberId(@Param("memberId") Long memberId);
+
+    int switchMemberPayment(@Param("memberId") Long memberId, @Param("oldState") String oldState,
+                            @Param("oldPayType") String oldPayType, @Param("newState") String newState,
+                            @Param("newPayType") String newPayType, @Param("readyAt") LocalDateTime readyAt);
+
+    int switchOrderPayment(@Param("orderId") Long orderId, @Param("oldPayType") String oldPayType,
+                           @Param("oldPayStatus") Long oldPayStatus, @Param("newPayType") String newPayType,
+                           @Param("newPayStatus") Long newPayStatus);
+
     int activateIfPending(@Param("batchId") Long batchId, @Param("activatedAt") LocalDateTime activatedAt);
 
     int insertActivationEvent(@Param("batchId") Long batchId, @Param("createdAt") LocalDateTime createdAt);
 
+    Long selectPendingActivationEventIdByBatchId(@Param("batchId") Long batchId);
+
     List<Long> selectPendingActivationEventIds(@Param("now") LocalDateTime now, @Param("limit") int limit);
 
     OrderCheckoutOutboxEvent selectOutboxByIdForUpdate(@Param("id") Long id);

+ 22 - 0
ruoyi-system/src/main/resources/mapper/system/OrderCheckoutMapper.xml

@@ -157,6 +157,23 @@
         WHERE id=#{orderId} AND pay_status=0 AND state=0 AND is_display=0 AND pay_type IS NULL
     </update>
 
+    <select id="countPaymentAttemptsByMemberId" resultType="long">
+        SELECT COUNT(*) FROM order_checkout_payment_attempt WHERE member_id=#{memberId}
+    </select>
+
+    <update id="switchMemberPayment">
+        UPDATE order_checkout_member SET payment_state=#{newState}, pay_type=#{newPayType},
+          ready_at=#{readyAt}, update_time=NOW(3), version=version+1
+        WHERE id=#{memberId} AND payment_state=#{oldState} AND pay_type=#{oldPayType}
+    </update>
+
+    <update id="switchOrderPayment">
+        UPDATE pos_order SET pay_type=#{newPayType}, pay_status=#{newPayStatus},
+          collect_payment=CASE WHEN #{newPayType}='1' THEN '1' ELSE NULL END
+        WHERE id=#{orderId} AND pay_type=#{oldPayType} AND pay_status=#{oldPayStatus}
+          AND state=0 AND is_display=0
+    </update>
+
     <update id="activateIfPending">
         UPDATE order_checkout_batch SET checkout_status='ACTIVE', activated_at=#{activatedAt},
           update_time=#{activatedAt}, version=version+1
@@ -171,6 +188,11 @@
         ON DUPLICATE KEY UPDATE id=id
     </insert>
 
+    <select id="selectPendingActivationEventIdByBatchId" resultType="long">
+        SELECT id FROM order_checkout_outbox
+        WHERE event_type='CHECKOUT_ACTIVATED' AND aggregate_id=#{batchId} AND status='PENDING'
+    </select>
+
     <select id="selectPendingActivationEventIds" resultType="long">
         SELECT id FROM order_checkout_outbox
         WHERE event_type='CHECKOUT_ACTIVATED' AND status='PENDING' AND next_retry_at &lt;= #{now}

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

@@ -7,15 +7,15 @@
 | 方法与路径 | 请求关键字段 | 成功响应 | 拒绝条件 |
 |---|---|---|---|
 | `POST /quote` | 地址 ID、顺序候选店与商品/规格/数量、同骑手、各店即时/预约时段 | `quoteToken`、有效至、锚点/围栏、参与店及逐店原运费/折后运费/商品价/实付/可选支付方式、圈外店和不兼容原因、规则版本 | 店/商品/配送资格不合格、支付方式仅转账、报价配置非法;圈外店仅报告不创建 |
-| `POST /batches` | `requestKey`、`quoteToken`、用户明确确认的参与店 ID 列表 | 父批次号、子单号/金额/可选方式、`operationDeadlineAt`、`finalDeadlineAt`、`PENDING` | 报价过期、成员/地址/价格/规则变化、夹带圈外店、少于 2 店、权限不符;同用户同 requestKey 返回同批或拒绝载荷冲突 |
+| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]` | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;含线上为 `PENDING`,全到付为 `ACTIVE` | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/不可用、权限不符;同用户同 requestKey 返回同批或拒绝载荷冲突 |
 | `GET /batches/{batchId}` | token + 路径批次 ID | 批次/逐店支付选择、真实实收、到付确认、退款与履约状态、截止时间、关联订单 | 非本人批次拒绝,不暴露其他用户父/子/组 |
-| `POST /batches/members/payment` | body `{batchId,orderId,payType,clientRequestId}`,`payType` = 1/2/3/5 | 到付返回批次状态 `PENDING/ACTIVE/CANCELLED`;线上仅返回 `ONLINE_PENDING`,随后按子单号调用原 `/pay/line/create` 或 `/pay/omg/create` 取得付款凭证,不能把选择结果当实收 | 截止后新发起、未决尝试换方式、商家不可用、金额/归属不符;6 转账始终不支持 |
+| `POST /batches/members/payment` | body `{batchId,orderId,payType,clientRequestId}`,`payType` = 1/2/3/5 | 新批次的安全改选:到付返回批次状态,线上返回 `ONLINE_PENDING`;网关创建仍走原接口 | 批次已生效/过操作截止、该店已有任何网关尝试或实收、商家不可用、归属不符;6 转账始终不支持 |
 | `POST /batches/cancel` | body `{batchId,reason:"USER_CANCEL",clientRequestId}` | 当前入口只返回是否成功关闭未生效批次的布尔值;退款真实进度待详情接口返回 | 已 `ACTIVE` 须走子单取消规则;退款处理中不伪称退款成功 |
 | `POST /batches/members/cancel` | body `{batchId,orderId,reason:"USER_CANCEL",clientRequestId}` | 当前入口只返回是否成功取消该子单;退款真实进度待详情接口返回,其他成员费用快照不变 | 未生效不允许单店取消;骑手已接/取餐拒绝用户取消;并发接组只能一方成功 |
 
 每店报价另外区分 `discountedFreight`(骑手毛运费)、`merchantDeliverySubsidy`(商家免运承担)、`customerDeliveryFee`(客户运费)及 `amount`(优惠后商品金额+客户运费)。商家满额免运以多店折后运费为基数:原运费40、八折后32、全免时客户运费0、商家补贴32、骑手仍收32。到付给商家金额为 `amount - discountedFreight`;普通单的免运口径不改。
 
-回调/补单仍使用现有 LINE/OMG 入口,服务端以原订单/尝试号识别新批次成员:先验签与核实实收、落支付事实,再在事务内聚合批次;不得直接发履约新单通知。到付只表示用户确认按现有流程交餐付款,不伪称线上实收。用户列表应展示未选方式、部分付款、缓冲核实、待批次生效、退款中并提供继续结算入口;普通旧单字段语义不变。
+回调/补单仍使用现有 LINE/OMG 入口,服务端以原订单/尝试号识别新批次成员:先验签与核实实收、落支付事实,再在事务内聚合批次;不得直接发履约新单通知。到付只表示用户确认按现有流程交餐付款,不伪称线上实收。用户列表应展示逐店已选方式、部分付款、缓冲核实、待批次生效、退款中并提供继续结算入口;普通旧单字段语义不变。
 
 036 子单调用原 LINE/OMG 创建入口、OMG 重试入口同样须携带上述能力头,旧版 App 不能通过原网关入口绕过逐店收银。原 LINE/OMG 网关查询、异步回调、补单继续按原交易事实核实,不以客户端能力头作为可信付款事实。
 LINE 创建使用 `{ddId:orderNo}`;OMG 创建使用 `{orderId:orderNo,paymentMethod:"CREDIT"}` 对应 `payType=2`,或 `paymentMethod:"APPLE_PAY"` 对应 `payType=5`。`POST /batches/members/payment` 只选付款方式,不替代上述网关创建,也不返回跳转凭证。

+ 17 - 15
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -6,7 +6,7 @@
 
 核心概念(先读这段):
 
-- 用户一次选购**至少 2 家门店**、由**同一骑手**合并配送:报价 → 创建批次 → 逐店选择支付方式 → 全部子单就绪后批次生效 → 生成骑手配送组 → 骑手**整组接单**、**逐店**取餐送达。
+- 用户一次选购**至少 2 家门店**、由**同一骑手**合并配送:报价 → 逐店选择支付方式并创建批次 → 完成线上支付(到付已在创建时确认)→ 全部子单就绪后批次生效 → 骑手**整组接单**、**逐店**取餐送达。
 - 每家店是一个独立**子单**(有自己的 orderId/orderNo 和支付方式);**批次(batch)**是收银单位,**配送组(group)**是履约单位。
 - App 一律按服务端返回渲染状态、金额和可操作按钮;不在本地推算倒计时、重算费用或推断可操作性。
 
@@ -18,7 +18,7 @@
 
 当前支持:
 
-- 用户端:多店报价、创建批次、逐店选择到付/线上支付(LINE Pay、OMG 信用卡、OMG Apple Pay)、批次详情、组合订单列表(普通单卡与批次卡同列返回)、整批取消、单店取消。
+- 用户端:多店报价、逐店选择到付/线上支付(LINE Pay、OMG 信用卡、OMG Apple Pay)并创建批次、安全改选、批次详情、组合订单列表(普通单卡与批次卡同列返回)、整批取消、单店取消。
 - 骑手端:组合任务列表(普通单卡与组卡同列返回)、角标、组详情、整组接单;逐店取餐/送达复用现有单笔接口。
 - 即时配送与预约配送(全批次同一预约时段)。
 
@@ -75,7 +75,7 @@
 ### 3.1 生命周期
 
 ```text
-报价(quote) → 建批(PENDING) → 逐店选择支付 → 全部就绪 ⇒ ACTIVE(开放履约,生成配送组 WAITING)
+报价(quote) → 建批并提交逐店 payType → 线上逐店支付 → 全部就绪 ⇒ ACTIVE(开放履约,生成配送组 WAITING)
                                   └→ 超时/整批取消/预约过期 ⇒ CANCELLED
 配送组:WAITING(待抢)→ ACCEPTED(已接,逐店取送)→ FINISHED / CANCELLED
 ```
@@ -94,7 +94,7 @@
 
 | 值 | 含义 |
 |---|---|
-| `UNSELECTED` | 尚未选择支付方式 |
+| `UNSELECTED` | 旧批次尚未选择支付方式;新建批次不会产生此状态 |
 | `ONLINE_PENDING` | 已选线上方式(LINE/OMG),等待用户发起并完成支付 |
 | `ONLINE_PAID` | 服务端收到渠道可信回调,已实际收款 |
 | `COD_CONFIRMED` | 用户已确认到付,交餐时向骑手付款 |
@@ -105,10 +105,10 @@
 
 | 值 | 含义 | 说明 |
 |---|---|---|
-| `"1"` | 到付 | 在 5.3 接口直接确认,无需网关 |
-| `"2"` | OMG 信用卡 | 5.3 返回 ONLINE_PENDING 后调 `/pay/omg/create` |
-| `"3"` | LINE Pay | 5.3 返回 ONLINE_PENDING 后调 `/pay/line/create` |
-| `"5"` | OMG Apple Pay | 5.3 返回 ONLINE_PENDING 后调 `/pay/omg/create` |
+| `"1"` | 到付 | 建批时即确认,无需网关 |
+| `"2"` | OMG 信用卡 | 建批返回 ONLINE_PENDING 后调 `/pay/omg/create` |
+| `"3"` | LINE Pay | 建批返回 ONLINE_PENDING 后调 `/pay/line/create` |
+| `"5"` | OMG Apple Pay | 建批返回 ONLINE_PENDING 后调 `/pay/omg/create` |
 | `"6"` | 线下转账 | **新批次不支持**,提交即被拒 |
 
 `availablePayTypes` 由服务端按门店返回(可能不含某些方式,如商家不可用到付),App 只按返回渲染。
@@ -246,7 +246,8 @@ POST /system/multiStoreCheckout/batches
 请求体:
 
 ```json
-{ "requestKey": "本次提交固定唯一键", "quoteToken": "报价返回值", "participatingStoreIds": [101, 102] }
+{ "requestKey": "本次提交固定唯一键", "quoteToken": "报价返回值", "participatingStoreIds": [101, 102],
+  "paymentMethods": [{"storeId":101,"payType":"3"},{"storeId":102,"payType":"1"}] }
 ```
 
 | 字段 | 类型 | 必填 | 说明 |
@@ -254,6 +255,7 @@ POST /system/multiStoreCheckout/batches
 | `requestKey` | string | 是 | 幂等键:网络重试沿用同一键返回同一批次;换新键会创建新批次 |
 | `quoteToken` | string | 是 | 5.1 返回的报价令牌;过期报"报价已过期,请重新获取" |
 | `participatingStoreIds` | long[] | 是 | 用户明确确认参与的门店,须是报价参与店的子集且 ≥2 家;夹带圈外店会被拒 |
+| `paymentMethods` | array | 是 | 与参与店一一对应的 `{storeId,payType}`;每店恰好一条,只能选择该店报价提供的 `availablePayTypes`,不支持转账 `"6"` |
 
 响应 `data`:
 
@@ -261,15 +263,15 @@ POST /system/multiStoreCheckout/batches
 |---|---|---|
 | `batchId` | long | 批次 ID,后续接口都用它 |
 | `parentOrderNo` | string | 父批次号 |
-| `checkoutStatus` | string | 创建后为 `PENDING` |
+| `checkoutStatus` | string | 含线上付款时为 `PENDING`;全到付时创建即为 `ACTIVE`,实际开放由出站流程处理 |
 | `totalAmount` | int | 批次总金额(TWD 元) |
 | `operationDeadlineAt` | string | 操作截止(创建后 15 分钟,见 8.1) |
 | `finalDeadlineAt` | string | 最终截止(创建后 20 分钟,见 8.1) |
-| `members[]` | array | `{orderId, orderNo, storeId, amount, availablePayTypes[]}`,逐店保存好 orderNo |
+| `members[]` | array | `{orderId, orderNo, storeId, amount, payType, paymentState, availablePayTypes[]}`,逐店保存好 orderNo |
 
 注意:幂等重试返回的 `availablePayTypes` 可能为空数组,**不能**当作门店永久不支持付款;以最新报价和付款接口校验为准。
 
-### 5.3 逐店选择支付方式 / 确认到付
+### 5.3 逐店安全改选支付方式
 
 ```http
 POST /system/multiStoreCheckout/batches/members/payment
@@ -296,8 +298,8 @@ POST /system/multiStoreCheckout/batches/members/payment
 
 规则:
 
-- 到付确认要求批次 `PENDING`、未过 `operationDeadlineAt` 且该店 `UNSELECTED`;已确认为到付的子单重复调用幂等返回当前状态。
-- 线上选择要求批次 `PENDING`、未过 `operationDeadlineAt` 且该店 `UNSELECTED`;**未决的线上尝试不能换渠道**(同渠道重试幂等返回 `ONLINE_PENDING`)。
+- 新批次已在 5.2 保存逐店方式,首次支付无需调用本接口;本接口仅用于用户修改方式。批次须 `PENDING` 且未过 `operationDeadlineAt`,`ACTIVE` 后不可再改。
+- 线上→线上、线上→到付、到付→线上只在该店**从未发起网关交易**时允许;一旦有网关尝试记录,含结果未明的尝试,拒绝改选,先沿原渠道查询或完成支付。已真实付款不可改。相同方式重试保持幂等。
 - 该方式必须在门店 `availablePayTypes` 内;不可用会被拒。
 
 ### 5.4 发起线上支付(原渠道接口)
@@ -394,7 +396,7 @@ GET /system/multiStoreCheckout/rider/tasks?page=1&size=10&tab=newTask&longitude=
 | `tab` | 否 | 默认 `newTask`,取值见 3.6 |
 | `longitude` / `latitude` | 否 | 骑手坐标。**只用于普通单排序兼容**,不能替代服务端位置;待抢组资格以服务端最近位置与首店距离为准 |
 
-响应 `data` 为分页结构,`records[]` 中 `GROUP` 卡一张 = 一组(角标与列表同口径,组只计一次)。待抢组只在批次已生效且到预约开放时点后才可见;新任务有有效坐标时按首店距离升序,其余按创建时间方向。
+响应 `data` 为分页结构,`records[]` 中 `GROUP` 卡一张 = 一组(角标与列表同口径,组只计一次)。待抢组只在批次已生效且到预约开放时点后才可见;新任务有有效坐标时按首店距离升序,其余按创建时间方向。2026-10-21 起,最后成员就绪并提交后会立即尝试开放即时/已到点批次,预约未到仍由分钟任务到点开放;App 不需等待固定一分钟才查询。此 URL 与下方角标 URL 保持不变,服务端方法归原骑手控制器。
 
 ### 6.2 各页签任务数(角标)
 

+ 3 - 1
specs/036-multi-store-checkout/plan.md

@@ -36,10 +36,12 @@
 
 ### 每店不同支付方式的建议
 
-复用StorePayMethodController和PaymentMethodGateService逐店查询平台/商家/凭证可用方式,不取跨店交集。统一创建仅校验参与店至少有一种本批次可用方式,不要求创建请求逐项携带已选方式,也不默认选到付;后续逐子单收银请求再传所选payType、重新校验并保存。旧OrderCreateInput全局paymentMethod及旧单店行为保留,不直接照搬其创建即设到付准入的逻辑。
+复用StorePayMethodController和PaymentMethodGateService逐店查询平台/商家/凭证可用方式,不取跨店交集。2026-10-21 调整:建批请求必须逐店提交`paymentMethods:[{storeId,payType}]`,服务端与参与店及最新报价的可用方式一一核对,固化子单和成员支付方式;线上只进入`ONLINE_PENDING`,到付进入`COD_CONFIRMED`,全到付建批即可激活。幂等摘要包含逐店选项。批次待生效、操作截止前且该店没有任何网关尝试时,专用支付入口允许安全改选;一旦请求已发往网关则先核实原交易,不能换渠道。旧OrderCreateInput全局paymentMethod及旧单店行为保留。
 
 三店均参与、A选LINE/B选OMG/C选到付:C须在15分钟期限内合法确认,A/B须在该期限内由服务端记录并实际发起支付尝试,可在默认5分钟可配置缓冲内完成真实付款和核实。最后成员就绪即生效,C仅创建不就绪,不提前推送或被抢;全到付最后一单须在15分钟内确认,缓冲不允许新选择/发起/重试/切换或到付确认。两个截止在创建时固定,不因逐店付款/重试延长。
 
+2026-10-21 开放执行补充:线上最后付款、到付最后确认和全到付建批在同一事务内只写 `ACTIVE` 与持久开放事件;提交后通过独立事务按批次取事件,调用与分钟任务共用的开放逻辑。即时单和已到旧预约开放时点的批次立即开放;未到时点保留 `PENDING`,分钟任务处理到点及失败/重启补偿。开放事务只写子单显示、组 `opened_at` 和事件 `OPENED`;通知仍由独立消费者处理。新版骑手列表/角标方法归 `PosOrderQsOprateController`,对外 URL 和聚合协议保持不变,旧列表只供兼容。
+
 取消/超时按每笔真实收款处理:A/B 已实收则原渠道退款,C 未实收只取消、不产生网关退款。支付进度分别展示线上已付/待付和到付约定,不能把到付计为已实收。骑手取餐、收款页面需逐子单显示付款方式,并单独合计到付待收金额,不能向用户再次收取线上已付餐费。线下收款路线仍沿用现行到付逻辑,不引入新资金池。
 
 混用、逐笔选方式及统一15分钟发起/到付确认期限已确认。已就绪数与实际已付款数分别展示:到付确认参加就绪进度,不参与实收金额。支付失败/未知或未选方式仍不就绪;客户端回跳/已完成标志不能代替服务端事实。旧渠道尝试未决时不能直接切到到付并宣称就绪,须先核实/明确对账补偿,不直接复用旧切换接口或假定旧交易已经关闭。

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

@@ -52,7 +52,7 @@
 - 用户可选择同骑手;不合并或围栏外订单独立配送、独立计费。按用户最新澄清,圈外店不挂在本次合并结算批次内,另行结算,不等待其支付。
 - 配送费按参与合并的各店原配送费之和打折;示例两单九折、三单以上八折,阶梯可配置。
 - 原文合并数量建议值5~8、示例最多5单;2026-10-13用户确认首版默认最多5家且可配置,取代原未定默认值;使用全局规则,暂不做城市/区域覆盖。
-- 分笔支付并显示进度,未生效时只能取消整批;最新规则为15分钟支付发起/到付确认期限,已有线上支付可在默认5分钟可配置缓冲内完成和核实。全到付不免期限;第15分钟后不能新发起支付,但已打开的合法渠道页面可继续完成付款。全部合法成员未就绪前不开放履约。
+- 分笔支付并显示进度,未生效时只能取消整批;建批时逐店提交付款方式,到付即确认,线上仍需真实支付。含未付款线上子单的批次有15分钟新支付发起期限,已有线上尝试可在默认5分钟可配置缓冲内完成和核实;第15分钟后不能新发起支付,但已打开的合法渠道页面可继续完成付款。全部合法成员未就绪前不开放履约。
 - 超时整批取消,已付款的逐笔原路退款;付清并生效后按正常售后处理。
 - 原文提出单店售后取消不退配送费;最新确认覆盖用户主动取消规则:未接单实收全退含运费,已接单用户不得取消,不采用申请取消/商家审核,也不采用退餐款保留运费的用户取消路径。商家/平台异常仍待确认。
 - 全部门店和商品提交前校验;订单详情显示关联订单。
@@ -135,12 +135,12 @@
 
 用户可以预约多店平台配送,也可以选择到付;到付不是在线付款成功,预约不是提交后立即抢单。
 
-**Independent Test**: 预约批次在15分钟内发起各线上支付/确认到付,既有线上交易可在缓冲内完成和核实,全部就绪后仍等待原预约开放时间;即时全到付第三笔确认前不能被抢、全部确认后仅生效一次,缓冲不放宽到付确认期限,不造退款。
+**Independent Test**: 建批逐店提交方式后,预约批次在15分钟内发起各线上支付,既有线上交易可在缓冲内完成和核实,全部就绪后仍等待原预约开放时间;即时全到付在建批时一次确认并仅激活一次,不造线上退款。
 
 **Acceptance Scenarios**:
 
-1. 即时/预约、线上/到付均共用创建起15分钟支付发起/到付确认期限,不延长至预约日期;截止前已记录并实际发起的线上尝试可在默认5分钟可配置缓冲内完成及核实,缓冲不开放新的支付发起、重试、方式切换或到付确认。全部就绪生效后等待原预约开放,不再因结算倒计时取消。
-2. 到付子单须用户逐店选择且服务端合法确认,不在创建时默认就绪。所有到付确认完成才统一生效,不在第一条创建或首次确认时提前推送。
+1. 即时/预约均共用创建起15分钟新支付发起/方式改选期限,不延长至预约日期;到付已在建批时由用户明确确认。截止前已记录并实际发起的线上尝试可在默认5分钟可配置缓冲内完成及核实,缓冲不开放新的支付发起、重试或方式切换。全部就绪生效后等待原预约开放,不再因结算倒计时取消。
+2. 到付子单须用户逐店明确选择且服务端合法确认,不得静默默认到付。建批时逐店明确选择到付即为该店确认;全部成员就绪才统一生效,不在尚有线上未付款的子单时提前推送。
 3. 接单、商家处理、列表和通知共同检查批次生效及预约开放条件;仅改 isDisplay 不能防止直接接口提前接单。
 4. 本轮补充旧时点下普通平台配送/新多店预约后续开放扫描、重启补扫、开放幂等及通知重试;新批次额外检查已生效,普通预约不套用新期限,通知不得提前或重复。不复用废弃TestTask;新预约语义与倒推最迟接单算法延期,037 现有无人接单规则的多店接入按 FR-042 实现。
 5. 到付取消不产生未实收款项的在线退款;实际线下收款、取餐和售后仍按现行到付流程处理,不把旧 payStatus=1 当作渠道实收证据。
@@ -173,7 +173,7 @@
 - **FR-001** 使用现有父子订单表达一次结算;各子单各商家独立收款。
 - **FR-002** 结算批次和配送组职责必须区分:普通不合并结算不创建组;同骑手结算父单仅含本次确认参与合并的店。购物车圈外店不挂入该父单,不允许“A/B组加C独立单”仍共用付款生效闸门。
 - **FR-003** 同骑手资格、范围、参与店数、报价和总额均由服务端重新计算校验;首店到后续各店使用坐标直线距离,范围默认1公里、平台可配置,不按道路距离或写死1公里。范围须有效且大于0,缺失/非法沿合并配置保守关闭规则处理;提交重验规则/金额并固化快照。
-- **FR-004** 所有本批成员共用创建起15分钟支付发起/到付确认期限;期限内服务端已记录并实际发起的线上尝试允许在已快照的缓冲内完成付款及核实,缓冲默认5分钟且平台可配置,不开放新的交易。全部合法成员就绪即生效,不预先区分全线上/全到付,不按创建或默认payStatus开放。认证或支付请求创建不等于实收,待核实阶段仍禁止履约,预约另满足原时间规则,列表/通知/操作共同校验。
+- **FR-004** 所有本批成员共用创建起15分钟新支付发起/方式改选期限;建批时明确选择到付即确认,选择线上仍须实际支付。期限内服务端已记录并实际发起的线上尝试允许在已快照的缓冲内完成付款及核实,缓冲默认5分钟且平台可配置,不开放新的交易。全部合法成员就绪即生效;全到付可在建批后激活,但不能仅凭默认payStatus开放。认证或支付请求创建不等于实收,待核实阶段仍禁止履约,预约另满足原时间规则,列表/通知/操作共同校验。
 - **FR-005** 保留子单付款事实;父批次是否生效不覆盖子单支付、退款事实。
 - **FR-006** 父批次未生效时,主动取消/超时整批取消,已付款交易分别真实退款并独立跟踪。
 - **FR-007** 付款完成、取消、到期、补单和迟到付款处理必须幂等,取消不得被后续付款重新激活。
@@ -255,7 +255,7 @@
 - **SC-013** 生效后未接组三单取消C:线上实收132原交易退款132含32运费,到付未收无退款;A/B继续待接不补差,C不能被分配。并发接组先胜时拒绝未接单取消路径;取消全部成员时任务结束,退款幂等并保持真实进度。
 - **SC-014** 已接组在未备餐、备餐、未取/已取阶段,用户单笔/整组及直接接口取消均拒绝,无状态更新、容量释放、退款或审核任务;特别覆盖state=0但deliveryStatus=1且qsId已分配的商家未接场景,以及组已承接但子单字段异常不一致时保守拒绝。接组与用户取消并发仍无“已承接却按未接全退”,校验后发生承接必须使取消CAS失败。旧非适用单据原流程保持不变。
 - **SC-015** 已接组C无法供餐且未取,商家只取消C、真实线上132全退含32运费;A/B仍由原骑手履约不补差,待收/站点/容量及通知一致;到付未收不造网关退款。取餐先成功拒绝此取消路径,重复请求不重复退款、商家无越权,全组有效成员皆取消则任务结束。
-- **SC-016** 全到付/全线上/混合/未选共用15分钟发起/到付确认截止;创建/部分到付确认不生效,缓冲不开放新支付发起/重试/换方式/到付确认。默认5分钟缓冲允许截止前已记录并实际发起的线上尝试完成和核实,最后合法成员就绪只激活一次;最终截止未就绪实收全退,无收款到付不造退款。主动取消后/最终截止后发现线上成功退款不重开;生效后不再按结算未完成取消。
+- **SC-016** 全到付/全线上/混合共用15分钟新支付发起与改选截止;全到付建批即确认并可激活,混合批次仍待线上实收。缓冲不开放新支付发起/重试/换方式;默认5分钟缓冲允许截止前已记录并实际发起的线上尝试完成和核实,最后合法成员就绪只激活一次;最终截止未就绪实收全退,无收款到付不造退款。主动取消后/最终截止后发现线上成功退款不重开;生效后不再按结算未完成取消。
 - **SC-017** 验证动态增加/修改/删除档位、最高门槛匹配、不同原运费分别打折、去重/圈外店不计数、重复门槛及非法系数拒绝。新增4家七折仅影响新报价;已有4家八折订单不重算,部分取消不补差;提交重验报价防止混用配置版本。
 - **SC-018** 首版不同城市使用同一套全局规则,初始2家九折、3/4/5家八折且无4家七折档;合法配置默认5家时5家可进入后续资格/任务容量校验、6家不能合并,不静默拆组。修改最大店数后预结算/创建/接组重验,上限调整不强制取消已接组;现有字典缺失回退1任务仍可承接一个合法五店组,不误计为5个任务,不新增子单容量配置。
 - **SC-019** 验证每店舍入方向和单位:41元八折32.8元取33元,45元九折40.5元取41元;三店原运费均41元、八折时各33元,总运费99元,不能按总额123元八折98.4元另舍入为98元。支付/退款/到付/骑手毛运费及账单基数一致,配置变化或允许取消不重算剩余金额,不产生跨店尾差。
@@ -332,3 +332,15 @@
 - **FR-052** 按用户确认,新建多店父单沿用`99`+13位毫秒序列的15位纯数字单号;子单为父号+3位店序号,共18位,禁止UUID或新MS前缀。服务端通过已有Redis原子毫秒序列分配,处理同毫秒并发及进程重启,并与普通用户单、商家代客单共用号码占用及持久记录检查,避免同号空间撞号;事务回滚释放占用,不新增业务配置或数据库列。单号以字符串返回和回传,序号不限制骑手取送顺序。
 - **FR-053** LINE/OMG创建、重试、实收回调、取消、骑手接单、平台查询和旧父单详情均以真实批次/成员关联确认多店身份;普通99订单无关联时仍沿旧流程,不得仅凭99前缀要求升级或套用多店状态机。历史MS号只兼容读取,不再生成;最短关联字段20字符、pos_order.dd_id为30字符,均不可通过截断存储绕过。
 - **SC-040** 验证父15位、子18位、前缀99、全部纯数字、父子/批次响应一致及子号互异;幂等重试不重新编号。数字多店单沿真实关联通过支付、记录实收并保持旧入口防绕过;普通同号段订单保持原支付/取消/列表行为。无需前端新增参数或数据库迁移。
+
+## 2026-10-21 建批逐店付款方式与安全改选
+
+- **FR-054** 新建多店批次必须提交与参与门店一一对应的`paymentMethods:[{storeId,payType}]`,只允许该店最新报价可用的`1/2/3/5`,不得由服务端默认到付。每个子单和成员在创建时保存所选方式;线上为`ONLINE_PENDING`但并未实收,到付为`COD_CONFIRMED`,全到付批次创建后进入统一激活出站流程。幂等请求包含选择快照,重试不能覆盖原单。
+- **FR-055** 多店专用支付接口允许在批次`PENDING`、操作截止前且该成员从未产生网关尝试时改选线上/到付方式;持同一批次锁更新成员和子单。已发起任何网关尝试、已实收、批次生效/取消或超时均拒绝;同方式重试保持幂等。普通订单支付切换逻辑不变。
+- **SC-041** 验证漏选、重复、额外门店或门店不可用方式被拒且不写订单;混合方式初始状态、全到付激活、请求键载荷冲突、网关尝试前可改/尝试后不可改,以及普通订单回归。
+
+## 2026-10-21 批次即时开放与骑手查询归口
+
+- **FR-056** 线上最后一笔可信实收、到付最后确认或全到付建批令批次激活时,在原事务持久登记开放事件;仅事务提交后以独立事务复用统一开放条件。即时单及已到原预约开放时点的批次立即写入子单显示和组 `opened_at`;未到时点保留待处理事件。原分钟任务继续处理预约到点、即时触发失败和重启补偿,开放与通知仍为两个阶段,重复触发不得重复开放或推送。
+- **FR-057** 新版骑手列表和角标方法归于 `PosOrderQsOprateController`,对外保留 `/system/multiStoreCheckout/rider/tasks` 与 `/system/multiStoreCheckout/rider/taskCount`,响应、版本门禁和普通单/多店组聚合口径不变;旧 `/system/orderQsOprate/orderList`、`orderCount` 继续兼容旧版单笔列表。
+- **SC-042** 验证事务提交前及回滚时不开放;提交后的即时单开放一次,预约未到不开放、到点由原任务开放;触发失败保留事件可重试;新旧骑手 URL 与版本门禁保持兼容。

+ 15 - 0
specs/036-multi-store-checkout/tasks.md

@@ -140,3 +140,18 @@ T034验证与交付(2026-10-20):
 - 失败先行测试复现原37位子号超长、数字号漏过原MS判断;新增共享号码双向互斥、持久记录存在、事务回滚清理、用户及商家入口写前检查和占号重试回归。最终JDK21执行`mvn -pl ruoyi-admin -am verify`通过:ruoyi-system 166项、ruoyi-admin 1248项,共1414项,失败/错误/跳过均0,所有模块构建成功。`git diff --check`通过。
 - 文档及关键代码注释同步,App仍以string传单号,无新增入参、SQL或字段扩容;沿当前`test-202609v2`提交推送,不创建分支。部署需先具备036关联表(包括功能关闭时的普通订单识别查询),测试库此前已迁移。
 - 测试库只读核对`pos_order.dd_id`为30字符、最短关联字段为20字符,报错requestKey无已提交批次。未修改数据库,未调用真实支付/退款/地图或远端建批HTTP;本地无Redis实例,原子Lua与SET NX按mock契约验证,真实Redis集成和部署后建批仍需联调。
+
+## 2026-10-21 逐店支付方式随建批提交
+
+- [x] T035 建批请求逐店提交`paymentMethods:[{storeId,payType}]`,校验与参与店和报价一致,固化成员/子单支付方式;全到付创建即激活,幂等摘要包含所选方式。
+- [x] T036 多店专用接口在批次锁下安全改选;任何网关尝试后拒绝改选,不改变普通订单支付切换。
+- [x] T037 同步 App 接入契约,运行 JDK21 定向测试与回归、差异检查;不修改 App 源码或数据库。
+
+T035–T037验证:2026-10-21 10:44 使用 JDK21 执行`mvn -pl ruoyi-admin -am verify -q`成功(当时1420项、失败/错误/跳过均0),`git diff --check`通过;未执行真实支付网关联调或部署,也未改 App 源码、普通订单入口和数据库结构。随后同一工作区出现其他并行改动及新的测试,以上全量通过结论不覆盖后续并行改动。
+
+## 2026-10-21 即时开放与骑手查询归口
+
+- [x] T038 三种最终就绪路径提交后独立事务尝试开放,复用原预约时点与原子开放逻辑;分钟任务保留补偿,测试提交/回滚、即时/预约/失败重试。
+- [x] T039 将新版骑手列表和角标方法归到原骑手控制器,对外 URL、版本门禁及旧列表保持不变;更新接入文档并完成路由回归。
+
+T038–T039验证:2026-10-21 使用 JDK21 执行 `mvn -q -pl ruoyi-admin -am verify` 成功,Surefire 报告合计1429项、失败/错误/跳过均0;失败重试及路由等定向31项通过。`git diff --check` 通过。未连接真实数据库、未启用暂停的 Quartz 任务、未调用真实支付或推送渠道。