Răsfoiți Sursa

完善多店结算支付配送流程并接入谷歌路线矩阵

qmj 3 zile în urmă
părinte
comite
a14af81b56
100 a modificat fișierele cu 5647 adăugiri și 99 ștergeri
  1. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java
  2. 31 4
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  3. 22 8
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  4. 41 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  5. 15 24
      ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java
  6. 88 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationService.java
  7. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationTask.java
  8. 90 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActiveMemberCancellationService.java
  9. 25 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAdminBatchController.java
  10. 62 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryService.java
  11. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAppCapability.java
  12. 270 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java
  13. 96 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCancellationService.java
  14. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCancellationTask.java
  15. 93 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCodService.java
  16. 6 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfigService.java
  17. 112 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutController.java
  18. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutGroupCompletionService.java
  19. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutGroupCompletionTask.java
  20. 31 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuard.java
  21. 99 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantCancellationService.java
  22. 77 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantController.java
  23. 105 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNoRiderTimeoutService.java
  24. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNoRiderTimeoutTask.java
  25. 79 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNotificationService.java
  26. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNotificationTask.java
  27. 184 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactService.java
  28. 74 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionService.java
  29. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutPaymentSelectionController.java
  30. 125 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryController.java
  31. 256 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryService.java
  32. 6 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteController.java
  33. 61 23
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteService.java
  34. 70 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundBridgeService.java
  35. 26 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundBridgeTask.java
  36. 27 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundFollowupController.java
  37. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundFollowupService.java
  38. 76 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundProjectionService.java
  39. 55 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRules.java
  40. 138 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreDeliveryGroupAcceptanceService.java
  41. 24 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  42. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  43. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  44. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  45. 22 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryAcceptanceService.java
  46. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  47. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCancelRequest.java
  48. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreateRequest.java
  49. 13 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreatedView.java
  50. 52 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCardView.java
  51. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreGroupAcceptRequest.java
  52. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberCancelRequest.java
  53. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberPaymentRequest.java
  54. 24 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberView.java
  55. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMerchantCancelRequest.java
  56. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStorePageView.java
  57. 2 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreQuoteView.java
  58. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreRefundFollowupPage.java
  59. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreRefundIntentView.java
  60. 8 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreRefundMemberView.java
  61. 142 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/route/GoogleRouteDistanceMatrixProvider.java
  62. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/route/RouteDistanceMatrixProvider.java
  63. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java
  64. 23 4
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java
  65. 32 5
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java
  66. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java
  67. 3 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  68. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  69. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  70. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  71. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  72. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  73. 25 8
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java
  74. 28 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
  75. 20 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  76. 20 6
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryControllerTest.java
  77. 46 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java
  78. 86 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActivationServiceTest.java
  79. 106 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActiveMemberCancellationServiceTest.java
  80. 78 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryServiceTest.java
  81. 197 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java
  82. 116 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCancellationServiceTest.java
  83. 66 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCapabilityGateTest.java
  84. 128 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCodServiceTest.java
  85. 27 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigServiceTest.java
  86. 88 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutControllerTest.java
  87. 89 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutGroupCompletionServiceTest.java
  88. 49 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuardTest.java
  89. 33 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutMapperXmlTest.java
  90. 136 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantCancellationServiceTest.java
  91. 55 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantControllerTest.java
  92. 97 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutNoRiderTimeoutServiceTest.java
  93. 79 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutNotificationServiceTest.java
  94. 197 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactServiceTest.java
  95. 102 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionServiceTest.java
  96. 75 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutPaymentSelectionControllerTest.java
  97. 30 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryControllerTest.java
  98. 201 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java
  99. 154 8
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteServiceTest.java
  100. 80 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutRefundBridgeServiceTest.java

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java

@@ -6,6 +6,7 @@ import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
 import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgRefundPaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgRetryPaymentRequest;
+import com.ruoyi.app.order.MultiStoreCheckoutAppCapability;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -89,8 +90,15 @@ public class OmgPaymentController {
     @Auth
     @PostMapping("/create")
     public AjaxResult create(@RequestHeader(name = "token") String token,
+                             @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                             String capability,
                              @RequestBody(required = false) OmgCreatePaymentRequest request) {
         String orderId = request == null ? null : request.getOrderId();
+        // 普通单继续兼容旧 App;036 子单即使走原网关创建入口也必须携带新版能力标识。
+        if (orderId != null && orderId.trim().startsWith("MS")
+                && !MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
         String paymentMethodValue = request == null ? null : request.getPaymentMethod();
         String safeOrderId = safeLogOrderId(orderId);
         Long safeUserId = null;
@@ -143,8 +151,15 @@ public class OmgPaymentController {
     @Auth
     @PostMapping("/retry")
     public AjaxResult retry(@RequestHeader(name = "token") String token,
+                            @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                            String capability,
                             @RequestBody(required = false) OmgRetryPaymentRequest request) {
         String orderId = request == null ? null : request.getOrderId();
+        // 重试可能创建新的真实网关尝试,不能让旧 App 绕过多店收银能力门禁。
+        if (orderId != null && orderId.trim().startsWith("MS")
+                && !MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
         String paymentMethodValue = request == null ? null : request.getPaymentMethod();
         String safeOrderId = safeLogOrderId(orderId);
         Long safeUserId = null;

+ 31 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.omgpay;
 
 import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.order.MultiStoreCheckoutOnlineFactService;
 
 import com.ruoyi.app.omgpay.dto.OmgCreatePaymentResponse;
 import com.ruoyi.system.domain.PosStoreOmg;
@@ -11,6 +12,7 @@ import com.ruoyi.system.service.IPosStoreOmgService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.dao.DuplicateKeyException;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -25,15 +27,26 @@ public class OmgPaymentCreateService {
     private final IPosStoreOmgService credentials;
     private final OmgMerchantTradeNoGenerator generator;
     private final OmgPaymentFormFactory formFactory;
+    private final MultiStoreCheckoutOnlineFactService multiStoreFacts;
 
     public OmgPaymentCreateService(IOmgPaymentAttemptService attempts,
                                    IPosStoreOmgService credentials,
                                    OmgMerchantTradeNoGenerator generator,
                                    OmgPaymentFormFactory formFactory) {
+        this(attempts, credentials, generator, formFactory, null);
+    }
+
+    @Autowired
+    public OmgPaymentCreateService(IOmgPaymentAttemptService attempts,
+                                   IPosStoreOmgService credentials,
+                                   OmgMerchantTradeNoGenerator generator,
+                                   OmgPaymentFormFactory formFactory,
+                                   MultiStoreCheckoutOnlineFactService multiStoreFacts) {
         this.attempts = attempts;
         this.credentials = credentials;
         this.generator = generator;
         this.formFactory = formFactory;
+        this.multiStoreFacts = multiStoreFacts;
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -43,8 +56,9 @@ public class OmgPaymentCreateService {
             throw business(AUTH_REQUIRED);
         }
         String normalizedOrderId = normalizeOrderId(orderId);
+        if (multiStoreFacts != null) multiStoreFacts.lockBatchIfAny(normalizedOrderId);
         OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(normalizedOrderId);
-        validateOrder(userId, normalizedOrderId, order);
+        validateOrder(userId, normalizedOrderId, order, paymentMethod);
         if (attempts.selectActiveCreatedByDdIdForUpdate(normalizedOrderId) != null) {
             throw business(PAYMENT_ATTEMPT_EXISTS, order.getStoreId());
         }
@@ -68,8 +82,9 @@ public class OmgPaymentCreateService {
             throw business(AUTH_REQUIRED);
         }
         String normalizedOrderId = normalizeOrderId(orderId);
+        if (multiStoreFacts != null) multiStoreFacts.lockBatchIfAny(normalizedOrderId);
         OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(normalizedOrderId);
-        validateOrder(userId, normalizedOrderId, order);
+        validateOrder(userId, normalizedOrderId, order, paymentMethod);
         OmgPaymentAttempt active = attempts.selectActiveCreatedByDdIdForUpdate(normalizedOrderId);
         if (active == null || active.getId() == null || expectedMerchantTradeNo == null
                 || !expectedMerchantTradeNo.equals(active.getMerchantTradeNo())) {
@@ -106,6 +121,11 @@ public class OmgPaymentCreateService {
                         && attempts.markProductionEnvironment(attempt.getId()) != 1) {
                     throw new IllegalStateException("OMG production environment snapshot failed");
                 }
+                if (multiStoreFacts != null && order.getDdId().startsWith("MS")
+                        && !order.getDdId().equals(order.getParentDdId())) {
+                    // OMG 表单发给 App 前记下服务端已发行的交易号,缓冲期不接受截止后才新建的表单。
+                    multiStoreFacts.recordDispatch(order.getId(), "OMG", order.getPayType(), merchantTradeNo);
+                }
                 OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
                 return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
                         order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));
@@ -139,13 +159,20 @@ public class OmgPaymentCreateService {
         }
     }
 
-    private static void validateOrder(Long userId, String orderId, OmgPaymentOrderSnapshot order) {
+    private void validateOrder(Long userId, String orderId, OmgPaymentOrderSnapshot order,
+                               OmgPaymentMethod paymentMethod) {
         if (order == null || !userId.equals(order.getUserId())) {
             throw business(ORDER_NOT_AVAILABLE);
         }
         Long storeId = order.getStoreId();
         if (order.getParentDdId() == null || !orderId.equals(order.getParentDdId())) {
-            throw business(MULTI_STORE_ORDER_NOT_SUPPORTED, storeId);
+            String payType = order.getPayType();
+            boolean chosenMethod = "2".equals(payType) && paymentMethod == OmgPaymentMethod.CREDIT
+                    || "5".equals(payType) && paymentMethod == OmgPaymentMethod.APPLE_PAY;
+            if (!chosenMethod || multiStoreFacts == null
+                    || !multiStoreFacts.authorizeCreate(orderId, userId, payType)) {
+                throw business(MULTI_STORE_ORDER_NOT_SUPPORTED, storeId);
+            }
         }
         if (storeId == null) {
             throw business(ORDER_NOT_AVAILABLE);

+ 22 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -3,6 +3,7 @@ package com.ruoyi.app.omgpay;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.order.MerchantNotificationRouter;
+import com.ruoyi.app.order.MultiStoreCheckoutOnlineFactService;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.push.RecipientApp;
@@ -66,6 +67,8 @@ public class OmgPaymentNotifyService {
     private PushEventService pushEventService;
     @Autowired(required = false)
     private BusinessPushService businessPushService;
+    @Autowired(required = false)
+    private MultiStoreCheckoutOnlineFactService multiStoreFacts;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -93,6 +96,7 @@ public class OmgPaymentNotifyService {
             log.warn("OMG notify rejected merchantTradeNo={} reason=prelock_fact_mismatch", merchantTradeNo);
             return false;
         }
+        boolean multiStore = multiStoreFacts != null && multiStoreFacts.lockBatchIfAny(discovered.getDdId());
         OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
         OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(merchantTradeNo);
         if (attempt == null || !verifyTrust(request, attempt)) {
@@ -113,7 +117,7 @@ public class OmgPaymentNotifyService {
                 facts.paymentType, facts.paymentDate, facts.tradeDate,
                 facts.paymentTypeChargeFee, facts.simulatePaid,
                 request.value("gwsr"));
-        return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
+        return applyVerifiedFacts(order, attempt, gatewayFacts, multiStore) != null;
     }
 
     /**
@@ -129,17 +133,19 @@ public class OmgPaymentNotifyService {
         if (discovered == null || !sameIdentity(facts, discovered)) {
             throw new IllegalArgumentException("OMG query facts do not identify an attempt");
         }
+        boolean multiStore = multiStoreFacts != null && multiStoreFacts.lockBatchIfAny(discovered.getDdId());
         OmgPaymentOrderSnapshot order = attempts.selectOrderForUpdate(discovered.getDdId());
         OmgPaymentAttempt attempt = attempts.selectByMerchantTradeNoForUpdate(facts.merchantTradeNo());
         if (attempt == null || !sameIdentity(facts, attempt)) {
             throw new IllegalArgumentException("OMG query attempt changed while locking");
         }
-        return applyVerifiedFacts(order, attempt, facts);
+        return applyVerifiedFacts(order, attempt, facts, multiStore);
     }
 
     private OmgPaymentSettlementResult applyVerifiedFacts(OmgPaymentOrderSnapshot order,
                                                           OmgPaymentAttempt attempt,
-                                                          OmgPaymentGatewayFacts facts) {
+                                                          OmgPaymentGatewayFacts facts,
+                                                          boolean multiStore) {
         boolean paid = facts.resultCode() == 1;
         if (!paid) {
             if (attempt.getAttemptStatus() != null && attempt.getAttemptStatus() == STATUS_PAID) {
@@ -176,14 +182,22 @@ public class OmgPaymentNotifyService {
         boolean canFulfill = Long.valueOf(0L).equals(order.getPayStatus())
                 && order.getState() != null && order.getState() != 4L
                 && ("2".equals(order.getPayType()) || "5".equals(order.getPayType()));
+        // 036 先持久化子单支付状态,再允许成员就绪和整批激活;失败的真实扣款只进入退款。
+        int orderMarked = canFulfill ? attempts.markOrderPaid(attempt.getDdId()) : 0;
+        String batchOutcome = multiStore ? multiStoreFacts.recordPaid(order.getId(), "OMG",
+                order.getPayType(), attempt.getMerchantTradeNo(), facts.tradeNo(), attempt.getAmount(),
+                orderMarked == 1) : "NOT_BATCH";
         if (canFulfill) {
-            if (attempts.markOrderPaid(attempt.getDdId()) == 1) {
-                openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
-                pushPaymentSuccessToUser(order);
-            } else if (refundService != null) {
+            if (orderMarked == 1) {
+                if (!multiStore && !"REFUND_REQUIRED".equals(batchOutcome)) {
+                    // 036 只能由整批出站事件统一开放,不能沿旧 OMG 路径逐单通知。
+                    openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
+                    pushPaymentSuccessToUser(order);
+                }
+            } else if (!multiStore && refundService != null) {
                 refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
             }
-        } else if (refundService != null) {
+        } else if (!multiStore && refundService != null) {
             refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());

+ 41 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java

@@ -1,5 +1,7 @@
 package com.ruoyi.app.order;
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.push.BusinessPushProperties;
 import com.ruoyi.app.push.NativeRiderBatchPushService;
@@ -100,6 +102,45 @@ public class DeliveryOrderNotificationService {
         });
     }
 
+    /** 036 只为一个配送组排队一次骑手新任务提醒,商家仍逐子单收到自己的新订单。 */
+    public void notifyMultiStoreGroupAvailable(Long groupId, Long eventId, PosOrder anchor,
+                                                List<PosOrder> activeOrders, boolean waiting) {
+        if (groupId == null || eventId == null || anchor == null || activeOrders == null
+                || activeOrders.isEmpty()) throw new IllegalArgumentException("Invalid multi-store notification");
+        for (PosOrder order : activeOrders) {
+            if (merchantNotificationRouter != null) {
+                merchantNotificationRouter.sendStoreNotification(order.getMdId(), order.getShId(),
+                        "no.message.push.message", "no.message.push.new.order",
+                        multiStoreBody(order, groupId, eventId), order.getDdId());
+            }
+        }
+        if (!waiting) return;
+        List<RiderPosition> riders = riderPositionMapper.getAcceptRiderList(
+                anchor.getLongitude(), anchor.getLatitude(), RIDER_NOTIFICATION_LIMIT);
+        if (riders == null) return;
+        String body = multiStoreBody(anchor, groupId, eventId);
+        String orderNo = anchor.getDdId();
+        for (RiderPosition rider : riders) {
+            if (rider == null || rider.getRiderId() == null) continue;
+            AsyncManager.me().execute(new TimerTask() {
+                @Override
+                public void run() {
+                    PayPush.qsPushHandleLocal(new PayPush(), pushEventService, rider.getRiderId(), rider.getCid(),
+                            "no.message.push.message", "no.message.push.new.food.order", body, "", orderNo);
+                }
+            });
+        }
+    }
+
+    private String multiStoreBody(PosOrder order, Long groupId, Long eventId) {
+        JSONObject body = JSON.parseObject(OrderPushBodyDto.getJson(order.getDdId(),
+                String.valueOf(order.getState()), 0));
+        // 通知投递可能重试,前端按 eventId 去重并始终刷新 groupId 详情,不用旧单笔接单入口。
+        body.put("groupId", groupId);
+        body.put("eventId", eventId);
+        return body.toJSONString();
+    }
+
     /** 外送订单取消后,通知除取消人外的相关方。 */
     public void notifyOrderCancelled(PosOrder order, Long operatorId) {
         notifyOrderCancelled(order, operatorId, false);

+ 15 - 24
ruoyi-admin/src/main/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteService.java

@@ -11,8 +11,6 @@ import java.util.Map;
 
 import org.springframework.stereotype.Service;
 
-import com.alibaba.fastjson.JSONArray;
-import com.alibaba.fastjson.JSONObject;
 import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
@@ -34,14 +32,15 @@ import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosFreightMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
-import com.ruoyi.system.utils.GetArea;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
 
 /**
  * 满额免运完整配送报价服务(008 T075,spec FR-033/FR-034)。
  *
  * 服务端核实门店、在售商品、规格归属与数量,并按用餐类型构造可信
  * PromotionDeliveryContext 调用算价三参重载(2026-10-19 追加 FR-037 报价入口统一):
- * 平台骑手外送按 Google 路线与运价跳表计算 R 并评估满额免运;商家自配送 R 照算但
+ * 平台骑手外送按 Google 矩阵路线与运价跳表计算 R 并评估满额免运;商家自配送 R 照算但
  * 免运不评估(F=0);自取/堂食免地址、R/F/D=0、A=G。客户端传入的运费、免运金额、
  * 商品单价一律不作为计费依据;外送路线/运价不可用时返回业务错误,不默认运费0。
  */
@@ -57,6 +56,7 @@ public class FoodDeliveryPromotionQuoteService
     private final PosFreightMapper freight;
     private final SelfDeliveryService selfDeliveryService;
     private final IPromotionCalcService promotions;
+    private final RouteDistanceMatrixProvider routeMatrix;
 
     public FoodDeliveryPromotionQuoteService(PosStoreMapper stores,
                                              InfoAddressMapper addresses,
@@ -66,7 +66,8 @@ public class FoodDeliveryPromotionQuoteService
                                              FoodSpecsValueMapper values,
                                              PosFreightMapper freight,
                                              SelfDeliveryService selfDelivery,
-                                             IPromotionCalcService promotions)
+                                             IPromotionCalcService promotions,
+                                             RouteDistanceMatrixProvider routeMatrix)
     {
         this.stores = stores;
         this.addresses = addresses;
@@ -77,6 +78,7 @@ public class FoodDeliveryPromotionQuoteService
         this.freight = freight;
         this.selfDeliveryService = selfDelivery;
         this.promotions = promotions;
+        this.routeMatrix = routeMatrix;
     }
 
     /**
@@ -249,33 +251,22 @@ public class FoodDeliveryPromotionQuoteService
     }
 
     /**
-     * 现行路线口径:服务端按门店/地址坐标调用 Google Directions 读取 routes[0].legs[0]
-     * 距离(米)。不接收客户端 quli 作为权威距离;失败返回业务错误。
-     * protected 便于单元测试以 spy 注入受控距离,不调用真实地图。
+     * 普通外送以1×1矩阵取得门店→用户的道路米数,与多店报价使用同一提供器。
+     * 客户端和运价公式不变;外部失败仍返回原有业务错误,不补0运费或回退直线。
      */
     protected BigDecimal routeDistanceMeters(PosStore store, InfoAddress address)
     {
         try
         {
-            String origin = store.getLatitude().doubleValue() + "," + store.getLongitude().doubleValue();
-            String destination = address.getLatitude().doubleValue() + "," + address.getLongitude().doubleValue();
-            JSONObject response = new GetArea().getluxian(origin, destination, "zh-TW");
-            JSONArray routes = response == null ? null : response.getJSONArray("routes");
-            if (routes == null || routes.isEmpty())
+            List<BigDecimal> distances = routeMatrix.distancesToDestination(
+                    List.of(new GeoPoint(store.getLatitude(), store.getLongitude())),
+                    new GeoPoint(address.getLatitude(), address.getLongitude()));
+            if (distances == null || distances.size() != 1 || distances.get(0) == null
+                    || distances.get(0).signum() <= 0 || distances.get(0).stripTrailingZeros().scale() > 0)
             {
                 throw invalid("promotion.freeDelivery.freightUnavailable");
             }
-            JSONArray legs = routes.getJSONObject(0).getJSONArray("legs");
-            if (legs == null || legs.isEmpty())
-            {
-                throw invalid("promotion.freeDelivery.freightUnavailable");
-            }
-            Integer meters = legs.getJSONObject(0).getJSONObject("distance").getInteger("value");
-            if (meters == null || meters <= 0)
-            {
-                throw invalid("promotion.freeDelivery.freightUnavailable");
-            }
-            return BigDecimal.valueOf(meters);
+            return distances.get(0);
         }
         catch (ServiceException known)
         {

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

@@ -0,0 +1,88 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.utils.DateUtil;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutOutboxEvent;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+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.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+/** 036 生效后的独立开放阶段;预约沿普通外卖原提前分钟口径,绝不把支付完成当作可抢。 */
+@Service
+public class MultiStoreCheckoutActivationService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Taipei");
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+
+    public MultiStoreCheckoutActivationService(OrderCheckoutMapper checkout, PosOrderMapper orders) {
+        this.checkout = checkout;
+        this.orders = orders;
+    }
+
+    public List<Long> pendingActivationEvents(LocalDateTime now, int limit) {
+        if (now == null || limit < 1 || limit > 500) throw new IllegalArgumentException("Invalid activation scan");
+        return checkout.selectPendingActivationEventIds(now, limit);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean openActivation(Long eventId, LocalDateTime now) {
+        if (eventId == null || now == null) return false;
+        OrderCheckoutOutboxEvent event = checkout.selectOutboxByIdForUpdate(eventId);
+        if (event == null || !"CHECKOUT_ACTIVATED".equals(event.getEventType())
+                || !"PENDING".equals(event.getStatus())) return false;
+        // 与取消/接组保持批次 -> 组 -> 子单锁顺序;事件行只由本消费者竞争。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(event.getAggregateId());
+        if (batch == null || "CANCELLED".equals(batch.getCheckoutStatus())) {
+            checkout.cancelActivationEvent(eventId, now);
+            return false;
+        }
+        if (!"ACTIVE".equals(batch.getCheckoutStatus()) || batch.getActivatedAt() == null) return false;
+        OrderDeliveryGroup group = checkout.selectGroupByBatchIdForUpdate(batch.getId());
+        if (group == null || !"WAITING".equals(group.getStatus()) || group.getRiderId() != null) return false;
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batch.getId());
+        if (members == null || members.size() < 2) throw new IllegalStateException("Invalid multi-store group members");
+        PosOrder first = orders.selectById(members.get(0).getPosOrderId());
+        if (first == null) throw new IllegalStateException("Missing multi-store anchor order");
+        Date appointmentStart = PosOrderNoRiderTimeoutService.appointmentStart(first.getDelryTime());
+        if (first.getDelryTime() != null && !first.getDelryTime().isBlank() && appointmentStart == null) {
+            throw new IllegalStateException("Invalid multi-store appointment window");
+        }
+        if (appointmentStart != null) {
+            int leadMinutes = DateUtil.getAppoinmentOrderHandleTime();
+            if (leadMinutes < 0 || leadMinutes > 1440) throw new IllegalStateException("Invalid appointment lead time");
+            Date openingCutoff = Date.from(now.atZone(BUSINESS_ZONE).toInstant()
+                    .plusSeconds((long) leadMinutes * 60));
+            if (appointmentStart.after(openingCutoff)) return false;
+        }
+        int active = 0;
+        for (OrderCheckoutMember member : members) {
+            PosOrder order = orders.selectById(member.getPosOrderId());
+            if (order == null) throw new IllegalStateException("Missing multi-store order");
+            if (Long.valueOf(4L).equals(order.getState())) continue;
+            if (checkout.openGroupMemberOrder(order.getId(), batch.getActivatedAt(), now) != 1) {
+                throw new IllegalStateException("Multi-store member cannot open");
+            }
+            active++;
+        }
+        if (active == 0) {
+            checkout.cancelWaitingGroup(batch.getId());
+            checkout.cancelActivationEvent(eventId, now);
+            return false;
+        }
+        if (checkout.openWaitingGroup(batch.getId(), now) != 1
+                || checkout.markActivationOpened(eventId, now) != 1) {
+            throw new IllegalStateException("Multi-store activation changed");
+        }
+        // OPENED 留给独立通知消费者;数据库开放与通知投递不得混为同一已完成状态。
+        return true;
+    }
+}

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActivationTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/** 036 按持久事件恢复预约开放;每组独立事务,失败留给下次扫描重试。 */
+@Component("multiStoreCheckoutActivationTask")
+public class MultiStoreCheckoutActivationTask {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutActivationTask.class);
+    private final MultiStoreCheckoutActivationService service;
+
+    public MultiStoreCheckoutActivationTask(MultiStoreCheckoutActivationService service) {
+        this.service = service;
+    }
+
+    public void openDueGroups() {
+        for (Long eventId : service.pendingActivationEvents(LocalDateTime.now(), 200)) {
+            try {
+                service.openActivation(eventId, LocalDateTime.now());
+            } catch (RuntimeException error) {
+                log.error("Open multi-store group failed, eventId={}", eventId, error);
+            }
+        }
+    }
+}

+ 90 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutActiveMemberCancellationService.java

@@ -0,0 +1,90 @@
+package com.ruoyi.app.order;
+
+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.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+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.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 036 生效后、骑手接组前的用户单店取消;费用快照不重算,其他店不补差。 */
+@Service
+public class MultiStoreCheckoutActiveMemberCancellationService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final DeliveryOrderNotificationService notifications;
+
+    public MultiStoreCheckoutActiveMemberCancellationService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                                             DeliveryOrderNotificationService notifications) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.notifications = notifications;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelByUser(Long batchId, Long orderId, Long userId, LocalDateTime now) {
+        if (batchId == null || orderId == null || userId == null || now == null) throw invalid();
+        // 与骑手接组、整组超时固定相同锁顺序;取消与接组不可能各自基于旧状态成功。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !userId.equals(batch.getUserId())) throw invalid();
+        if (!"ACTIVE".equals(batch.getCheckoutStatus())) return false;
+        OrderDeliveryGroup group = checkout.selectGroupByBatchIdForUpdate(batchId);
+        if (group == null || !"WAITING".equals(group.getStatus()) || group.getRiderId() != null) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.group.changed"));
+        }
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        if (members == null || members.size() < 2) throw invalid();
+        OrderCheckoutMember selected = members.stream().filter(m -> orderId.equals(m.getPosOrderId()))
+                .findFirst().orElseThrow(this::invalid);
+        PosOrder order = orders.selectById(orderId);
+        if (order == null || !userId.equals(order.getUserId())) throw invalid();
+        if (Long.valueOf(4L).equals(order.getState())) return false;
+        if (!Long.valueOf(0L).equals(order.getState()) || order.getQsId() != null
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || checkout.cancelActiveGroupMember(orderId) != 1) throw invalid();
+
+        if (selected.getCouponId() != null) {
+            checkout.returnCouponIfValid(selected.getCouponId(), orderId, now);
+        }
+        boolean anyRemaining = false;
+        for (OrderCheckoutMember member : members) {
+            if (orderId.equals(member.getPosOrderId())) continue;
+            PosOrder other = orders.selectById(member.getPosOrderId());
+            if (other == null) throw invalid();
+            if (!Long.valueOf(4L).equals(other.getState())) anyRemaining = true;
+        }
+        if (anyRemaining) {
+            // 使取消前的组卡版本失效;骑手须按最新有效成员重新读取并接组。
+            if (checkout.bumpWaitingGroupVersion(batchId, now) != 1) throw invalid();
+        } else {
+            if (checkout.cancelWaitingGroup(batchId) != 1
+                    || checkout.cancelActiveBatch(batchId, "USER_CANCEL", now) != 1
+                    || checkout.cancelParentOrder(batch.getParentDdId()) != 1) throw invalid();
+            checkout.cancelUnsentActivationByBatch(batchId, now);
+        }
+        for (OrderCheckoutPaymentAttempt paid : checkout.selectPaidAttemptsByBatchId(batchId)) {
+            if (!selected.getId().equals(paid.getMemberId())) continue;
+            if (selected.getAmount() == null || selected.getAmount() <= 0) throw invalid();
+            // 仅逐笔登记该子单真实实收的原路退款意图;到付没有网关交易,不造退款。
+            checkout.insertRefundIntent(selected.getId(), "USER_CANCEL", paid.getChannel(),
+                    paid.getMerchantOrderNo(), paid.getGatewayTransactionId(), selected.getAmount(), now);
+        }
+        order.setState(4L);
+        notifications.notifyOrderCancelled(order, userId);
+        return true;
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 25 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAdminBatchController.java

@@ -0,0 +1,25 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 平台只读批次详情,不沿用 App 的用户 token 查询权限。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout/admin/batches")
+public class MultiStoreCheckoutAdminBatchController {
+    private final MultiStoreCheckoutAdminQueryService queries;
+
+    public MultiStoreCheckoutAdminBatchController(MultiStoreCheckoutAdminQueryService queries) {
+        this.queries = queries;
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:list')")
+    @GetMapping("/{batchId}")
+    public AjaxResult detail(@PathVariable Long batchId) {
+        return AjaxResult.success(queries.batchDetail(batchId));
+    }
+}

+ 62 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryService.java

@@ -0,0 +1,62 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreCardView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.MultiStoreAdminOrderLink;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.MultiStoreCheckoutQueryMapper;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** 平台只读视图:子单仍逐行,批次事实按当前页批量装配。 */
+@Service
+public class MultiStoreCheckoutAdminQueryService {
+    private final OrderCheckoutMapper checkout;
+    private final MultiStoreCheckoutQueryMapper links;
+    private final MultiStoreCheckoutQueryService cards;
+
+    public MultiStoreCheckoutAdminQueryService(OrderCheckoutMapper checkout,
+                                               MultiStoreCheckoutQueryMapper links,
+                                               MultiStoreCheckoutQueryService cards) {
+        this.checkout = checkout;
+        this.links = links;
+        this.cards = cards;
+    }
+
+    @Transactional(readOnly = true)
+    public void enrichOrders(List<PosOrder> orders) {
+        if (orders == null || orders.isEmpty()) return;
+        // 036 单号前缀只用于跳过旧单和未执行迁移的环境;最终归属仍以成员表关联为准。
+        List<Long> candidateIds = orders.stream().filter(order -> order.getId() != null
+                        && order.getDdId() != null && order.getDdId().startsWith("MS"))
+                .map(PosOrder::getId).toList();
+        if (candidateIds.isEmpty()) return;
+        Map<Long, MultiStoreAdminOrderLink> indexed = links.selectAdminOrderLinks(candidateIds).stream()
+                .collect(Collectors.toMap(MultiStoreAdminOrderLink::getOrderId, Function.identity()));
+        for (PosOrder order : orders) order.setMultiStore(indexed.get(order.getId()));
+    }
+
+    @Transactional(readOnly = true)
+    public MultiStoreCardView batchDetail(Long batchId) {
+        if (batchId == null) throw invalid();
+        OrderCheckoutBatch batch = checkout.selectBatchById(batchId);
+        if (batch == null || batch.getUserId() == null) throw invalid();
+        // 复用同一批次成员/渠道退款事实装配,但不把用户可操作动作暴露给平台。
+        MultiStoreCardView result = cards.batchDetail(batchId, batch.getUserId());
+        result.setAllowedActions(List.of());
+        result.getMembers().forEach(member -> member.setAllowedActions(List.of()));
+        return result;
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 22 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutAppCapability.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+
+/** 036 专用接口的 App 能力标识;旧 App 不发送该请求头,因而不能操作组合卡。 */
+public final class MultiStoreCheckoutAppCapability {
+    public static final String HEADER = "X-Multi-Store-Capability";
+    public static final String BATCH_CARDS_V1 = "batch-cards-v1";
+
+    private MultiStoreCheckoutAppCapability() {
+    }
+
+    public static boolean supports(String capability) {
+        return BATCH_CARDS_V1.equals(capability);
+    }
+
+    public static AjaxResult upgradeRequired() {
+        return AjaxResult.error(MessageUtils.message("multi.store.checkout.upgrade.required"))
+                .put("errorCode", "UPGRADE_REQUIRED");
+    }
+}

+ 270 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java

@@ -0,0 +1,270 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.app.order.dto.MultiStoreBatchCreateRequest;
+import com.ruoyi.app.order.dto.MultiStoreBatchCreatedView;
+import com.ruoyi.app.order.dto.MultiStoreQuoteRequest;
+import com.ruoyi.app.order.dto.MultiStoreQuoteView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.dto.PromotionCalcRequest;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.mapper.MultiStoreCheckoutConfigMapper;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.OrderParentMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/** 036 专用建批入口;普通 `/createOrder` 不经过此服务,也不继承多店付款期限。 */
+@Service
+public class MultiStoreCheckoutBatchService {
+    private final MultiStoreCheckoutQuoteService quotes;
+    private final MultiStoreCheckoutConfigMapper config;
+    private final OrderCheckoutMapper checkout;
+    private final OrderParentMapper parents;
+    private final PosOrderMapper orders;
+    private final InfoAddressMapper addresses;
+    private final PosStoreMapper stores;
+    private final OrderPromotionHelper promotions;
+
+    public MultiStoreCheckoutBatchService(MultiStoreCheckoutQuoteService quotes,
+                                          MultiStoreCheckoutConfigMapper config, OrderCheckoutMapper checkout,
+                                          OrderParentMapper parents, PosOrderMapper orders,
+                                          InfoAddressMapper addresses, PosStoreMapper stores,
+                                          OrderPromotionHelper promotions) {
+        this.quotes = quotes;
+        this.config = config;
+        this.checkout = checkout;
+        this.parents = parents;
+        this.orders = orders;
+        this.addresses = addresses;
+        this.stores = stores;
+        this.promotions = promotions;
+    }
+
+    @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) {
+            throw invalid("multi.store.checkout.invalid");
+        }
+        String requestHash = hash(input.quoteToken() + "|" + JSON.toJSONString(input.participatingStoreIds()));
+        // 相同请求键的网络重试在报价过期后仍可取回原批次;不同载荷不能借键覆盖旧单。
+        OrderCheckoutBatch existing = checkout.selectByUserAndRequestKey(userId, input.requestKey());
+        if (existing != null) return existingView(existing, requestHash);
+
+        MultiStoreCheckoutQuoteService.QuoteSnapshot snapshot = quotes.loadSnapshot(input.quoteToken(), userId);
+        MultiStoreQuoteView quote = quotes.quote(snapshot.request(), userId);
+        if (!snapshot.fingerprint().equals(quote.fingerprint())
+                || !quote.participatingStoreIds().equals(input.participatingStoreIds())) {
+            throw invalid("multi.store.checkout.quote.expired");
+        }
+        // 与配置下调使用同一数据库行锁;锁后核对版本及成员上限,禁止旧报价穿过并发配置变更。
+        MultiStoreCheckoutConfig policy = MultiStoreCheckoutConfig.fromRow(config.selectCurrentForUpdate());
+        if (!policy.enabled() || policy.version() != quote.ruleVersion()
+                || quote.participatingStoreIds().size() > policy.maxStores()) {
+            throw invalid("multi.store.checkout.quote.expired");
+        }
+        InfoAddress address = addresses.selectById(snapshot.request().addressId());
+        if (address == null || !userId.equals(address.getUserId())
+                || address.getLatitude() == null || address.getLongitude() == null) {
+            throw invalid("multi.store.checkout.address.invalid");
+        }
+        String frozenAddress = JSON.toJSONString(address);
+        LocalDateTime now = LocalDateTime.now();
+        String parentNo = "MS" + UUID.randomUUID().toString().replace("-", "").toUpperCase();
+        int total = quote.stores().stream().mapToInt(price -> integer(price.amount())).sum();
+        int original = quote.stores().stream().mapToInt(price ->
+                integer(price.originalFoodAmount().add(price.originalFreight()))).sum();
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setParentDdId(parentNo);
+        batch.setUserId(userId);
+        batch.setRequestKey(input.requestKey());
+        batch.setRequestHash(requestHash);
+        batch.setCheckoutStatus("PENDING");
+        batch.setAnchorStoreId(quote.anchorStoreId());
+        batch.setAddressId(address.getId());
+        batch.setSameRider(1);
+        batch.setFenceMeters(policy.fenceMeters());
+        batch.setMaxStoreCount(policy.maxStores());
+        batch.setRuleVersion(String.valueOf(policy.version()));
+        batch.setMatchedMinStores(policy.tiers().keySet().stream()
+                .filter(min -> min <= quote.stores().size()).max(Integer::compareTo).orElse(null));
+        batch.setDiscountRatio(quote.freightRatio());
+        batch.setTotalAmount(total);
+        batch.setConfirmationBufferMinutes(policy.confirmationBufferMinutes());
+        batch.setOperationDeadlineAt(now.plusMinutes(15));
+        batch.setFinalDeadlineAt(batch.getOperationDeadlineAt().plusMinutes(policy.confirmationBufferMinutes()));
+        batch.setCreateTime(now);
+        batch.setUpdateTime(now);
+        checkout.insertOrGetBatch(batch);
+        // 唯一键冲突会回填既有 ID;在事务内只读原批次,绝不重复写父子单。
+        OrderCheckoutBatch stored = checkout.selectBatchById(batch.getId());
+        if (stored != null && !parentNo.equals(stored.getParentDdId())) {
+            return existingView(stored, requestHash);
+        }
+
+        OrderParent parent = new OrderParent();
+        parent.setUserId(userId);
+        parent.setOrderStatus(0L);
+        parent.setTotalAmount((long) original);
+        parent.setTotalDiscount((long) original - total);
+        parent.setActualPayAmount((long) total);
+        parent.setDdId(parentNo);
+        parent.setShdzId(address.getId());
+        parent.setShAddress(frozenAddress);
+        parent.setType(0L);
+        parent.setCreateTime(new Date());
+        if (parents.insert(parent) != 1) throw invalid("multi.store.checkout.invalid");
+
+        List<MultiStoreBatchCreatedView.Member> created = new ArrayList<>();
+        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++) {
+            MultiStoreQuoteView.StorePrice price = quote.stores().get(index);
+            MultiStoreQuoteRequest.Store choice = selected.get(price.storeId());
+            PosStore store = stores.selectById(price.storeId());
+            if (choice == null || store == null || store.getUserId() == null) {
+                throw invalid("multi.store.checkout.store.invalid");
+            }
+            PromotionCalcResponse food = quotes.priceFoodForBatch(choice, userId, price.discountedFreight());
+            if (food == null || food.getOriginalAmount() == null || food.getFinalAmount() == null
+                    || food.getOriginalAmount().compareTo(price.originalFoodAmount()) != 0
+                    || food.getFinalAmount().compareTo(price.foodAmount()) != 0
+                    || food.getDeliveryFee() == null || food.getDeliveryFee().compareTo(price.discountedFreight()) != 0
+                    || food.getCustomerDeliveryFee() == null
+                    || food.getCustomerDeliveryFee().compareTo(price.customerDeliveryFee()) != 0
+                    || food.getDeliveryDiscountAmount() == null
+                    || food.getDeliveryDiscountAmount().compareTo(price.merchantDeliverySubsidy()) != 0
+                    || food.getPayableAmount() == null || food.getPayableAmount().compareTo(price.amount()) != 0) {
+                throw invalid("multi.store.checkout.quote.expired");
+            }
+            List<PromotionCalcRequest.CartItem> cart = new ArrayList<>();
+            for (MultiStoreQuoteRequest.Item item : choice.items()) {
+                PromotionCalcRequest.CartItem line = new PromotionCalcRequest.CartItem();
+                line.setProductId(item.productId());
+                line.setQuantity(item.quantity());
+                line.setSpecValueIds(item.specValueIds());
+                cart.add(line);
+            }
+            String orderNo = parentNo + String.format("%03d", index + 1);
+            PosOrder order = new PosOrder();
+            order.setDdId(orderNo);
+            order.setParentDdId(parentNo);
+            order.setUserId(userId);
+            order.setShId(store.getUserId());
+            order.setMdId(price.storeId());
+            order.setType(0L);
+            order.setOrderSource("USER");
+            order.setState(0L);
+            order.setDeliveryStatus(0L);
+            order.setPayStatus(0L);
+            order.setAfterSaleStatus(0L);
+            order.setIsDisplay(false);
+            order.setSelfDelivery(0);
+            order.setShdzId(address.getId());
+            order.setShAddress(frozenAddress);
+            order.setLongitude(store.getLongitude());
+            order.setLatitude(store.getLatitude());
+            order.setDelryTime(choice.deliveryWindow());
+            // 008 完整配送报价固化骑手毛运费、客户运费与商家补贴;普通订单入口不受此处影响。
+            promotions.applyVersion2Pricing(order, food);
+            order.setFood(promotions.buildServerFoodJson(cart));
+            order.setPosName(store.getPosName());
+            order.setLogo(store.getLogo());
+            order.setOrderCategory(String.valueOf(store.getType()));
+            order.setCretim(new Date());
+            promotions.fillPromotionFields(order, food);
+            if (orders.insert(order) != 1 || order.getId() == null) {
+                throw invalid("multi.store.checkout.invalid");
+            }
+            promotions.savePromotionSnapshots(order.getId(), food);
+            Long coupon = promotions.resolveCouponToConsume(food, true);
+            if (coupon != null) promotions.markCouponUsed(coupon, order.getId(), userId);
+
+            OrderCheckoutMember member = new OrderCheckoutMember();
+            member.setBatchId(batch.getId());
+            member.setPosOrderId(order.getId());
+            member.setStoreId(price.storeId());
+            member.setSortNo(index + 1);
+            member.setPaymentState("UNSELECTED");
+            member.setPaidAmount(0);
+            member.setOriginalFreight(integer(price.originalFreight()));
+            member.setDiscountedFreight(integer(price.discountedFreight()));
+            member.setFoodAmount(integer(price.foodAmount()));
+            member.setAmount(integer(price.amount()));
+            member.setCouponId(coupon);
+            member.setCreateTime(now);
+            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()));
+        }
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setBatchId(batch.getId());
+        group.setStatus("WAITING");
+        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,
+                batch.getOperationDeadlineAt(), batch.getFinalDeadlineAt(), created);
+    }
+
+    private MultiStoreBatchCreatedView existingView(OrderCheckoutBatch batch, String requestHash) {
+        if (!requestHash.equals(batch.getRequestHash())) throw invalid("multi.store.checkout.invalid");
+        List<MultiStoreBatchCreatedView.Member> members = checkout.selectMembersByBatchId(batch.getId()).stream()
+                .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());
+                }).toList();
+        return new MultiStoreBatchCreatedView(batch.getId(), batch.getParentDdId(), batch.getCheckoutStatus(),
+                batch.getTotalAmount(), batch.getOperationDeadlineAt(), batch.getFinalDeadlineAt(), members);
+    }
+
+    private int integer(BigDecimal amount) {
+        if (amount == null || amount.signum() < 0) throw invalid("multi.store.checkout.invalid");
+        try {
+            return amount.intValueExact();
+        } catch (ArithmeticException exception) {
+            throw invalid("multi.store.checkout.invalid");
+        }
+    }
+
+    private String hash(String value) {
+        try {
+            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+                    .digest(value.getBytes(StandardCharsets.UTF_8)));
+        } catch (NoSuchAlgorithmException exception) {
+            throw new IllegalStateException("SHA-256 unavailable", exception);
+        }
+    }
+
+    private ServiceException invalid(String key) {
+        return new ServiceException(MessageUtils.message(key));
+    }
+}

+ 96 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCancellationService.java

@@ -0,0 +1,96 @@
+package com.ruoyi.app.order;
+
+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.OrderCheckoutPaymentAttempt;
+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.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** 036 未生效批次整批取消;退款先落持久意图,不在行锁内调用外部支付渠道。 */
+@Service
+public class MultiStoreCheckoutCancellationService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+
+    public MultiStoreCheckoutCancellationService(OrderCheckoutMapper checkout, PosOrderMapper orders) {
+        this.checkout = checkout;
+        this.orders = orders;
+    }
+
+    public List<Long> expiredCandidates(LocalDateTime now, int limit) {
+        if (now == null || limit < 1 || limit > 500) throw invalid();
+        return checkout.selectExpiredPendingBatchIds(now, limit);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelExpired(Long batchId, LocalDateTime now) {
+        if (batchId == null || now == null) throw invalid();
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !"PENDING".equals(batch.getCheckoutStatus())) return false;
+        boolean paymentTimeout = batch.getFinalDeadlineAt() != null
+                && !now.isBefore(batch.getFinalDeadlineAt());
+        boolean appointmentExpired = false;
+        if (!paymentTimeout) {
+            List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+            if (members != null && !members.isEmpty()) {
+                PosOrder first = orders.selectById(members.get(0).getPosOrderId());
+                appointmentExpired = first != null
+                        && MultiStoreCheckoutRules.appointmentExpired(first.getDelryTime(), now);
+            }
+        }
+        if (!paymentTimeout && !appointmentExpired) return false;
+        cancelLocked(batch, paymentTimeout ? "PAYMENT_TIMEOUT" : "APPOINTMENT_EXPIRED", now);
+        return true;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelByUser(Long batchId, Long userId, LocalDateTime now) {
+        if (batchId == null || userId == null || now == null) throw invalid();
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !userId.equals(batch.getUserId())) throw invalid();
+        if ("CANCELLED".equals(batch.getCheckoutStatus())) return false;
+        if (!"PENDING".equals(batch.getCheckoutStatus())) throw invalid();
+        cancelLocked(batch, "USER_CANCEL", now);
+        return true;
+    }
+
+    private void cancelLocked(OrderCheckoutBatch batch, String reason, LocalDateTime now) {
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batch.getId());
+        if (members == null || members.size() < 2) throw invalid();
+        Map<Long, OrderCheckoutMember> byId = members.stream()
+                .collect(Collectors.toMap(OrderCheckoutMember::getId, Function.identity()));
+        // 与支付回调共用批次 FOR UPDATE 锁;本事务任一步失败会撤销整批取消及退款意图。
+        if (checkout.cancelPendingBatch(batch.getId(), reason, now) != 1) throw invalid();
+        for (OrderCheckoutMember member : members) {
+            if (checkout.cancelOrderMember(member.getPosOrderId()) != 1) throw invalid();
+            // 建批时已核销的原券仅在原有效期内返还;CAS 绑定订单,重复取消不会多返。
+            if (member.getCouponId() != null) {
+                checkout.returnCouponIfValid(member.getCouponId(), member.getPosOrderId(), now);
+            }
+        }
+        if (checkout.cancelParentOrder(batch.getParentDdId()) != 1
+                || checkout.cancelWaitingGroup(batch.getId()) != 1) throw invalid();
+        for (OrderCheckoutPaymentAttempt paid : checkout.selectPaidAttemptsByBatchId(batch.getId())) {
+            OrderCheckoutMember member = byId.get(paid.getMemberId());
+            if (member == null || member.getAmount() == null || member.getAmount() <= 0) throw invalid();
+            // 一笔交易只可有一个退款意图;迟到实收也由回调沿同一唯一键补记,不重复退款。
+            checkout.insertRefundIntent(member.getId(), reason, paid.getChannel(),
+                    paid.getMerchantOrderNo(), paid.getGatewayTransactionId(), member.getAmount(), now);
+        }
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCancellationTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/** 由若依 sys_job 周期调用;每批独立事务使单批异常不阻断后续超时恢复。 */
+@Component("multiStoreCheckoutCancellationTask")
+public class MultiStoreCheckoutCancellationTask {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutCancellationTask.class);
+    private final MultiStoreCheckoutCancellationService service;
+
+    public MultiStoreCheckoutCancellationTask(MultiStoreCheckoutCancellationService service) {
+        this.service = service;
+    }
+
+    public void cancelExpiredBatches() {
+        for (Long batchId : service.expiredCandidates(LocalDateTime.now(), 200)) {
+            try {
+                service.cancelExpired(batchId, LocalDateTime.now());
+            } catch (RuntimeException error) {
+                log.error("Cancel expired multi-store checkout failed, batchId={}", batchId, error);
+            }
+        }
+    }
+}

+ 93 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutCodService.java

@@ -0,0 +1,93 @@
+package com.ruoyi.app.order;
+
+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.PosStore;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 到付是用户逐店确认付款方式,不是网关实收;普通到付单仍沿旧入口。 */
+@Service
+public class MultiStoreCheckoutCodService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final PosStoreMapper stores;
+    private final PaymentMethodGateService payments;
+    private final MultiStoreCheckoutCancellationService cancellation;
+
+    public MultiStoreCheckoutCodService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                        PosStoreMapper stores, PaymentMethodGateService payments,
+                                        MultiStoreCheckoutCancellationService cancellation) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.stores = stores;
+        this.payments = payments;
+        this.cancellation = cancellation;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public String confirm(Long batchId, Long orderId, Long userId) {
+        if (batchId == null || orderId == null || userId == null) throw invalid();
+        // 所有逐店付款事实先锁父批次;激活、取消和回调也必须共用这把数据库行锁。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !userId.equals(batch.getUserId())
+                || !("PENDING".equals(batch.getCheckoutStatus()) || "ACTIVE".equals(batch.getCheckoutStatus()))) {
+            throw invalid();
+        }
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        if (members == null || members.size() < 2) throw invalid();
+        OrderCheckoutMember selected = members.stream()
+                .filter(member -> orderId.equals(member.getPosOrderId())).findFirst().orElseThrow(this::invalid);
+        if ("COD_CONFIRMED".equals(selected.getPaymentState())) return batch.getCheckoutStatus();
+        LocalDateTime now = LocalDateTime.now();
+        if (!"PENDING".equals(batch.getCheckoutStatus())
+                || !MultiStoreCheckoutRules.canStartPayment(now, batch.getOperationDeadlineAt())
+                || !"UNSELECTED".equals(selected.getPaymentState())) {
+            // 未决线上交易不得直接切到到付并误称已就绪。
+            throw invalid();
+        }
+        PosStore store = stores.selectById(selected.getStoreId());
+        PosOrder order = orders.selectById(orderId);
+        if (store == null || store.getUserId() == null || order == null
+                || !userId.equals(order.getUserId()) || !selected.getStoreId().equals(order.getMdId())) {
+            throw invalid();
+        }
+        payments.assertUsableForOrder("1", PaymentMethodGateService.GateScope.MERCHANT,
+                selected.getStoreId(), store.getUserId(), 0L);
+        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())
+                || "COD_CONFIRMED".equals(member.getPaymentState()));
+        if (!allReady) return "PENDING";
+        PosOrder first = orders.selectById(members.get(0).getPosOrderId());
+        List<String> states = members.stream().map(member -> member == selected
+                ? "COD_CONFIRMED" : member.getPaymentState()).toList();
+        if (first != null && MultiStoreCheckoutRules.appointmentExpired(first.getDelryTime(), now)) {
+            // 预约结束时立即关闭整个未生效批次,不等待分钟扫描;只退已真实实收的线上交易。
+            if (!cancellation.cancelExpired(batchId, now)) throw invalid();
+            return "CANCELLED";
+        }
+        if (first == null || !MultiStoreCheckoutRules.canActivate(states, first.getDelryTime(), now)) {
+            throw invalid();
+        }
+        if (checkout.activateIfPending(batchId, now) != 1) throw invalid();
+        checkout.insertActivationEvent(batchId, now);
+        // 出站事件后续按实际预约开放时点处理;这里不直接通知骑手或商家。
+        return "ACTIVE";
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 6 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutConfigService.java

@@ -39,7 +39,7 @@ public class MultiStoreCheckoutConfigService {
         validate(requested, adminId);
         MultiStoreCheckoutConfigRow current;
         try {
-            current = mapper.selectCurrent();
+            current = mapper.selectCurrentForUpdate();
         } catch (RuntimeException exception) {
             // 尚未执行 036 手工迁移时给平台返回可解释的配置冲突,不允许静默创建启用行。
             throw new IllegalStateException("Multi-store checkout config table is unavailable", exception);
@@ -48,6 +48,11 @@ public class MultiStoreCheckoutConfigService {
                 || current.getVersion() != requested.version()) {
             throw new IllegalStateException("Multi-store checkout config is missing or changed");
         }
+        // 锁住配置行后再检查未接组;建批也必须锁同一行,才能与下调上限串行化。
+        if (requested.maxStores() < current.getMaxStores()
+                && mapper.hasUnacceptedGroupOverLimit(requested.maxStores())) {
+            throw new IllegalStateException("Unaccepted multi-store group exceeds requested store limit");
+        }
         MultiStoreCheckoutConfigRow update = new MultiStoreCheckoutConfigRow();
         update.setId(1);
         update.setEnabled(requested.enabled() ? 1 : 0);

+ 112 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutController.java

@@ -0,0 +1,112 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreGroupAcceptRequest;
+import com.ruoyi.app.order.dto.MultiStoreBatchCreateRequest;
+import com.ruoyi.app.order.dto.MultiStoreBatchCancelRequest;
+import com.ruoyi.app.order.dto.MultiStoreMemberCancelRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+
+/** 036 新多店专用操作入口;普通外卖继续使用原订单接口。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout")
+public class MultiStoreCheckoutController extends BaseController {
+    private final MultiStoreDeliveryGroupAcceptanceService groupAcceptance;
+    private final MultiStoreCheckoutBatchService batches;
+    private final MultiStoreCheckoutCancellationService cancellation;
+    private final MultiStoreCheckoutActiveMemberCancellationService memberCancellation;
+
+    public MultiStoreCheckoutController(MultiStoreDeliveryGroupAcceptanceService groupAcceptance,
+                                        MultiStoreCheckoutBatchService batches,
+                                        MultiStoreCheckoutCancellationService cancellation,
+                                        MultiStoreCheckoutActiveMemberCancellationService memberCancellation) {
+        this.groupAcceptance = groupAcceptance;
+        this.batches = batches;
+        this.cancellation = cancellation;
+        this.memberCancellation = memberCancellation;
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/batches")
+    public AjaxResult createBatch(@RequestHeader String token,
+                                  @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false) String capability,
+                                  @RequestBody MultiStoreBatchCreateRequest input) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
+        // 批次服务重新报价并校验归属、配置版本与幂等键,客户端提交的金额一律不采信。
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(batches.create(input, userId));
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/batches/cancel")
+    public AjaxResult cancelBatch(@RequestHeader String token,
+                                  @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false) String capability,
+                                  @RequestBody MultiStoreBatchCancelRequest input) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
+        if (input == null || input.batchId() == null || !"USER_CANCEL".equals(input.reason())
+                || input.clientRequestId() == null || input.clientRequestId().isBlank()
+                || input.clientRequestId().length() > 64) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+        }
+        // 取消服务锁批次、按 token 用户核归属;返回成功不表示各渠道退款已到账。
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(cancellation.cancelByUser(input.batchId(), userId, LocalDateTime.now()));
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/batches/members/cancel")
+    public AjaxResult cancelMember(@RequestHeader String token,
+                                   @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false) String capability,
+                                   @RequestBody MultiStoreMemberCancelRequest input) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
+        if (input == null || input.batchId() == null || input.orderId() == null
+                || !"USER_CANCEL".equals(input.reason()) || input.clientRequestId() == null
+                || input.clientRequestId().isBlank() || input.clientRequestId().length() > 64) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+        }
+        // 骑手已接组由服务层在批次/组行锁内拒绝,不以客户端列表状态推断取消权限。
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(memberCancellation.cancelByUser(input.batchId(), input.orderId(),
+                userId, LocalDateTime.now()));
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/groups/accept")
+    public AjaxResult acceptGroup(@RequestHeader String token,
+                                  @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false) String capability,
+                                  @RequestBody MultiStoreGroupAcceptRequest input) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
+        if (input == null || input.groupId() == null || input.version() == null
+                || input.clientRequestId() == null || input.clientRequestId().isBlank()
+                || input.clientRequestId().length() > 64) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+        }
+        // 骑手 ID 仅从已认证 token 取得;服务层再在同一骑手锁内复核位置、资格和全组状态。
+        Long riderId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(groupAcceptance.accept(input.groupId(), input.version(), riderId));
+    }
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutGroupCompletionService.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+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.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 036 组终态恢复:逐子单送达后,仅最后一个终态子单使整组和父单完成。 */
+@Service
+public class MultiStoreCheckoutGroupCompletionService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+
+    public MultiStoreCheckoutGroupCompletionService(OrderCheckoutMapper checkout, PosOrderMapper orders) {
+        this.checkout = checkout;
+        this.orders = orders;
+    }
+
+    public List<Long> terminalCandidates(int limit) {
+        if (limit < 1 || limit > 500) throw new IllegalArgumentException("Invalid group scan limit");
+        return checkout.selectTerminalAcceptedGroupBatchIds(limit);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean finalizeIfTerminal(Long batchId, LocalDateTime now) {
+        if (batchId == null || now == null) return false;
+        // 与接组、商家取消固定批次 -> 组的锁顺序;候选扫描只是提示,锁内必须重查每个子单。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !"ACTIVE".equals(batch.getCheckoutStatus())) return false;
+        OrderDeliveryGroup group = checkout.selectGroupByBatchIdForUpdate(batchId);
+        if (group == null || !"ACCEPTED".equals(group.getStatus()) || group.getRiderId() == null) return false;
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        if (members == null || members.size() < 2) throw new IllegalStateException("Invalid group members");
+        boolean delivered = false;
+        for (OrderCheckoutMember member : members) {
+            PosOrder order = orders.selectById(member.getPosOrderId());
+            if (order == null) throw new IllegalStateException("Missing group order");
+            if (Long.valueOf(3L).equals(order.getState())) delivered = true;
+            else if (!Long.valueOf(4L).equals(order.getState())) return false;
+        }
+        if (delivered) {
+            if (checkout.finishAcceptedGroup(batchId, group.getRiderId(), "FINISHED", now) != 1
+                    || checkout.completeParentOrder(batch.getParentDdId()) != 1) {
+                throw new IllegalStateException("Group completion changed");
+            }
+        } else if (checkout.finishAcceptedGroup(batchId, group.getRiderId(), "CANCELLED", now) != 1
+                || checkout.cancelActiveBatch(batchId, "ALL_CHILDREN_CANCELLED", now) != 1
+                || checkout.cancelParentOrder(batch.getParentDdId()) != 1) {
+            throw new IllegalStateException("Group cancellation changed");
+        }
+        return true;
+    }
+}

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutGroupCompletionTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/** 036 送达接口保留普通子单流程;独立扫描在提交后结清整组,失败可重试。 */
+@Component("multiStoreCheckoutGroupCompletionTask")
+public class MultiStoreCheckoutGroupCompletionTask {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutGroupCompletionTask.class);
+    private final MultiStoreCheckoutGroupCompletionService service;
+
+    public MultiStoreCheckoutGroupCompletionTask(MultiStoreCheckoutGroupCompletionService service) {
+        this.service = service;
+    }
+
+    public void finalizeTerminalGroups() {
+        for (Long batchId : service.terminalCandidates(200)) {
+            try {
+                service.finalizeIfTerminal(batchId, LocalDateTime.now());
+            } catch (RuntimeException error) {
+                log.error("Finalize multi-store group failed, batchId={}", batchId, error);
+            }
+        }
+    }
+}

+ 31 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuard.java

@@ -0,0 +1,31 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import org.springframework.stereotype.Service;
+
+/** 036 旧单笔入口防绕过;普通订单不访问尚未部署的多店表。 */
+@Service
+public class MultiStoreCheckoutLegacyGuard {
+    private final OrderCheckoutMapper checkout;
+
+    public MultiStoreCheckoutLegacyGuard(OrderCheckoutMapper checkout) {
+        this.checkout = checkout;
+    }
+
+    public void assertSingleCancelAllowed(PosOrder order) {
+        if (order != null && order.getId() != null && order.getDdId() != null
+                && order.getDdId().startsWith("MS")
+                && checkout.selectBatchIdByOrderId(order.getId()) != null) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.batch.cancel.required"));
+        }
+    }
+
+    /** 旧父单详情没有用户过滤;真实 036 批次只能从带归属校验的新详情入口读取。 */
+    public boolean isBatchParent(String parentDdId) {
+        return parentDdId != null && parentDdId.startsWith("MS")
+                && checkout.selectBatchIdByParentDdId(parentDdId) != null;
+    }
+}

+ 99 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantCancellationService.java

@@ -0,0 +1,99 @@
+package com.ruoyi.app.order;
+
+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.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.service.MerchantAccessContext;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 036 商家无法供餐:仅骑手已接但本店尚未取餐时可取消本子单。 */
+@Service
+public class MultiStoreCheckoutMerchantCancellationService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final DeliveryOrderNotificationService notifications;
+
+    public MultiStoreCheckoutMerchantCancellationService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                                         DeliveryOrderNotificationService notifications) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.notifications = notifications;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelUnavailable(Long batchId, Long orderId, MerchantAccessContext merchant, LocalDateTime now) {
+        if (batchId == null || orderId == null || merchant == null || merchant.loginUserId() == null || now == null) {
+            throw invalid();
+        }
+        // 与接组、用户取消按批次 -> 组 -> 子单的锁序列化;取餐使用子单 CAS,先取餐则本取消失败。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !"ACTIVE".equals(batch.getCheckoutStatus())) throw invalid();
+        OrderDeliveryGroup group = checkout.selectGroupByBatchIdForUpdate(batchId);
+        if (group == null || !"ACCEPTED".equals(group.getStatus()) || group.getRiderId() == null) throw changed();
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        if (members == null || members.size() < 2) throw invalid();
+        OrderCheckoutMember selected = members.stream().filter(m -> orderId.equals(m.getPosOrderId()))
+                .findFirst().orElseThrow(this::invalid);
+        PosOrder order = orders.selectById(orderId);
+        // 门店授权来自已认证商家上下文,锁内再次核对目标子单门店,不能只信 Controller 预查。
+        if (order == null || order.getMdId() == null || !merchant.storeIds().contains(order.getMdId())) {
+            throw invalid();
+        }
+        if (Long.valueOf(4L).equals(order.getState())) return false;
+        if (!group.getRiderId().equals(order.getQsId())
+                || !Long.valueOf(1L).equals(order.getDeliveryStatus())
+                || !(Long.valueOf(0L).equals(order.getState()) || Long.valueOf(1L).equals(order.getState()))
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || checkout.cancelAcceptedGroupMember(orderId, group.getRiderId()) != 1) throw changed();
+        // 最后一个有效子单被取消时结清组状态;已有送达的组仍算完成,不能误判整批退款。
+        boolean active = false;
+        boolean delivered = false;
+        for (OrderCheckoutMember member : members) {
+            if (orderId.equals(member.getPosOrderId())) continue;
+            PosOrder sibling = orders.selectById(member.getPosOrderId());
+            if (sibling == null) throw changed();
+            if (Long.valueOf(3L).equals(sibling.getState())) delivered = true;
+            else if (!Long.valueOf(4L).equals(sibling.getState())) active = true;
+        }
+        if (active) {
+            if (checkout.bumpAcceptedGroupVersion(batchId, group.getRiderId(), now) != 1) throw changed();
+        } else if (delivered) {
+            if (checkout.finishAcceptedGroup(batchId, group.getRiderId(), "FINISHED", now) != 1
+                    || checkout.completeParentOrder(batch.getParentDdId()) != 1) throw changed();
+        } else {
+            if (checkout.finishAcceptedGroup(batchId, group.getRiderId(), "CANCELLED", now) != 1
+                    || checkout.cancelActiveBatch(batchId, "MERCHANT_UNAVAILABLE", now) != 1
+                    || checkout.cancelParentOrder(batch.getParentDdId()) != 1) throw changed();
+        }
+        if (selected.getCouponId() != null) checkout.returnCouponIfValid(selected.getCouponId(), orderId, now);
+        for (OrderCheckoutPaymentAttempt paid : checkout.selectPaidAttemptsByBatchId(batchId)) {
+            if (!selected.getId().equals(paid.getMemberId())) continue;
+            if (selected.getAmount() == null || selected.getAmount() <= 0) throw invalid();
+            // 到付尚未发生用户网关实收;只有已付款的原交易产生退款意图。
+            checkout.insertRefundIntent(selected.getId(), "MERCHANT_UNAVAILABLE", paid.getChannel(),
+                    paid.getMerchantOrderNo(), paid.getGatewayTransactionId(), selected.getAmount(), now);
+        }
+        order.setState(4L);
+        notifications.notifyOrderCancelled(order, merchant.loginUserId());
+        return true;
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+
+    private ServiceException changed() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.group.changed"));
+    }
+}

+ 77 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantController.java

@@ -0,0 +1,77 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreMerchantCancelRequest;
+import com.ruoyi.app.pay.LinePayOrderGuard;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.MerchantAccessContext;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+
+/** 036 商家专用异常入口;普通商家取消仍沿原接口与权限规则。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout/merchant")
+public class MultiStoreCheckoutMerchantController extends BaseController {
+    private final IInfoUserService users;
+    private final MerchantStoreAccessService access;
+    private final PosOrderMapper orders;
+    private final LinePayOrderGuard legacyOwnership;
+    private final MultiStoreCheckoutMerchantCancellationService cancellation;
+
+    public MultiStoreCheckoutMerchantController(IInfoUserService users, MerchantStoreAccessService access,
+                                                PosOrderMapper orders, LinePayOrderGuard legacyOwnership,
+                                                MultiStoreCheckoutMerchantCancellationService cancellation) {
+        this.users = users;
+        this.access = access;
+        this.orders = orders;
+        this.legacyOwnership = legacyOwnership;
+        this.cancellation = cancellation;
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/members/cancelUnavailable")
+    public AjaxResult cancelUnavailable(@RequestHeader String token,
+                                        @RequestBody MultiStoreMerchantCancelRequest input) {
+        if (input == null || input.batchId() == null || input.orderId() == null
+                || !"MERCHANT_UNAVAILABLE".equals(input.reason()) || input.clientRequestId() == null
+                || input.clientRequestId().isBlank() || input.clientRequestId().length() > 64) {
+            throw invalid();
+        }
+        Long merchantId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoUser merchant = users.getById(merchantId);
+        PosOrder order = orders.selectById(input.orderId());
+        if (merchant == null || order == null || order.getMdId() == null) throw invalid();
+        MerchantAccessContext context;
+        if ("3".equals(merchant.getUserType())) {
+            // 夜市主仅能处理其自己归属的门店;若当前门店为自配送,则本多店批次本就不准入。
+            legacyOwnership.requireMerchantOwnership(order, merchant);
+            context = new MerchantAccessContext(merchantId, merchantId, true, Set.of(order.getMdId()));
+        } else {
+            context = access.resolve(merchantId);
+        }
+        // 预查仅用于账号权限;服务层在批次/组锁内再核门店归属及未取餐事实。
+        return AjaxResult.success(cancellation.cancelUnavailable(input.batchId(), input.orderId(),
+                context, LocalDateTime.now()));
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 105 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNoRiderTimeoutService.java

@@ -0,0 +1,105 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+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.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** 036 接入 037 原无人接单阈值:按组超时,接组与取消共用批次/组行锁。 */
+@Service
+public class MultiStoreCheckoutNoRiderTimeoutService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Taipei");
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final DeliveryOrderNotificationService notifications;
+
+    public MultiStoreCheckoutNoRiderTimeoutService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                                   DeliveryOrderNotificationService notifications) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.notifications = notifications;
+    }
+
+    public List<Long> expiredCandidates(LocalDateTime now, int minutes, int limit) {
+        if (now == null || minutes < 1 || minutes > 1440 || limit < 1 || limit > 500) {
+            throw new IllegalArgumentException("Invalid no-rider scan");
+        }
+        return checkout.selectExpiredWaitingGroupBatchIds(now.minusMinutes(minutes), limit);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelExpired(Long batchId, LocalDateTime now, int minutes) {
+        if (batchId == null || now == null || minutes < 1 || minutes > 1440) return false;
+        // 固定批次 -> 组 -> 子单锁顺序;同骑手抢组提交后扫描必须让路。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !"ACTIVE".equals(batch.getCheckoutStatus()) || batch.getActivatedAt() == null) return false;
+        OrderDeliveryGroup group = checkout.selectGroupByBatchIdForUpdate(batchId);
+        if (group == null || !"WAITING".equals(group.getStatus()) || group.getRiderId() != null
+                || group.getOpenedAt() == null) return false;
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        if (members == null || members.size() < 2) throw new IllegalStateException("Invalid group members");
+        PosOrder first = orders.selectById(members.get(0).getPosOrderId());
+        if (first == null) throw new IllegalStateException("Missing group anchor");
+        Date scheduledStart = PosOrderNoRiderTimeoutService.appointmentStart(first.getDelryTime());
+        if (first.getDelryTime() != null && !first.getDelryTime().isBlank() && scheduledStart == null) {
+            throw new IllegalStateException("Invalid appointment window");
+        }
+        long baseline = Math.max(batch.getActivatedAt().atZone(BUSINESS_ZONE).toInstant().toEpochMilli(),
+                group.getOpenedAt().atZone(BUSINESS_ZONE).toInstant().toEpochMilli());
+        if (scheduledStart != null) baseline = Math.max(baseline, scheduledStart.getTime());
+        if (baseline > now.atZone(BUSINESS_ZONE).toInstant().toEpochMilli() - (long) minutes * 60_000) return false;
+
+        List<PosOrder> active = new ArrayList<>();
+        Map<Long, OrderCheckoutMember> byMember = members.stream()
+                .collect(Collectors.toMap(OrderCheckoutMember::getId, Function.identity()));
+        for (OrderCheckoutMember member : members) {
+            PosOrder order = orders.selectById(member.getPosOrderId());
+            if (order == null) throw new IllegalStateException("Missing group order");
+            if (Long.valueOf(4L).equals(order.getState())) continue;
+            if (!Long.valueOf(0L).equals(order.getState()) || order.getQsId() != null
+                    || !Long.valueOf(0L).equals(order.getDeliveryStatus())) {
+                throw new IllegalStateException("Group order changed during timeout");
+            }
+            active.add(order);
+        }
+        if (checkout.cancelActiveBatch(batchId, "AUTO_NO_RIDER", now) != 1
+                || checkout.cancelWaitingGroup(batchId) != 1) throw new IllegalStateException("Group changed");
+        for (PosOrder order : active) {
+            if (checkout.cancelOrderMember(order.getId()) != 1) throw new IllegalStateException("Order changed");
+            OrderCheckoutMember member = members.stream()
+                    .filter(candidate -> order.getId().equals(candidate.getPosOrderId()))
+                    .findFirst().orElseThrow(() -> new IllegalStateException("Missing group member"));
+            // 超时取消仅返还有效期内、仍绑定当前子单的券;已到期券不延长有效期。
+            if (member.getCouponId() != null) {
+                checkout.returnCouponIfValid(member.getCouponId(), order.getId(), now);
+            }
+            order.setState(4L);
+            notifications.notifyNoRiderCancelled(order);
+        }
+        if (checkout.cancelParentOrder(batch.getParentDdId()) != 1) throw new IllegalStateException("Parent changed");
+        checkout.cancelUnsentActivationByBatch(batchId, now);
+        for (OrderCheckoutPaymentAttempt paid : checkout.selectPaidAttemptsByBatchId(batchId)) {
+            OrderCheckoutMember member = byMember.get(paid.getMemberId());
+            if (member == null || member.getAmount() == null || member.getAmount() <= 0) {
+                throw new IllegalStateException("Invalid paid attempt amount");
+            }
+            checkout.insertRefundIntent(member.getId(), "AUTO_NO_RIDER", paid.getChannel(),
+                    paid.getMerchantOrderNo(), paid.getGatewayTransactionId(), member.getAmount(), now);
+        }
+        return true;
+    }
+}

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

@@ -0,0 +1,32 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/** 036 沿用 037 原字典阈值逐组恢复扫描;异常组留待下一轮重试。 */
+@Component("multiStoreCheckoutNoRiderTimeoutTask")
+public class MultiStoreCheckoutNoRiderTimeoutTask {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutNoRiderTimeoutTask.class);
+    private final MultiStoreCheckoutNoRiderTimeoutService groups;
+    private final PosOrderNoRiderTimeoutService ordinary;
+
+    public MultiStoreCheckoutNoRiderTimeoutTask(MultiStoreCheckoutNoRiderTimeoutService groups,
+                                                PosOrderNoRiderTimeoutService ordinary) {
+        this.groups = groups;
+        this.ordinary = ordinary;
+    }
+
+    public void cancelExpiredGroups() {
+        int minutes = ordinary.timeoutMinutes();
+        for (Long batchId : groups.expiredCandidates(LocalDateTime.now(), minutes, 200)) {
+            try {
+                groups.cancelExpired(batchId, LocalDateTime.now(), minutes);
+            } catch (RuntimeException error) {
+                log.error("Cancel no-rider multi-store group failed, batchId={}", batchId, error);
+            }
+        }
+    }
+}

+ 79 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNotificationService.java

@@ -0,0 +1,79 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutOutboxEvent;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/** 036 开放事件通知消费者;失败保留持久事件,任务重启后可重新排队。 */
+@Service
+public class MultiStoreCheckoutNotificationService {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutNotificationService.class);
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final DeliveryOrderNotificationService notifications;
+
+    public MultiStoreCheckoutNotificationService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                                 DeliveryOrderNotificationService notifications) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.notifications = notifications;
+    }
+
+    public List<Long> pendingNotifications(LocalDateTime now, int limit) {
+        if (now == null || limit < 1 || limit > 500) throw new IllegalArgumentException("Invalid notification scan");
+        return checkout.selectOpenedNotificationEventIds(now, limit);
+    }
+
+    public boolean dispatch(Long eventId, LocalDateTime now) {
+        if (eventId == null || now == null || checkout.claimActivationNotification(eventId, now) != 1) return false;
+        try {
+            OrderCheckoutOutboxEvent event = checkout.selectOutboxById(eventId);
+            if (event == null || !"CHECKOUT_ACTIVATED".equals(event.getEventType())
+                    || !"SENDING".equals(event.getStatus())) throw new IllegalStateException("Event claim changed");
+            OrderCheckoutBatch batch = checkout.selectBatchById(event.getAggregateId());
+            if (batch == null || !"ACTIVE".equals(batch.getCheckoutStatus())) {
+                checkout.cancelOpenedNotification(eventId, now);
+                return false;
+            }
+            OrderDeliveryGroup group = checkout.selectGroupByBatchId(batch.getId());
+            if (group == null || group.getOpenedAt() == null) throw new IllegalStateException("Group not open");
+            List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batch.getId());
+            if (members == null || members.isEmpty()) throw new IllegalStateException("Group has no members");
+            PosOrder anchor = orders.selectById(members.get(0).getPosOrderId());
+            if (anchor == null) throw new IllegalStateException("Missing group anchor");
+            List<PosOrder> active = new ArrayList<>();
+            for (OrderCheckoutMember member : members) {
+                PosOrder order = orders.selectById(member.getPosOrderId());
+                if (order == null) throw new IllegalStateException("Missing group order");
+                if (!Long.valueOf(4L).equals(order.getState())) {
+                    if (!Boolean.TRUE.equals(order.getIsDisplay())) throw new IllegalStateException("Order not open");
+                    active.add(order);
+                }
+            }
+            if (active.isEmpty()) {
+                checkout.cancelOpenedNotification(eventId, now);
+                return false;
+            }
+            // 待抢组只通知一次骑手;已被承接时仍逐子单通知商家,但不再发无效待抢提醒。
+            boolean waiting = "WAITING".equals(group.getStatus()) && group.getRiderId() == null;
+            notifications.notifyMultiStoreGroupAvailable(group.getId(), eventId, anchor, active, waiting);
+            if (checkout.markActivationNotified(eventId, now) != 1) throw new IllegalStateException("Event mark sent failed");
+            return true;
+        } catch (RuntimeException error) {
+            log.error("Queue multi-store open notification failed, eventId={}", eventId, error);
+            checkout.retryActivationNotification(eventId, now, now.plusMinutes(1));
+            return false;
+        }
+    }
+}

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutNotificationTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/** 036 持久事件转既有商家/骑手推送队列;单事件失败不阻断后续事件。 */
+@Component("multiStoreCheckoutNotificationTask")
+public class MultiStoreCheckoutNotificationTask {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutNotificationTask.class);
+    private final MultiStoreCheckoutNotificationService service;
+
+    public MultiStoreCheckoutNotificationTask(MultiStoreCheckoutNotificationService service) {
+        this.service = service;
+    }
+
+    public void dispatchOpenedGroups() {
+        for (Long eventId : service.pendingNotifications(LocalDateTime.now(), 200)) {
+            try {
+                service.dispatch(eventId, LocalDateTime.now());
+            } catch (RuntimeException error) {
+                log.error("Dispatch multi-store notification failed, eventId={}", eventId, error);
+            }
+        }
+    }
+}

+ 184 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactService.java

@@ -0,0 +1,184 @@
+package com.ruoyi.app.order;
+
+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.OrderCheckoutPaymentAttempt;
+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.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 仅供已验签 LINE/OMG 网关路径调用;App 回跳与付款按钮都不是实收事实。 */
+@Service
+public class MultiStoreCheckoutOnlineFactService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final MultiStoreCheckoutCancellationService cancellation;
+
+    public MultiStoreCheckoutOnlineFactService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                              MultiStoreCheckoutCancellationService cancellation) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.cancellation = cancellation;
+    }
+
+    /** 仅新批次号才读 036 表,原普通单不增加查询;真实身份仍以成员关联为准。 */
+    public boolean lockBatchIfAny(String orderNo) {
+        if (orderNo == null || !orderNo.startsWith("MS")) return false;
+        Long batchId = checkout.selectBatchIdByOrderNo(orderNo);
+        if (batchId == null) return false;
+        if (checkout.selectBatchByIdForUpdate(batchId) == null) throw invalid();
+        return true;
+    }
+
+    /** 旧 LINE/OMG 入口只可为已逐店选择且未过发起截止的 036 子单创建交易。 */
+    public boolean authorizeCreate(String orderNo, Long userId, String payType) {
+        if (orderNo == null || !orderNo.startsWith("MS")) return false;
+        if (userId == null || payType == null) throw invalid();
+        Long batchId = checkout.selectBatchIdByOrderNo(orderNo);
+        if (batchId == null) return false;
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        OrderCheckoutMember member = checkout.selectMemberByOrderNo(orderNo);
+        if (batch == null || member == null || !userId.equals(batch.getUserId())
+                || !"PENDING".equals(batch.getCheckoutStatus())
+                || !MultiStoreCheckoutRules.canStartPayment(LocalDateTime.now(), batch.getOperationDeadlineAt())
+                || !"ONLINE_PENDING".equals(member.getPaymentState())
+                || !payType.equals(member.getPayType())) throw invalid();
+        return true;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void recordDispatch(Long orderId, String channel, String payType, String merchantOrderNo) {
+        if (orderId == null || !supported(channel, payType) || merchantOrderNo == null
+                || merchantOrderNo.isBlank()) throw invalid();
+        Long batchId = checkout.selectBatchIdByOrderId(orderId);
+        if (batchId == null) throw invalid();
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        OrderCheckoutMember member = findMember(batchId, orderId);
+        LocalDateTime now = LocalDateTime.now();
+        if (batch == null || !"PENDING".equals(batch.getCheckoutStatus())
+                || !MultiStoreCheckoutRules.canStartPayment(now, batch.getOperationDeadlineAt())
+                || member == null || !"ONLINE_PENDING".equals(member.getPaymentState())
+                || !payType.equals(member.getPayType())) throw invalid();
+        // LINE 为服务端真正发出请求前,OMG 为付款表单发给客户端前;均须持久化实际发起凭据。
+        int inserted = checkout.insertDispatchedAttempt(member.getId(), channel, payType, merchantOrderNo, now);
+        if (inserted != 1) {
+            OrderCheckoutPaymentAttempt existing = checkout.selectAttempt(channel, merchantOrderNo);
+            if (existing == null || !member.getId().equals(existing.getMemberId())
+                    || !payType.equals(existing.getPayType()) || existing.getDispatchedAt() == null) throw invalid();
+        }
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public String recordPaid(Long orderId, String channel, String payType, String merchantOrderNo,
+                             String gatewayTransactionId, int paidAmount) {
+        return recordPaid(orderId, channel, payType, merchantOrderNo, gatewayTransactionId, paidAmount, true);
+    }
+
+    /** 订单支付行更新失败时只能保存实收及退款证据,不得使 036 成员就绪。 */
+    @Transactional(rollbackFor = Exception.class)
+    public String recordPaid(Long orderId, String channel, String payType, String merchantOrderNo,
+                             String gatewayTransactionId, int paidAmount, boolean orderPaymentRecorded) {
+        if (orderId == null || !supported(channel, payType) || merchantOrderNo == null
+                || merchantOrderNo.isBlank() || gatewayTransactionId == null
+                || gatewayTransactionId.isBlank() || paidAmount <= 0) throw invalid();
+        Long batchId = checkout.selectBatchIdByOrderId(orderId);
+        if (batchId == null) return "NOT_BATCH";
+        // 批次锁先于成员事实和激活,取消/回调的唯一赢家由服务端数据库决定。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        OrderCheckoutMember member = findMember(batchId, orderId);
+        if (batch == null || member == null) throw invalid();
+        LocalDateTime now = LocalDateTime.now();
+        OrderCheckoutPaymentAttempt attempt = checkout.selectAttempt(channel, merchantOrderNo);
+        boolean identified = attempt != null && member.getId().equals(attempt.getMemberId())
+                && payType.equals(attempt.getPayType());
+        if (identified && !"PAID".equals(attempt.getState())) {
+            if (checkout.markAttemptPaid(attempt.getId(), gatewayTransactionId, now) != 1) {
+                // 网关已证实扣款但本地尝试状态矛盾,必须留退款意图而非抛异常回滚收款证据。
+                refund(member, "ATTEMPT_STATE_CONFLICT", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+                return "REFUND_REQUIRED";
+            }
+        } else if (identified && "PAID".equals(attempt.getState())
+                && !gatewayTransactionId.equals(attempt.getGatewayTransactionId())) {
+            throw invalid();
+        }
+        if (!identified || attempt.getDispatchedAt() == null) {
+            refund(member, "UNVERIFIED_DISPATCH", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if (member.getAmount() == null || member.getAmount() != paidAmount) {
+            refund(member, "AMOUNT_MISMATCH", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if ("ONLINE_PAID".equals(member.getPaymentState())) {
+            // 成员只记首笔获准实收:原尝试重放是幂等,其余真实扣款必须原路退回。
+            if (attempt.getId().equals(member.getAcceptedPaymentAttemptId())) {
+                return batch.getCheckoutStatus();
+            }
+            refund(member, "DUPLICATE_PAYMENT", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if (!orderPaymentRecorded) {
+            refund(member, "ORDER_NOT_PAYABLE", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if (!"PENDING".equals(batch.getCheckoutStatus())
+                || !MultiStoreCheckoutRules.canVerifyDispatchedPayment(now, attempt.getDispatchedAt(),
+                        batch.getOperationDeadlineAt(), batch.getFinalDeadlineAt())) {
+            refund(member, "LATE_PAYMENT", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if (!"ONLINE_PENDING".equals(member.getPaymentState()) || !payType.equals(member.getPayType())
+                || checkout.updateMemberOnlinePaid(member.getId(), paidAmount, attempt.getId(), now) != 1) {
+            refund(member, "INVALID_PAYMENT_STATE", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        if (members == null || members.size() < 2) throw invalid();
+        List<String> states = members.stream().map(item -> item.getId().equals(member.getId())
+                ? "ONLINE_PAID" : item.getPaymentState()).toList();
+        if (!states.stream().allMatch(state -> "ONLINE_PAID".equals(state) || "COD_CONFIRMED".equals(state))) {
+            return "PENDING";
+        }
+        PosOrder first = orders.selectById(members.get(0).getPosOrderId());
+        if (first != null && MultiStoreCheckoutRules.appointmentExpired(first.getDelryTime(), now)) {
+            // 当前实收已落支付尝试,与先前各店实收一起在批次锁内关闭并登记逐交易退款。
+            if (cancellation.cancelExpired(batchId, now)) return "REFUND_REQUIRED";
+            refund(member, "APPOINTMENT_EXPIRED", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if (first == null || !MultiStoreCheckoutRules.canActivate(states, first.getDelryTime(), now)) {
+            refund(member, "APPOINTMENT_EXPIRED", channel, merchantOrderNo, gatewayTransactionId, paidAmount, now);
+            return "REFUND_REQUIRED";
+        }
+        if (checkout.activateIfPending(batchId, now) != 1) throw invalid();
+        checkout.insertActivationEvent(batchId, now);
+        return "ACTIVE";
+    }
+
+    private OrderCheckoutMember findMember(Long batchId, Long orderId) {
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batchId);
+        return members == null ? null : members.stream().filter(item -> orderId.equals(item.getPosOrderId()))
+                .findFirst().orElse(null);
+    }
+
+    private boolean supported(String channel, String payType) {
+        return "LINE".equals(channel) && "3".equals(payType)
+                || "OMG".equals(channel) && ("2".equals(payType) || "5".equals(payType));
+    }
+
+    private void refund(OrderCheckoutMember member, String reason, String channel,
+                        String merchantOrderNo, String transactionId, int amount, LocalDateTime now) {
+        checkout.insertRefundIntent(member.getId(), reason, channel, merchantOrderNo, transactionId, amount, now);
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 74 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionService.java

@@ -0,0 +1,74 @@
+package com.ruoyi.app.order;
+
+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.PosStore;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 036 子单逐店选择线上付款方式;选择只表示待发起,不等于网关已收款。 */
+@Service
+public class MultiStoreCheckoutOnlineSelectionService {
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final PosStoreMapper stores;
+    private final PaymentMethodGateService payments;
+
+    public MultiStoreCheckoutOnlineSelectionService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                                    PosStoreMapper stores, PaymentMethodGateService payments) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.stores = stores;
+        this.payments = payments;
+    }
+
+    @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();
+        // 与到付确认、将来的网关实收及取消共用批次行锁;普通订单不调用此服务。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(batchId);
+        if (batch == null || !userId.equals(batch.getUserId())
+                || !"PENDING".equals(batch.getCheckoutStatus())
+                || !MultiStoreCheckoutRules.canStartPayment(LocalDateTime.now(), batch.getOperationDeadlineAt())) {
+            throw invalid();
+        }
+        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) throw invalid();
+        PosOrder order = orders.selectById(orderId);
+        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())) {
+            // 同渠道网络重试可继续查询原尝试;未决时不能改用另一个渠道。
+            return "ONLINE_PENDING";
+        }
+        if (!"UNSELECTED".equals(member.getPaymentState()) || member.getPayType() != null
+                || order.getPayType() != null
+                || checkout.updateMemberOnlinePending(member.getId(), payType) != 1
+                || checkout.updateOnlineOrderType(orderId, payType) != 1) throw invalid();
+        return "ONLINE_PENDING";
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutPaymentSelectionController.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreMemberPaymentRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 036 逐店收银入口;选择线上方式不代表已发起交易,更不代表已经实收。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout/batches/members")
+public class MultiStoreCheckoutPaymentSelectionController extends BaseController {
+    private final MultiStoreCheckoutCodService cod;
+    private final MultiStoreCheckoutOnlineSelectionService online;
+
+    public MultiStoreCheckoutPaymentSelectionController(MultiStoreCheckoutCodService cod,
+                                                        MultiStoreCheckoutOnlineSelectionService online) {
+        this.cod = cod;
+        this.online = online;
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/payment")
+    public AjaxResult select(@RequestHeader String token,
+                             @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                             String capability,
+                             @RequestBody MultiStoreMemberPaymentRequest input) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
+        if (input == null || input.batchId() == null || input.orderId() == null
+                || input.clientRequestId() == null || input.clientRequestId().isBlank()
+                || input.clientRequestId().length() > 64) throw invalid();
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        if ("1".equals(input.payType())) {
+            return AjaxResult.success(MessageUtils.message("no.action.success"),
+                    cod.confirm(input.batchId(), input.orderId(), userId));
+        }
+        if ("2".equals(input.payType()) || "3".equals(input.payType())
+                || "5".equals(input.payType())) {
+            // 前端取得 ONLINE_PENDING 后,按子单号调用现有 LINE/OMG 创建接口;可信回调才写实收。
+            return AjaxResult.success(MessageUtils.message("no.action.success"),
+                    online.select(input.batchId(), input.orderId(), userId, input.payType()));
+        }
+        throw invalid();
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

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

@@ -0,0 +1,125 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+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 获取,不能由请求参数指定。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout")
+public class MultiStoreCheckoutQueryController extends BaseController {
+    private final MultiStoreCheckoutQueryService queries;
+
+    public MultiStoreCheckoutQueryController(MultiStoreCheckoutQueryService queries) {
+        this.queries = queries;
+    }
+
+    @Anonymous
+    @Auth
+    @GetMapping("/user/cards")
+    public AjaxResult userCards(@RequestHeader String token,
+                                @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                                String capability,
+                                @RequestParam(defaultValue = "1") int page,
+                                @RequestParam(defaultValue = "10") int size,
+                                @RequestParam(defaultValue = "all") String tab) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability))
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        return AjaxResult.success(queries.userCards(identity(token), page, size, tab));
+    }
+
+    @Anonymous
+    @Auth
+    @GetMapping("/user/cardCount")
+    public AjaxResult userCardCount(@RequestHeader String token,
+                                    @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                                    String capability) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability))
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        return AjaxResult.success(queries.userCardCount(identity(token)));
+    }
+
+    @Anonymous
+    @Auth
+    @GetMapping("/batches/{batchId}")
+    public AjaxResult batchDetail(@RequestHeader String token,
+                                  @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                                  String capability, @PathVariable Long batchId) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability))
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        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}")
+    public AjaxResult groupDetail(@RequestHeader String token,
+                                  @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                                  String capability, @PathVariable Long groupId) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability))
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        return AjaxResult.success(queries.groupDetail(groupId, identity(token), newTaskDistanceLimitMeters()));
+    }
+
+    private Long identity(String token) {
+        return Long.valueOf(new JwtUtil().getusid(token));
+    }
+
+    /** 与旧骑手列表沿用同一距离字典;缺失时待抢组不可见。 */
+    private Long newTaskDistanceLimitMeters() {
+        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;
+    }
+}

+ 256 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQueryService.java

@@ -0,0 +1,256 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreCardView;
+import com.ruoyi.app.order.dto.MultiStoreMemberView;
+import com.ruoyi.app.order.dto.MultiStorePageView;
+import com.ruoyi.app.order.dto.MultiStoreRefundMemberView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.CheckoutCardRow;
+import com.ruoyi.system.domain.CheckoutMemberRow;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.MultiStoreCheckoutQueryMapper;
+import com.ruoyi.system.service.IPosOrderService;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/** 036 组合卡只读装配:数据库先筛卡片键并分页,本页再批量装配子单。 */
+@Service
+public class MultiStoreCheckoutQueryService {
+    private static final int MAX_PAGE_SIZE = 50;
+    private static final List<String> USER_TABS = Arrays.asList("all", "pending", "active", "completed", "cancelled", "refund");
+    private static final List<String> RIDER_TABS = Arrays.asList("newTask", "toPickup", "delivering", "completed", "cancelled", "refund");
+    private final MultiStoreCheckoutQueryMapper mapper;
+    private final MultiStoreCheckoutRefundProjectionService refunds;
+    private final IPosOrderService ordinaryOrders;
+
+    public MultiStoreCheckoutQueryService(MultiStoreCheckoutQueryMapper mapper,
+                                          MultiStoreCheckoutRefundProjectionService refunds,
+                                          IPosOrderService ordinaryOrders) {
+        this.mapper = mapper;
+        this.refunds = refunds;
+        this.ordinaryOrders = ordinaryOrders;
+    }
+
+    public MultiStorePageView userCards(Long userId, int page, int size, String tab) {
+        validate(userId, page, size, tab, USER_TABS);
+        long total = mapper.countUserCards(userId, tab);
+        List<CheckoutCardRow> rows = mapper.selectUserCards(userId, tab, (page - 1) * size, size);
+        return new MultiStorePageView(assemble(rows), total, page, size);
+    }
+
+    public Map<String, Long> userCardCount(Long userId) {
+        if (userId == null) throw invalid();
+        Map<String, Long> result = new HashMap<>();
+        for (String tab : USER_TABS) result.put(tab, mapper.countUserCards(userId, tab));
+        return result;
+    }
+
+    public MultiStoreCardView batchDetail(Long batchId, Long userId) {
+        if (batchId == null || userId == null) throw invalid();
+        CheckoutCardRow row = mapper.selectBatchForUser(batchId, userId);
+        if (row == null) throw invalid();
+        return assemble(List.of(row)).get(0);
+    }
+
+    public MultiStorePageView riderTasks(Long riderId, int page, int size, String tab,
+                                         BigDecimal longitude, BigDecimal latitude, Long limitMeters) {
+        validate(riderId, page, size, tab, RIDER_TABS);
+        if ((longitude == null) != (latitude == null)) throw invalid();
+        long total = mapper.countRiderTasks(riderId, tab, longitude, latitude, limitMeters);
+        List<CheckoutCardRow> rows = mapper.selectRiderTasks(riderId, tab, longitude, latitude,
+                limitMeters, (page - 1) * size, size);
+        return new MultiStorePageView(assemble(rows), total, page, size);
+    }
+
+    public Map<String, Long> riderTaskCount(Long riderId, BigDecimal longitude, BigDecimal latitude,
+                                            Long limitMeters) {
+        if (riderId == null || (longitude == null) != (latitude == null)) throw invalid();
+        Map<String, Long> result = new HashMap<>();
+        for (String tab : RIDER_TABS) {
+            result.put(tab, mapper.countRiderTasks(riderId, tab, longitude, latitude, limitMeters));
+        }
+        return result;
+    }
+
+    public MultiStoreCardView groupDetail(Long groupId, Long riderId, Long limitMeters) {
+        if (groupId == null || riderId == null) throw invalid();
+        CheckoutCardRow row = mapper.selectGroupForRider(groupId, riderId, limitMeters);
+        if (row == null) throw invalid();
+        return assemble(List.of(row)).get(0);
+    }
+
+    private List<MultiStoreCardView> assemble(List<CheckoutCardRow> rows) {
+        if (rows == null || rows.isEmpty()) return List.of();
+        List<Long> batchIds = rows.stream().map(CheckoutCardRow::getBatchId).filter(Objects::nonNull).distinct().toList();
+        Map<Long, List<CheckoutMemberRow>> members = new HashMap<>();
+        if (!batchIds.isEmpty()) {
+            for (CheckoutMemberRow member : mapper.selectMembersForBatches(batchIds)) {
+                members.computeIfAbsent(member.getBatchId(), ignored -> new ArrayList<>()).add(member);
+            }
+        }
+        Map<Long, Map<Long, MultiStoreRefundMemberView>> refundFacts = new HashMap<>();
+        for (Long batchId : batchIds) {
+            Map<Long, MultiStoreRefundMemberView> projected = refunds.project(batchId);
+            refundFacts.put(batchId, projected == null ? Map.of() : projected);
+        }
+        List<MultiStoreCardView> result = rows.stream().map(row -> assembleOne(row,
+                members.getOrDefault(row.getBatchId(), List.of()),
+                refundFacts.getOrDefault(row.getBatchId(), Map.of()))).toList();
+        List<Long> ordinaryIds = rows.stream().filter(row -> "ORDER".equals(row.getCardType()))
+                .map(CheckoutCardRow::getCardId).filter(Objects::nonNull).distinct().toList();
+        if (!ordinaryIds.isEmpty()) {
+            // 仅查询当前页普通单,复用原 PosOrder 快照字段,避免新组合接口丢失旧单展示内容。
+            Map<Long, PosOrder> originals = new HashMap<>();
+            for (PosOrder order : ordinaryOrders.listByIds(ordinaryIds)) originals.put(order.getId(), order);
+            for (MultiStoreCardView view : result) {
+                if ("ORDER".equals(view.getCardType())) view.setLegacyOrder(originals.get(view.getOrderId()));
+            }
+        }
+        return result;
+    }
+
+    private MultiStoreCardView assembleOne(CheckoutCardRow row, List<CheckoutMemberRow> members,
+                                           Map<Long, MultiStoreRefundMemberView> refundFacts) {
+        MultiStoreCardView view = new MultiStoreCardView();
+        view.setCardType(row.getCardType());
+        view.setCardKey(row.getCardKey());
+        view.setOrderId("ORDER".equals(row.getCardType()) ? row.getCardId() : null);
+        view.setBatchId(row.getBatchId());
+        view.setGroupId(row.getGroupId());
+        view.setVersion(row.getVersion());
+        view.setParentOrderNo(row.getParentOrderNo());
+        view.setOrderNo(row.getOrderNo());
+        view.setStoreId(row.getStoreId());
+        view.setStoreName(row.getStoreName());
+        view.setOrderState(row.getOrderState());
+        view.setDeliveryStatus(row.getDeliveryStatus());
+        view.setPayType(row.getPayType());
+        view.setCheckoutStatus(row.getCheckoutStatus());
+        view.setGroupStatus(row.getGroupStatus());
+        view.setPrimaryTab(row.getPrimaryTab());
+        view.setTotalAmount(row.getTotalAmount());
+        view.setCreatedAt(row.getCreatedAt());
+        view.setOperationDeadlineAt(row.getOperationDeadlineAt());
+        view.setFinalDeadlineAt(row.getFinalDeadlineAt());
+        view.setActivatedAt(row.getActivatedAt());
+        view.setOpenedAt(row.getOpenedAt());
+        view.setAnchorDistanceMeters(row.getAnchorDistanceMeters());
+        List<MultiStoreMemberView> summaries = new ArrayList<>();
+        boolean hasRefundPending = false;
+        boolean hasRefunded = false;
+        for (CheckoutMemberRow member : members) {
+            boolean cancelled = Objects.equals(member.getState(), 4L);
+            MultiStoreRefundMemberView refund = refundFacts.get(member.getMemberId());
+            boolean refunded = refund != null && refund.refundedAmount() > 0;
+            boolean refundPending = refund != null && refund.pendingAmount() > 0;
+            hasRefundPending |= refundPending;
+            hasRefunded |= refunded;
+            view.setMemberCount(view.getMemberCount() + 1);
+            if (cancelled) view.setCancelledCount(view.getCancelledCount() + 1);
+            else view.setActiveCount(view.getActiveCount() + 1);
+            if (member.getPaidAmount() != null) view.setOnlinePaidAmount(view.getOnlinePaidAmount() + member.getPaidAmount());
+            if (member.getPaymentState() != null && ("ONLINE_PAID".equals(member.getPaymentState())
+                    || "COD_CONFIRMED".equals(member.getPaymentState()))) view.setReadyCount(view.getReadyCount() + 1);
+            if (!cancelled) {
+                if (member.getDeliveryStatus() != null && member.getDeliveryStatus() >= 2L)
+                    view.setPickedUpCount(view.getPickedUpCount() + 1);
+                if (Objects.equals(member.getDeliveryStatus(), 3L))
+                    view.setDeliveredCount(view.getDeliveredCount() + 1);
+                if (member.getDiscountedFreight() != null)
+                    view.setDiscountedFreightTotal(view.getDiscountedFreightTotal() + member.getDiscountedFreight());
+                if ("COD_CONFIRMED".equals(member.getPaymentState()) && member.getOrderAmount() != null) {
+                    view.setCodPromisedAmount(view.getCodPromisedAmount() + member.getOrderAmount());
+                    view.setCodToCollectAmount(view.getCodToCollectAmount() + member.getOrderAmount());
+                }
+            }
+            if (refund != null) {
+                view.setRefundedAmount(view.getRefundedAmount() + refund.refundedAmount());
+                view.setRefundPendingAmount(view.getRefundPendingAmount() + refund.pendingAmount());
+            }
+            summaries.add(memberView(row, member, cancelled, refund));
+        }
+        view.setMembers(summaries);
+        if (hasRefundPending) view.setRefundSummary(refundFacts.values().stream()
+                .filter(fact -> fact.pendingAmount() > 0).map(MultiStoreRefundMemberView::status)
+                .filter(Objects::nonNull).findFirst().orElse("PENDING"));
+        else if (hasRefunded) view.setRefundSummary("REFUNDED");
+        if ("GROUP".equals(row.getCardType())) {
+            if ("ACCEPTED".equals(row.getGroupStatus())) {
+                if (view.getActiveCount() > view.getPickedUpCount()) view.setPrimaryTab("toPickup");
+                else if (view.getActiveCount() > view.getDeliveredCount()) view.setPrimaryTab("delivering");
+                else view.setPrimaryTab(view.getActiveCount() == 0 ? "cancelled" : "completed");
+            }
+            if ("WAITING".equals(row.getGroupStatus())) {
+                view.setPrimaryTab("newTask");
+                view.setAllowedActions(List.of("ACCEPT_GROUP"));
+            } else if ("FINISHED".equals(row.getGroupStatus())) {
+                // 详情 SQL 不经列表的阶段 CASE;终态组必须仍返回可展示的主页签。
+                view.setPrimaryTab("completed");
+            } else if ("CANCELLED".equals(row.getGroupStatus())) {
+                view.setPrimaryTab("cancelled");
+            }
+        } else if ("BATCH".equals(row.getCardType())) {
+            if ("PENDING".equals(row.getCheckoutStatus())) {
+                if (row.getOperationDeadlineAt() != null && LocalDateTime.now().isBefore(row.getOperationDeadlineAt()))
+                    view.setAllowedActions(List.of("SELECT_PAYMENT", "CANCEL_BATCH"));
+                else view.setAllowedActions(List.of("CANCEL_BATCH"));
+            } else if ("ACTIVE".equals(row.getCheckoutStatus()) && "WAITING".equals(row.getGroupStatus())
+                    && view.getActiveCount() > 0) {
+                view.setAllowedActions(List.of("CANCEL_MEMBER"));
+            }
+        }
+        return view;
+    }
+
+    private MultiStoreMemberView memberView(CheckoutCardRow card, CheckoutMemberRow row, boolean cancelled,
+                                            MultiStoreRefundMemberView refund) {
+        MultiStoreMemberView view = new MultiStoreMemberView();
+        view.setOrderId(row.getOrderId());
+        view.setOrderNo(row.getOrderNo());
+        view.setStoreId(row.getStoreId());
+        view.setStoreName(row.getStoreName());
+        view.setPayType(row.getPayType());
+        view.setPaymentState(row.getPaymentState());
+        view.setOrderAmount(row.getOrderAmount());
+        view.setDiscountedFreight(row.getDiscountedFreight());
+        view.setDeliveryStatus(row.getDeliveryStatus());
+        view.setOrderState(row.getState());
+        view.setCancelStatus(cancelled ? "CANCELLED" : "ACTIVE");
+        view.setRefundStatus(refund == null ? "NONE" : refund.status());
+        if (!cancelled && "COD_CONFIRMED".equals(row.getPaymentState()) && row.getOrderAmount() != null) {
+            view.setCodToCollectAmount(row.getOrderAmount());
+            view.setCodToMerchantAmount(row.getOrderAmount() - nonNull(row.getDiscountedFreight()));
+        }
+        // 逐子单动作仍走普通履约入口;这里只给已接组的实际骑手展示当前可执行动作。
+        if (!cancelled && "GROUP".equals(card.getCardType()) && "ACCEPTED".equals(card.getGroupStatus())) {
+            if (Objects.equals(row.getState(), 2L) && Objects.equals(row.getDeliveryStatus(), 1L)) {
+                view.setAllowedActions(List.of("PICKUP_MEMBER"));
+            } else if (Objects.equals(row.getState(), 2L) && Objects.equals(row.getDeliveryStatus(), 2L)) {
+                view.setAllowedActions(List.of("DELIVER_MEMBER"));
+            }
+        }
+        return view;
+    }
+
+    private void validate(Long ownerId, int page, int size, String tab, List<String> tabs) {
+        if (ownerId == null || page < 1 || size < 1 || size > MAX_PAGE_SIZE || !tabs.contains(tab)
+                || (long) (page - 1) * size > Integer.MAX_VALUE) throw invalid();
+    }
+
+    private int nonNull(Integer value) {
+        return value == null ? 0 : value;
+    }
+
+    private ServiceException invalid() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+    }
+}

+ 6 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteController.java

@@ -25,7 +25,12 @@ public class MultiStoreCheckoutQuoteController extends BaseController {
     @PostMapping("/quote")
     @Auth
     @Anonymous
-    public AjaxResult quote(@RequestHeader String token, @RequestBody MultiStoreQuoteRequest request) {
+    public AjaxResult quote(@RequestHeader String token,
+                            @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false) String capability,
+                            @RequestBody MultiStoreQuoteRequest request) {
+        if (!MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
         Long userId = Long.valueOf(new JwtUtil().getusid(token));
         return success(quoteService.quote(request, userId));
     }

+ 61 - 23
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteService.java

@@ -16,6 +16,7 @@ import com.ruoyi.system.domain.PosFreight;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
 import com.ruoyi.system.mapper.FoodSpecRelationMapper;
 import com.ruoyi.system.mapper.FoodSpecsMapper;
 import com.ruoyi.system.mapper.FoodSpecsValueMapper;
@@ -27,6 +28,8 @@ import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.IPromotionCalcService;
 import com.ruoyi.system.service.PaymentMethodGateService;
 import com.ruoyi.system.service.StallQualificationGateService;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -68,6 +71,7 @@ public class MultiStoreCheckoutQuoteService {
     private final SelfDeliveryService selfDelivery;
     private final IPromotionCalcService promotions;
     private final RedisCache redis;
+    private final RouteDistanceMatrixProvider routeMatrix;
 
     public MultiStoreCheckoutQuoteService(MultiStoreCheckoutConfigService config, InfoAddressMapper addresses,
                                           PosStoreMapper stores, OperatingHoursMapper hours, PosFoodMapper foods,
@@ -75,7 +79,7 @@ public class MultiStoreCheckoutQuoteService {
                                           FoodSpecsValueMapper values, PosFreightMapper freight,
                                           PaymentMethodGateService payments, StallQualificationGateService qualifications,
                                           SelfDeliveryService selfDelivery, IPromotionCalcService promotions,
-                                          RedisCache redis) {
+                                          RedisCache redis, RouteDistanceMatrixProvider routeMatrix) {
         this.config = config;
         this.addresses = addresses;
         this.stores = stores;
@@ -90,6 +94,7 @@ public class MultiStoreCheckoutQuoteService {
         this.selfDelivery = selfDelivery;
         this.promotions = promotions;
         this.redis = redis;
+        this.routeMatrix = routeMatrix;
     }
 
     public MultiStoreQuoteView quote(MultiStoreQuoteRequest input, Long userId) {
@@ -141,23 +146,28 @@ public class MultiStoreCheckoutQuoteService {
                 excluded.add(new MultiStoreQuoteView.ExcludedStore(selected.storeId(), "NO_SUPPORTED_PAYMENT"));
                 continue;
             }
-            PromotionCalcResponse foodPrice = priceFood(selected, userId);
-            BigDecimal distanceMeters = BigDecimal.valueOf(MultiStoreCheckoutRules.distanceMeters(
-                    store.getLatitude().doubleValue(), store.getLongitude().doubleValue(),
-                    address.getLatitude().doubleValue(), address.getLongitude().doubleValue()));
-            candidates.add(new Candidate(selected.storeId(), foodPrice.getOriginalAmount(), foodPrice.getFinalAmount(),
-                    originalFreight(fare, distanceMeters), payTypes));
+            candidates.add(new Candidate(selected, store, payTypes));
         }
-        if (candidates.size() < 2 || !candidates.get(0).storeId().equals(Long.valueOf(anchor.getId()))) {
+        if (candidates.size() < 2 || !candidates.get(0).selected().storeId().equals(Long.valueOf(anchor.getId()))) {
             throw invalid("multi.store.checkout.invalid");
         }
+        // 资格/围栏/支付方式筛选完成后,只对真正参与的门店发一次N×1请求。
+        // 每店到用户的道路里程独立计价;这不是按门店顺序串联的一条导航路线。
+        List<BigDecimal> distances = routeDistancesMeters(candidates.stream().map(Candidate::store).toList(), address);
         BigDecimal ratio = MultiStoreCheckoutRules.resolveRatio(policy.tiers(), candidates.size());
-        List<MultiStoreQuoteView.StorePrice> prices = candidates.stream().map(candidate -> {
-            BigDecimal reduced = MultiStoreCheckoutRules.discountedFreight(candidate.originalFreight(), ratio);
-            return new MultiStoreQuoteView.StorePrice(candidate.storeId(), candidate.originalFoodAmount(), candidate.foodAmount(),
-                    candidate.originalFreight(), reduced, candidate.foodAmount().add(reduced), candidate.payTypes());
-        }).toList();
-        List<Long> participants = candidates.stream().map(Candidate::storeId).toList();
+        List<MultiStoreQuoteView.StorePrice> prices = new ArrayList<>();
+        for (int index = 0; index < candidates.size(); index++) {
+            Candidate candidate = candidates.get(index);
+            BigDecimal original = originalFreight(fare, distances.get(index));
+            // 先按参与店数确定多店折后运费,再把它作为免运活动的骑手毛配送费基数。
+            BigDecimal reduced = MultiStoreCheckoutRules.discountedFreight(original, ratio);
+            PromotionCalcResponse price = priceFoodForBatch(candidate.selected(), userId, reduced);
+            prices.add(new MultiStoreQuoteView.StorePrice(candidate.selected().storeId(), price.getOriginalAmount(), price.getFinalAmount(),
+                    original, reduced, price.getCustomerDeliveryFee(),
+                    price.getDeliveryDiscountAmount(), price.getPayableAmount(), candidate.payTypes()));
+        }
+        prices = List.copyOf(prices);
+        List<Long> participants = candidates.stream().map(candidate -> candidate.selected().storeId()).toList();
         long expiresAt = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(QUOTE_TTL_MINUTES);
         // Token 是随机缓存索引而非客户端可自行构造的价格凭据;建批时还须重新报价并比对指纹。
         String quoteToken = UUID.randomUUID().toString();
@@ -182,7 +192,9 @@ public class MultiStoreCheckoutQuoteService {
         return snapshot;
     }
 
-    private PromotionCalcResponse priceFood(MultiStoreQuoteRequest.Store selected, Long userId) {
+    /** 建批事务复算同一商品/券明细并用于固化促销快照;不接受客户端商品价格。 */
+    PromotionCalcResponse priceFoodForBatch(MultiStoreQuoteRequest.Store selected, Long userId,
+                                            BigDecimal discountedFreight) {
         if (selected.items() == null || selected.items().isEmpty()) throw invalid("multi.store.checkout.food.invalid");
         PromotionCalcRequest request = new PromotionCalcRequest();
         request.setStoreId(selected.storeId());
@@ -204,15 +216,24 @@ public class MultiStoreCheckoutQuoteService {
             cartItem.setProductId(item.productId());
             cartItem.setQuantity(item.quantity());
             cartItem.setSpecPrice(specPrice);
+            cartItem.setSpecValueIds(item.specValueIds());
             cart.add(cartItem);
             independentlyPriced = independentlyPriced.add(food.getPrice().add(specPrice)
                     .multiply(BigDecimal.valueOf(item.quantity())));
         }
         request.setItems(cart);
-        PromotionCalcResponse promotion = promotions.calculate(request, userId);
+        PromotionCalcResponse promotion = promotions.calculate(request, userId,
+                new PromotionDeliveryContext(discountedFreight, true));
         if (promotion == null || promotion.getOriginalAmount() == null || promotion.getFinalAmount() == null
                 || promotion.getOriginalAmount().compareTo(independentlyPriced.setScale(0, RoundingMode.HALF_UP)) != 0
                 || promotion.getFinalAmount().signum() < 0
+                || promotion.getDeliveryFee() == null || promotion.getDeliveryFee().compareTo(discountedFreight) != 0
+                || promotion.getDeliveryDiscountAmount() == null || promotion.getDeliveryDiscountAmount().signum() < 0
+                || promotion.getCustomerDeliveryFee() == null || promotion.getCustomerDeliveryFee().signum() < 0
+                || promotion.getDeliveryDiscountAmount().add(promotion.getCustomerDeliveryFee())
+                        .compareTo(discountedFreight) != 0
+                || promotion.getPayableAmount() == null || promotion.getPayableAmount().compareTo(
+                        promotion.getFinalAmount().add(promotion.getCustomerDeliveryFee())) != 0
                 || (selected.couponId() != null && !selected.couponId().equals(promotion.getCouponId()))) {
             throw invalid("multi.store.checkout.food.invalid");
         }
@@ -279,7 +300,7 @@ public class MultiStoreCheckoutQuoteService {
             LocalDate date = LocalDate.parse(window.substring(0, 10));
             LocalTime start = LocalTime.parse(window.substring(11, 16));
             LocalTime end = LocalTime.parse(window.substring(17, 22));
-            if (window.length() != 22 || window.charAt(16) != '-' || !end.isAfter(start)
+            if (window.length() != 22 || window.charAt(10) != ' ' || window.charAt(16) != '-' || !end.isAfter(start)
                     || !date.atTime(start).isAfter(LocalDateTime.now())) {
                 throw invalid("multi.store.checkout.invalid");
             }
@@ -306,11 +327,9 @@ public class MultiStoreCheckoutQuoteService {
         return row;
     }
 
-    /** 对齐旧 getyunfei 的公里取三位、跳表整数单价、半公里门槛及千元舍入规则。 */
+    /** 对齐普通外卖的道路里程运价跳表;首店合并围栏仍独立按直线距离判断。 */
     private BigDecimal originalFreight(PosFreight row, BigDecimal meters) {
-        // 旧直线运费按 6378.137km 地球半径先取整到米;围栏则保留标准球面距离。
-        BigDecimal km = BigDecimal.valueOf(Math.round(meters.doubleValue() * 6378.137D / 6371D))
-                .movePointLeft(3);
+        BigDecimal km = meters.movePointLeft(3);
         BigDecimal extra = km.subtract(BigDecimal.valueOf(row.getStartingDistance())).max(BigDecimal.ZERO);
         if (extra.signum() > 0 && extra.compareTo(new BigDecimal("0.5")) < 0) extra = BigDecimal.ZERO;
         else if (extra.compareTo(new BigDecimal("0.5")) >= 0 && extra.compareTo(BigDecimal.ONE) < 0) extra = BigDecimal.ONE;
@@ -329,6 +348,26 @@ public class MultiStoreCheckoutQuoteService {
         return store.getLatitude() != null && store.getLongitude() != null;
     }
 
+    /** 矩阵返回顺序按参与店归位;部分失败不能生成报价,建批复核仍会重新调用本服务。 */
+    protected List<BigDecimal> routeDistancesMeters(List<PosStore> participants, InfoAddress address) {
+        try {
+            List<GeoPoint> origins = participants.stream()
+                    .map(store -> new GeoPoint(store.getLatitude(), store.getLongitude())).toList();
+            List<BigDecimal> distances = routeMatrix.distancesToDestination(origins,
+                    new GeoPoint(address.getLatitude(), address.getLongitude()));
+            if (distances == null || distances.size() != participants.size()
+                    || distances.stream().anyMatch(value -> value == null || value.signum() <= 0
+                    || value.stripTrailingZeros().scale() > 0)) {
+                throw invalid("multi.store.checkout.freight.invalid");
+            }
+            return distances;
+        } catch (ServiceException known) {
+            throw known;
+        } catch (Exception unavailable) {
+            throw invalid("multi.store.checkout.freight.invalid");
+        }
+    }
+
     private String fingerprint(String canonical) {
         try {
             byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8));
@@ -342,8 +381,7 @@ public class MultiStoreCheckoutQuoteService {
         return new ServiceException(MessageUtils.message(key));
     }
 
-    private record Candidate(Long storeId, BigDecimal originalFoodAmount, BigDecimal foodAmount,
-                             BigDecimal originalFreight,
+    private record Candidate(MultiStoreQuoteRequest.Store selected, PosStore store,
                              List<String> payTypes) {
     }
 

+ 70 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundBridgeService.java

@@ -0,0 +1,70 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.omgpay.OmgPaymentRefundService;
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.system.domain.OrderCheckoutRefundIntent;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+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 org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Objects;
+
+/** 将 036 意图桥接到现有渠道唯一退款队列;此处绝不直接请求网关。 */
+@Service
+public class MultiStoreCheckoutRefundBridgeService {
+    private final OrderCheckoutMapper checkout;
+    private final IPosOrderLinePaymentService linePayments;
+    private final LinePayRefundService lineRefunds;
+    private final IOmgPaymentAttemptService omgAttempts;
+    private final OmgPaymentRefundService omgRefunds;
+
+    public MultiStoreCheckoutRefundBridgeService(OrderCheckoutMapper checkout,
+                                                 IPosOrderLinePaymentService linePayments,
+                                                 LinePayRefundService lineRefunds,
+                                                 IOmgPaymentAttemptService omgAttempts,
+                                                 OmgPaymentRefundService omgRefunds) {
+        this.checkout = checkout;
+        this.linePayments = linePayments;
+        this.lineRefunds = lineRefunds;
+        this.omgAttempts = omgAttempts;
+        this.omgRefunds = omgRefunds;
+    }
+
+    public List<Long> pendingIds(int limit) {
+        if (limit < 1 || limit > 500) throw new IllegalArgumentException("invalid refund scan limit");
+        return checkout.selectPendingRefundIntentIds(limit);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void bridge(Long intentId) {
+        OrderCheckoutRefundIntent intent = checkout.selectRefundIntentForUpdate(intentId);
+        if (intent == null || !"PENDING".equals(intent.getStatus())) return;
+        if ("LINE".equals(intent.getChannel())) {
+            PosOrderLinePayment payment = linePayments.getByLineOrderId(intent.getMerchantOrderNo());
+            if (payment == null || !"PAID".equals(payment.getStatus())
+                    || !Objects.equals(payment.getTransactionId(), intent.getGatewayTransactionId())
+                    || !Objects.equals(payment.getAmount(), intent.getAmount())) {
+                throw new IllegalStateException("036 LINE refund intent does not match captured transaction");
+            }
+            lineRefunds.createRefundIntent(payment.getId(), "MULTI_STORE");
+        } else if ("OMG".equals(intent.getChannel())) {
+            OmgPaymentAttempt attempt = omgAttempts.selectByMerchantTradeNo(intent.getMerchantOrderNo());
+            if (attempt == null || !Integer.valueOf(1).equals(attempt.getAttemptStatus())
+                    || !Objects.equals(attempt.getTradeNo(), intent.getGatewayTransactionId())
+                    || !Objects.equals(attempt.getAmount(), intent.getAmount())) {
+                throw new IllegalStateException("036 OMG refund intent does not match captured transaction");
+            }
+            omgRefunds.createRefundIntentForAttempt(attempt.getId(), "MULTI_STORE");
+        } else {
+            throw new IllegalStateException("036 unsupported refund channel");
+        }
+        if (checkout.markRefundBridged(intentId) != 1) {
+            throw new IllegalStateException("036 refund intent changed while bridging");
+        }
+    }
+}

+ 26 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundBridgeTask.java

@@ -0,0 +1,26 @@
+package com.ruoyi.app.order;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/** 逐笔桥接到既有渠道退款任务;失败意图留在 PENDING,下次重试并报警。 */
+@Component("multiStoreCheckoutRefundBridgeTask")
+public class MultiStoreCheckoutRefundBridgeTask {
+    private static final Logger log = LoggerFactory.getLogger(MultiStoreCheckoutRefundBridgeTask.class);
+    private final MultiStoreCheckoutRefundBridgeService service;
+
+    public MultiStoreCheckoutRefundBridgeTask(MultiStoreCheckoutRefundBridgeService service) {
+        this.service = service;
+    }
+
+    public void bridgePendingIntents() {
+        for (Long intentId : service.pendingIds(200)) {
+            try {
+                service.bridge(intentId);
+            } catch (RuntimeException error) {
+                log.error("Bridge multi-store refund intent failed, intentId={}", intentId, error);
+            }
+        }
+    }
+}

+ 27 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundFollowupController.java

@@ -0,0 +1,27 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 平台订单权限内查看 036 逐交易退款状态;不暴露退款人工置成功入口。 */
+@RestController
+@RequestMapping("/system/multiStoreCheckout/admin")
+public class MultiStoreCheckoutRefundFollowupController {
+    private final MultiStoreCheckoutRefundFollowupService refunds;
+
+    public MultiStoreCheckoutRefundFollowupController(MultiStoreCheckoutRefundFollowupService refunds) {
+        this.refunds = refunds;
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:list')")
+    @GetMapping("/refunds")
+    public AjaxResult list(@RequestParam(defaultValue = "1") int page,
+                           @RequestParam(defaultValue = "20") int size,
+                           @RequestParam(defaultValue = "ALL") String status) {
+        return AjaxResult.success(refunds.list(page, size, status));
+    }
+}

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

@@ -0,0 +1,32 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreRefundFollowupPage;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.mapper.MultiStoreRefundFollowupMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Set;
+
+/** 平台退款跟进仅报告渠道真实状态,绝不提供手工修改到账状态的写接口。 */
+@Service
+public class MultiStoreCheckoutRefundFollowupService {
+    private static final Set<String> STATUSES = Set.of("ALL", "PENDING", "PROCESSING", "UNKNOWN",
+            "FAILED", "MANUAL_REVIEW", "REFUNDED");
+    private final MultiStoreRefundFollowupMapper mapper;
+
+    public MultiStoreCheckoutRefundFollowupService(MultiStoreRefundFollowupMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    @Transactional(readOnly = true)
+    public MultiStoreRefundFollowupPage list(int page, int size, String status) {
+        if (page < 1 || size < 1 || size > 100 || status == null || !STATUSES.contains(status)
+                || (long) (page - 1) * size > Integer.MAX_VALUE) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.invalid"));
+        }
+        return new MultiStoreRefundFollowupPage(mapper.select(status, (page - 1) * size, size),
+                mapper.count(status), page, size);
+    }
+}

+ 76 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRefundProjectionService.java

@@ -0,0 +1,76 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreRefundIntentView;
+import com.ruoyi.app.order.dto.MultiStoreRefundMemberView;
+import com.ruoyi.system.mapper.MultiStoreCheckoutRefundProjectionMapper;
+import com.ruoyi.system.mapper.MultiStoreCheckoutRefundProjectionRow;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** 036 列表和详情共用的只读退款投影;取消与退款完成是两件事。 */
+@Service
+public class MultiStoreCheckoutRefundProjectionService {
+    private final MultiStoreCheckoutRefundProjectionMapper mapper;
+
+    public MultiStoreCheckoutRefundProjectionService(MultiStoreCheckoutRefundProjectionMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    /** 按批次一次读取全部退款交易;无退款意图的到付成员不会出现在返回值中。 */
+    @Transactional(readOnly = true)
+    public Map<Long, MultiStoreRefundMemberView> project(Long batchId) {
+        if (batchId == null) throw new IllegalArgumentException("batchId required");
+        Map<Long, List<MultiStoreRefundIntentView>> grouped = new LinkedHashMap<>();
+        for (MultiStoreCheckoutRefundProjectionRow row : mapper.selectByBatchId(batchId)) {
+            if (row.getMemberId() == null || row.getIntentId() == null || row.getAmount() == null
+                    || row.getAmount() <= 0) throw new IllegalStateException("invalid 036 refund intent");
+            grouped.computeIfAbsent(row.getMemberId(), ignored -> new ArrayList<>())
+                    .add(new MultiStoreRefundIntentView(row.getIntentId(), row.getChannel(), row.getAmount(),
+                            status(row.getIntentStatus(), row.getGatewayStatus()), row.getGatewayStatus()));
+        }
+        Map<Long, MultiStoreRefundMemberView> result = new LinkedHashMap<>();
+        grouped.forEach((memberId, intents) -> {
+            int pendingAmount = 0;
+            int refundedAmount = 0;
+            String summary = "REFUNDED";
+            for (MultiStoreRefundIntentView intent : intents) {
+                if ("REFUNDED".equals(intent.status())) {
+                    refundedAmount = Math.addExact(refundedAmount, intent.amount());
+                } else {
+                    pendingAmount = Math.addExact(pendingAmount, intent.amount());
+                    if (priority(intent.status()) > priority(summary)) summary = intent.status();
+                }
+            }
+            result.put(memberId, new MultiStoreRefundMemberView(summary, pendingAmount,
+                    refundedAmount, List.copyOf(intents)));
+        });
+        return result;
+    }
+
+    private static String status(String intentStatus, String gatewayStatus) {
+        // 渠道事实优先;BRIDGED 只表示排队成功,缺少渠道行时结果必须保持未知。
+        if (gatewayStatus == null) return "PENDING".equals(intentStatus) ? "PENDING" : "UNKNOWN";
+        return switch (gatewayStatus) {
+            case "REFUNDED", "PROCESSING", "FAILED", "MANUAL_REVIEW", "UNKNOWN" -> gatewayStatus;
+            case "ACTION_UNKNOWN" -> "UNKNOWN";
+            case "CREATED", "RETRY_WAIT", "PENDING" -> "PENDING";
+            default -> "UNKNOWN";
+        };
+    }
+
+    private static int priority(String status) {
+        return switch (status) {
+            case "MANUAL_REVIEW" -> 5;
+            case "FAILED" -> 4;
+            case "UNKNOWN" -> 3;
+            case "PROCESSING" -> 2;
+            case "PENDING" -> 1;
+            default -> 0;
+        };
+    }
+}

+ 55 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutRules.java

@@ -2,6 +2,10 @@ package com.ruoyi.app.order;
 
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.LocalDate;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -58,7 +62,7 @@ public final class MultiStoreCheckoutRules {
         return distanceMeters(anchorLatitude, anchorLongitude, candidateLatitude, candidateLongitude) <= fenceMeters;
     }
 
-    /** 与围栏共用直线距离;运费仍按既有按公里计价规则单独计算。 */
+    /** 只供首店合并围栏使用;各店运费另按道路路线里程计算。 */
     public static double distanceMeters(double anchorLatitude, double anchorLongitude,
                                         double candidateLatitude, double candidateLongitude) {
         if (!validCoordinate(anchorLatitude, anchorLongitude)
@@ -77,4 +81,54 @@ public final class MultiStoreCheckoutRules {
         return Double.isFinite(latitude) && Double.isFinite(longitude)
                 && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
     }
+
+    /** 发起支付、换方式和到付确认只允许在 15 分钟操作截止之前。 */
+    public static boolean canStartPayment(LocalDateTime now, LocalDateTime operationDeadline) {
+        return now != null && operationDeadline != null && now.isBefore(operationDeadline);
+    }
+
+    /** 缓冲仅接受操作截止前实际发起的线上交易,不延长新支付的发起时间。 */
+    public static boolean canVerifyDispatchedPayment(LocalDateTime now, LocalDateTime dispatchedAt,
+                                                     LocalDateTime operationDeadline,
+                                                     LocalDateTime finalDeadline) {
+        return now != null && dispatchedAt != null && operationDeadline != null && finalDeadline != null
+                && !dispatchedAt.isAfter(now) && dispatchedAt.isBefore(operationDeadline)
+                && !now.isAfter(finalDeadline);
+    }
+
+    /** 最后一店就绪也必须检查预约结束时间;过期的预约批次不得自动转为即时单。 */
+    public static boolean canActivate(List<String> paymentStates, String deliveryWindow, LocalDateTime now) {
+        if (now == null || paymentStates == null || paymentStates.isEmpty()
+                || !paymentStates.stream().allMatch(state -> "ONLINE_PAID".equals(state)
+                        || "COD_CONFIRMED".equals(state))) {
+            return false;
+        }
+        if (deliveryWindow == null || deliveryWindow.isBlank()) return true;
+        try {
+            // 对齐普通外卖 delryTime 的空格分隔格式,预约开放扫描依赖此前16位可解析。
+            if (deliveryWindow.length() != 22 || deliveryWindow.charAt(10) != ' '
+                    || deliveryWindow.charAt(16) != '-') return false;
+            LocalDate date = LocalDate.parse(deliveryWindow.substring(0, 10));
+            LocalTime start = LocalTime.parse(deliveryWindow.substring(11, 16));
+            LocalTime end = LocalTime.parse(deliveryWindow.substring(17, 22));
+            return end.isAfter(start) && now.isBefore(date.atTime(end));
+        } catch (RuntimeException malformed) {
+            return false;
+        }
+    }
+
+    /** 未生效预约在窗口结束时即失效,不能等付款缓冲结束后再错误激活。 */
+    public static boolean appointmentExpired(String deliveryWindow, LocalDateTime now) {
+        if (deliveryWindow == null || deliveryWindow.isBlank() || now == null) return false;
+        try {
+            if (deliveryWindow.length() != 22 || deliveryWindow.charAt(10) != ' '
+                    || deliveryWindow.charAt(16) != '-') return false;
+            LocalDate date = LocalDate.parse(deliveryWindow.substring(0, 10));
+            LocalTime start = LocalTime.parse(deliveryWindow.substring(11, 16));
+            LocalTime end = LocalTime.parse(deliveryWindow.substring(17, 22));
+            return end.isAfter(start) && !now.isBefore(date.atTime(end));
+        } catch (RuntimeException malformed) {
+            return false;
+        }
+    }
 }

+ 138 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreDeliveryGroupAcceptanceService.java

@@ -0,0 +1,138 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.service.IRiderPositionService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/** 036 整组承接:同一骑手锁内按一个任务验资格,再在同一事务内分配全部有效子单。 */
+@Service
+public class MultiStoreDeliveryGroupAcceptanceService {
+    private static final long MAX_POSITION_AGE_MILLIS = 5 * 60_000L;
+    private final OrderCheckoutMapper checkout;
+    private final PosOrderMapper orders;
+    private final IRiderPositionService positions;
+    private final RiderDeliveryLockService riderLock;
+    private final RiderDeliveryAcceptanceService acceptance;
+
+    public MultiStoreDeliveryGroupAcceptanceService(OrderCheckoutMapper checkout, PosOrderMapper orders,
+                                                     IRiderPositionService positions,
+                                                     RiderDeliveryLockService riderLock,
+                                                     RiderDeliveryAcceptanceService acceptance) {
+        this.checkout = checkout;
+        this.orders = orders;
+        this.positions = positions;
+        this.riderLock = riderLock;
+        this.acceptance = acceptance;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public Long accept(Long groupId, Long version, Long riderId) {
+        if (groupId == null || version == null || riderId == null) throw changed();
+        return riderLock.withLock(riderId, () -> acceptLocked(groupId, version, riderId));
+    }
+
+    private Long acceptLocked(Long groupId, Long version, Long riderId) {
+        OrderDeliveryGroup discovered = checkout.selectGroupById(groupId);
+        if (discovered == null) throw changed();
+        // 固定锁顺序:批次 -> 配送组 -> 子单;取消/接组只能一方提交成功。
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(discovered.getBatchId());
+        OrderDeliveryGroup group = checkout.selectGroupByIdForUpdate(groupId);
+        if (batch == null || !"ACTIVE".equals(batch.getCheckoutStatus())
+                || group == null || !discovered.getBatchId().equals(group.getBatchId())) throw changed();
+        if ("ACCEPTED".equals(group.getStatus()) && riderId.equals(group.getRiderId())) {
+            // 网络重试只核对同一骑手已持有全部有效子单,不再消耗容量或重复更新订单。
+            List<OrderCheckoutMember> acceptedMembers = checkout.selectMembersByBatchId(batch.getId());
+            if (acceptedMembers == null || acceptedMembers.isEmpty()) throw changed();
+            for (OrderCheckoutMember member : acceptedMembers) {
+                PosOrder order = orders.selectById(member.getPosOrderId());
+                if (order == null || !Long.valueOf(4L).equals(order.getState())
+                        && !riderId.equals(order.getQsId())) throw changed();
+            }
+            return groupId;
+        }
+        if (!"WAITING".equals(group.getStatus()) || group.getRiderId() != null
+                || !version.equals(group.getVersion()) || group.getOpenedAt() == null
+                || group.getOpenedAt().isAfter(LocalDateTime.now())) throw changed();
+        List<OrderCheckoutMember> members = checkout.selectMembersByBatchId(batch.getId());
+        if (members == null || members.isEmpty()) throw changed();
+        List<PosOrder> active = new ArrayList<>();
+        PosOrder anchor = null;
+        for (OrderCheckoutMember member : members) {
+            PosOrder order = orders.selectById(member.getPosOrderId());
+            if (order == null) throw changed();
+            if (anchor == null) anchor = order;
+            if (Long.valueOf(4L).equals(order.getState())) continue;
+            if (!Long.valueOf(0L).equals(order.getState()) || !Long.valueOf(0L).equals(order.getType())
+                    || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                    || !Long.valueOf(1L).equals(order.getPayStatus())
+                    || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                    || !Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                    || Integer.valueOf(1).equals(order.getSelfDelivery())) throw changed();
+            active.add(order);
+        }
+        if (active.isEmpty() || batch.getMaxStoreCount() == null
+                || active.size() > batch.getMaxStoreCount()) throw changed();
+        acceptance.assertCanAcceptFood(riderId);
+        // 首店是创建时固定的合并锚点;即使首店后来合法取消,也不能改用最近的剩余门店。
+        assertRecentPositionWithinFirstStore(riderId, anchor);
+        LocalDateTime now = LocalDateTime.now();
+        if (checkout.acceptWaitingGroup(groupId, riderId, version, now) != 1) throw changed();
+        for (PosOrder order : active) {
+            if (checkout.acceptGroupOrder(order.getId(), riderId) != 1) throw changed();
+        }
+        return groupId;
+    }
+
+    private void assertRecentPositionWithinFirstStore(Long riderId, PosOrder first) {
+        RiderPosition position = positions.getOne(new QueryWrapper<RiderPosition>()
+                .eq("rider_id", riderId), false);
+        Date now = new Date();
+        if (position == null || position.getReportedAt() == null
+                || position.getLatitude() == null || position.getLongitude() == null
+                || position.getReportedAt().after(now)
+                || now.getTime() - position.getReportedAt().getTime() > MAX_POSITION_AGE_MILLIS) {
+            throw new ServiceException(MessageUtils.message("rider.accept.position.expired"));
+        }
+        int limitKm = distanceLimitKm();
+        if (first.getLatitude() == null || first.getLongitude() == null
+                || MultiStoreCheckoutRules.distanceMeters(position.getLatitude().doubleValue(),
+                position.getLongitude().doubleValue(), first.getLatitude().doubleValue(),
+                first.getLongitude().doubleValue()) > limitKm * 1000D) {
+            throw new ServiceException(MessageUtils.message("rider.accept.out.of.range"));
+        }
+    }
+
+    private int distanceLimitKm() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("sys_qs_newtask_distance");
+            if (values != null && !values.isEmpty()) {
+                int limit = Integer.parseInt(values.get(0).getDictValue());
+                if (limit > 0 && limit <= 100) return limit;
+            }
+        } catch (RuntimeException ignored) {
+            // 036 整组直连接口必须有有效距离上限,配置缺失时不能退化成不限距。
+        }
+        throw new ServiceException(MessageUtils.message("rider.accept.distance.unavailable"));
+    }
+
+    private ServiceException changed() {
+        return new ServiceException(MessageUtils.message("multi.store.checkout.group.changed"));
+    }
+}

+ 24 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -12,6 +12,7 @@ import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderLinePayment;
 import com.ruoyi.system.domain.PosOrderLineRefund;
 import com.ruoyi.system.domain.UserWallet;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosOrderLinePaymentService;
 import com.ruoyi.system.service.IPosOrderLineRefundService;
@@ -53,6 +54,8 @@ public class OrderLifecycleService {
     private final IPosOrderLineRefundService lineRefundService;
     private final RiderDeliveryLockService riderDeliveryLockService;
     private final RiderDeliveryAcceptanceService riderDeliveryAcceptanceService;
+    @Autowired(required = false)
+    private OrderCheckoutMapper checkout;
 
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
@@ -96,7 +99,9 @@ public class OrderLifecycleService {
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
-        return buildContext(requireOrder(id));
+        PosOrder order = requireOrder(id);
+        assertNotMultiStoreAdminMutation(order);
+        return buildContext(order);
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -106,6 +111,7 @@ public class OrderLifecycleService {
         if (order == null) {
             throw new ServiceException(lineMessage("line.pay.order.not.found", "LINE Pay order not found"));
         }
+        assertNotMultiStoreAdminMutation(order);
         if (!"3".equals(order.getPayType()) || Long.valueOf(3L).equals(order.getState())
                 || !Long.valueOf(1L).equals(order.getPayStatus())
                 || !Long.valueOf(0L).equals(order.getAfterSaleStatus())) {
@@ -168,6 +174,11 @@ public class OrderLifecycleService {
 
     private void acceptDeliveryByRiderLocked(Long id, Long riderId) {
         PosOrder order = requireOrder(id);
+        // 旧单笔入口不能拆抢036配送组;新组必须在同一骑手锁内一次分配全部有效子单。
+        if (checkout != null && order.getDdId() != null && order.getDdId().startsWith("MS")
+                && checkout.selectGroupIdByOrderId(id) != null) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.group.accept.required"));
+        }
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())
                 || !Long.valueOf(1L).equals(order.getPayStatus())
@@ -210,6 +221,7 @@ public class OrderLifecycleService {
                                                 Long operatorId, String operatorName) {
         requireReason(request);
         PosOrder order = requireOrder(id);
+        assertNotMultiStoreAdminMutation(order);
         validateSnapshot(order, request);
         rejectNormalAdjustmentWhenBlocked(order);
 
@@ -275,6 +287,7 @@ public class OrderLifecycleService {
                                                          Long operatorId, String operatorName) {
         requireReason(request);
         PosOrder order = requireOrder(id);
+        assertNotMultiStoreAdminMutation(order);
         validateSnapshot(order, request);
         if (!PAY_TYPE_OFFLINE.equals(order.getPayType())) {
             throw new ServiceException("仅线下支付订单可以确认收款,OMG 订单请使用支付补单");
@@ -306,6 +319,7 @@ public class OrderLifecycleService {
                                                         Long operatorId, String operatorName) {
         requireReason(request);
         PosOrder order = requireOrder(id);
+        assertNotMultiStoreAdminMutation(order);
         validateSnapshot(order, request);
         if (!PAY_TYPE_OFFLINE.equals(order.getPayType())) {
             throw new ServiceException("仅线下支付订单可以确认线下退款");
@@ -589,6 +603,15 @@ public class OrderLifecycleService {
                 safeName(operatorName), content);
     }
 
+    private void assertNotMultiStoreAdminMutation(PosOrder order) {
+        // 平台普通状态入口没有批次/组锁;036 成员只能走批次专用状态机。
+        if (checkout != null && order.getId() != null && order.getDdId() != null
+                && order.getDdId().startsWith("MS")
+                && checkout.selectBatchIdByOrderId(order.getId()) != null) {
+            throw new ServiceException(MessageUtils.message("multi.store.checkout.admin.read.only"));
+        }
+    }
+
     private AdminOrderStatusContext buildContext(PosOrder order) {
         AdminOrderStatusContext context = new AdminOrderStatusContext();
         context.setId(order.getId());

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -101,6 +101,8 @@ public class PosOrderController extends BaseController {
     @Autowired
     private IPosOrderService posOrderService;
     @Autowired
+    private MultiStoreCheckoutAdminQueryService multiStoreAdminQueries;
+    @Autowired
     private IInfoUserService infoUserService;
     @Autowired
     private IPosStoreService posStoreService;
@@ -999,6 +1001,8 @@ public class PosOrderController extends BaseController {
         QueryWrapper<PosOrder> queryWrapper = new QueryWrapper<>();
         queryWrapper.select().orderByDesc("cretim");
         queryWrapper.eq("user_id", id);
+        // 旧版用户列表按子单分页,不能把 036 组合批次拆成多张普通卡;新版改用组合卡接口。
+        queryWrapper.apply("NOT EXISTS (SELECT 1 FROM order_checkout_member m WHERE m.pos_order_id = pos_order.id)");
         queryWrapper.eq(!StringUtils.isEmpty(type),"type",type);
         if (!"".equals(state)) {
             if (state.equals("z23412")) {
@@ -1479,6 +1483,8 @@ public class PosOrderController extends BaseController {
                 }
             }
         }
+        // 仅装配当前页 036 真实成员关系;普通单保持原列表行和原分页总数。
+        multiStoreAdminQueries.enrichOrders(list);
         return getDataTable(list);
     }
 

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -443,6 +443,9 @@ public class PosOrderQsOprateController extends BaseController {
                                                          BigDecimal latitude, boolean withOrder) {
         LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(PosOrder::getIsDisplay,true);
+        // 036 多店子单的 parent_dd_id 指向批次父单;旧版单笔列表/角标不能拆组展示。
+        // 普通单的父单号等于自身单号,存量空父单号也保持原有可见性。
+        wrapper.apply("(parent_dd_id IS NULL OR parent_dd_id = dd_id)");
         switch (tab) {
             case "newTask":
                 wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L)

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -95,6 +95,8 @@ public class PosOrderShOprateController extends BaseController {
     private IUserBillingService userBillingService;
     @Autowired
     private MerchantStoreAccessService merchantStoreAccessService;
+    @Autowired
+    private MultiStoreCheckoutLegacyGuard multiStoreCheckoutLegacyGuard;
 
 
 
@@ -707,6 +709,8 @@ public class PosOrderShOprateController extends BaseController {
         if (order == null || order.getState() == null) {
             throw new ServiceException("订单不存在");
         }
+        // 036 子单取消须同步成员、配送组和退款,不能沿用旧单笔状态更新。
+        multiStoreCheckoutLegacyGuard.assertSingleCancelAllowed(order);
         if (order.getState() != 0L && order.getState() != 1L) {
             throw new ServiceException("当前状态不允许取消");
         }

+ 22 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/RiderDeliveryAcceptanceService.java

@@ -8,6 +8,7 @@ import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.flash.FlashDeliveryOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
 import com.ruoyi.system.mapper.flash.FlashDeliveryOrderMapper;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
@@ -37,13 +38,23 @@ public class RiderDeliveryAcceptanceService {
     private final IInfoUserService riders;
     private final IPosOrderService foodOrders;
     private final FlashDeliveryOrderMapper flashOrders;
+    private final OrderCheckoutMapper checkout;
 
     public RiderDeliveryAcceptanceService(IInfoUserService riders,
                                            IPosOrderService foodOrders,
                                            FlashDeliveryOrderMapper flashOrders) {
+        this(riders, foodOrders, flashOrders, null);
+    }
+
+    @org.springframework.beans.factory.annotation.Autowired
+    public RiderDeliveryAcceptanceService(IInfoUserService riders,
+                                           IPosOrderService foodOrders,
+                                           FlashDeliveryOrderMapper flashOrders,
+                                           OrderCheckoutMapper checkout) {
         this.riders = riders;
         this.foodOrders = foodOrders;
         this.flashOrders = flashOrders;
+        this.checkout = checkout;
     }
 
     /** 校验骑手能否再承接一笔外卖;进行中外卖和跨类型总量均受字典上限控制。 */
@@ -112,7 +123,17 @@ public class RiderDeliveryAcceptanceService {
                 .eq("rider_id", riderId)
                 .in("status", ACCEPTED, PICKED_UP)
                 .eq("delivery_type", URGENT);
-        return new ActiveTasks(safe(foodOrders.count(foodQuery)),
+        long rawFood = safe(foodOrders.count(foodQuery));
+        long duplicateGroupMembers = 0;
+        if (checkout != null && rawFood > 1) {
+            try {
+                duplicateGroupMembers = safe(checkout.countActiveGroupDuplicateMembers(riderId));
+            } catch (RuntimeException unavailable) {
+                // 036 迁移未执行或组表暂不可用时,保留普通订单的原始计数;只会更保守,不会放宽容量。
+                log.warn("Multi-store rider group count unavailable, keep raw food order count, riderId={}", riderId, unavailable);
+            }
+        }
+        return new ActiveTasks(rawFood - Math.min(rawFood, duplicateGroupMembers),
                 safe(flashOrders.selectCount(flashQuery)), safe(flashOrders.selectCount(urgentQuery)));
     }
 

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -103,6 +103,8 @@ public class UserOrderController extends BaseController {
     private SelfDeliveryService selfDeliveryService;
     @Autowired  //008 满额免运:完整配送报价(版本2订单权威重算)
     private FoodDeliveryPromotionQuoteService foodDeliveryPromotionQuoteService;
+    @Autowired
+    private MultiStoreCheckoutLegacyGuard multiStoreCheckoutLegacyGuard;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -622,6 +624,10 @@ public class UserOrderController extends BaseController {
     @Auth
     @Anonymous
     public AjaxResult getParentOrderDetail(@RequestHeader String token, @RequestParam String ddId){
+        // 旧接口未按 token 过滤父单;036 真实父单统一走带归属校验的新批次详情,避免旧版 App 越权读取。
+        if (multiStoreCheckoutLegacyGuard.isBatchParent(ddId)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
         OrderParent result = orderParentService.getOne(new LambdaQueryWrapper<OrderParent>().eq(OrderParent::getDdId,ddId));
         List<PosOrder> posOrders = posOrderService.list(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getParentDdId,ddId).orderBy(true,true,PosOrder::getCretim));
         result.setItems(posOrders);
@@ -758,6 +764,8 @@ public class UserOrderController extends BaseController {
         if (order == null) {
             return error("订单不存在");
         }
+        // 036 子单须走批次取消,旧单笔入口不能绕过退款与配送组锁。
+        multiStoreCheckoutLegacyGuard.assertSingleCancelAllowed(order);
         if (order.getState() == null || order.getState() != 0) {
             return error("仅待处理状态的订单可取消");
         }

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

@@ -0,0 +1,5 @@
+package com.ruoyi.app.order.dto;
+
+/** 多店未生效整批用户取消;客户端不能提交 userId 或退款结果。 */
+public record MultiStoreBatchCancelRequest(Long batchId, String reason, String clientRequestId) {
+}

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreateRequest.java

@@ -0,0 +1,8 @@
+package com.ruoyi.app.order.dto;
+
+import java.util.List;
+
+/** 客户端只确认参与集合;商品、费用和支付资格由报价快照及服务端重算决定。 */
+public record MultiStoreBatchCreateRequest(String requestKey, String quoteToken,
+                                           List<Long> participatingStoreIds) {
+}

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

@@ -0,0 +1,13 @@
+package com.ruoyi.app.order.dto;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 独立批次结果;PENDING 不表示任何子单已付款或可履约。 */
+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,
+                         List<String> availablePayTypes) {
+    }
+}

+ 52 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreCardView.java

@@ -0,0 +1,52 @@
+package com.ruoyi.app.order.dto;
+
+import lombok.Data;
+import com.ruoyi.system.domain.PosOrder;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/** 普通单、用户批次和骑手组共用的只读卡片。 */
+@Data
+public class MultiStoreCardView {
+    private String cardType;
+    private String cardKey;
+    private Long orderId;
+    /** 普通单沿用原骑手/用户列表的完整订单字段,不把旧单强行压缩为 036 子单摘要。 */
+    private PosOrder legacyOrder;
+    private Long batchId;
+    private Long groupId;
+    private Long version;
+    private String parentOrderNo;
+    private String orderNo;
+    private Long storeId;
+    private String storeName;
+    private Long orderState;
+    private Long deliveryStatus;
+    private String payType;
+    private String checkoutStatus;
+    private String groupStatus;
+    private String primaryTab;
+    private String refundSummary;
+    private int memberCount;
+    private int activeCount;
+    private int readyCount;
+    private int pickedUpCount;
+    private int deliveredCount;
+    private int cancelledCount;
+    private Integer totalAmount;
+    private int onlinePaidAmount;
+    private int codPromisedAmount;
+    private int refundPendingAmount;
+    private int refundedAmount;
+    private int discountedFreightTotal;
+    private int codToCollectAmount;
+    private LocalDateTime createdAt;
+    private LocalDateTime operationDeadlineAt;
+    private LocalDateTime finalDeadlineAt;
+    private LocalDateTime activatedAt;
+    private LocalDateTime openedAt;
+    private Double anchorDistanceMeters;
+    private List<MultiStoreMemberView> members = List.of();
+    private List<String> allowedActions = List.of();
+}

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

@@ -0,0 +1,5 @@
+package com.ruoyi.app.order.dto;
+
+/** 036 整组接单入参;坐标和骑手身份由服务端取得,不接受客户端代填。 */
+public record MultiStoreGroupAcceptRequest(Long groupId, Long version, String clientRequestId) {
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberCancelRequest.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.order.dto;
+
+/** 生效后仅取消指定子单;所属批次与用户身份在服务端重验。 */
+public record MultiStoreMemberCancelRequest(Long batchId, Long orderId, String reason,
+                                            String clientRequestId) {
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberPaymentRequest.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.order.dto;
+
+/** 到付确认或线上方式选择;线上真实付款仍须走对应网关创建与核实。 */
+public record MultiStoreMemberPaymentRequest(Long batchId, Long orderId, String payType,
+                                             String clientRequestId) {
+}

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMemberView.java

@@ -0,0 +1,24 @@
+package com.ruoyi.app.order.dto;
+
+import lombok.Data;
+import java.util.List;
+
+/** 组合卡逐店摘要;不承载待抢阶段的用户联系人或地址。 */
+@Data
+public class MultiStoreMemberView {
+    private Long orderId;
+    private String orderNo;
+    private Long storeId;
+    private String storeName;
+    private String payType;
+    private String paymentState;
+    private Integer orderAmount;
+    private Integer discountedFreight;
+    private Long deliveryStatus;
+    private Long orderState;
+    private String cancelStatus;
+    private String refundStatus;
+    private Integer codToCollectAmount;
+    private Integer codToMerchantAmount;
+    private List<String> allowedActions = List.of();
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreMerchantCancelRequest.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.order.dto;
+
+/** 商家因无法供餐取消本店已接组、未取餐的子单。 */
+public record MultiStoreMerchantCancelRequest(Long batchId, Long orderId, String reason,
+                                              String clientRequestId) {
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStorePageView.java

@@ -0,0 +1,7 @@
+package com.ruoyi.app.order.dto;
+
+import java.util.List;
+
+/** 与现有骑手分页响应同形状;total 为卡片数。 */
+public record MultiStorePageView(List<MultiStoreCardView> records, long total, int current, int size) {
+}

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

@@ -9,7 +9,8 @@ public record MultiStoreQuoteView(String quoteToken, long expiresAtEpochMillis,
                                   List<StorePrice> stores, List<ExcludedStore> excludedStores,
                                   BigDecimal freightRatio, String fingerprint) {
     public record StorePrice(Long storeId, BigDecimal originalFoodAmount, BigDecimal foodAmount, BigDecimal originalFreight,
-                             BigDecimal discountedFreight, BigDecimal amount,
+                             BigDecimal discountedFreight, BigDecimal customerDeliveryFee,
+                             BigDecimal merchantDeliverySubsidy, BigDecimal amount,
                              List<String> availablePayTypes) {
     }
 

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreRefundFollowupPage.java

@@ -0,0 +1,10 @@
+package com.ruoyi.app.order.dto;
+
+import com.ruoyi.system.domain.MultiStoreRefundFollowupRow;
+
+import java.util.List;
+
+/** 平台待退款/未知/人工核对列表,total 以退款意图笔数计。 */
+public record MultiStoreRefundFollowupPage(List<MultiStoreRefundFollowupRow> records,
+                                           long total, int current, int size) {
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreRefundIntentView.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.order.dto;
+
+/** 单笔实收的退款展示;status 只由渠道事实投影,绝不暴露 BRIDGED 为成功。 */
+public record MultiStoreRefundIntentView(Long intentId, String channel, int amount,
+                                         String status, String gatewayStatus) {
+}

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreRefundMemberView.java

@@ -0,0 +1,8 @@
+package com.ruoyi.app.order.dto;
+
+import java.util.List;
+
+/** 子单退款汇总;pendingAmount 包含待处理、失败、未知及人工复核金额。 */
+public record MultiStoreRefundMemberView(String status, int pendingAmount, int refundedAmount,
+                                         List<MultiStoreRefundIntentView> intents) {
+}

+ 142 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/route/GoogleRouteDistanceMatrixProvider.java

@@ -0,0 +1,142 @@
+package com.ruoyi.app.order.route;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+
+/** Google N×1 路线矩阵:每阶段一次 HTTP 请求,按门店分别返回道路距离。 */
+@Component
+public class GoogleRouteDistanceMatrixProvider implements RouteDistanceMatrixProvider {
+    private static final String ENDPOINT = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix";
+    private static final int MAX_ORIGINS = 625;
+    private static final RequestConfig TIMEOUTS = RequestConfig.custom()
+            .setConnectTimeout(2500).setConnectionRequestTimeout(2500).setSocketTimeout(3500).build();
+
+    @Override
+    public List<BigDecimal> distancesToDestination(List<GeoPoint> origins, GeoPoint destination) {
+        JSONObject body = requestBody(origins, destination);
+        // 沿用服务端地图字典,不把密钥写进客户端参数、URL或日志,也不复制旧硬编码密钥。
+        List<SysDictData> keys = DictUtils.getDictCache("sys_googlemap_key");
+        if (keys == null || keys.isEmpty() || keys.get(0) == null
+                || keys.get(0).getDictValue() == null || keys.get(0).getDictValue().isBlank()) {
+            throw new IllegalStateException("google route matrix key unavailable");
+        }
+        HttpPost request = new HttpPost(ENDPOINT);
+        request.setConfig(TIMEOUTS);
+        request.setHeader("X-Goog-Api-Key", keys.get(0).getDictValue());
+        // status 必须请求,否则缺少错误字段会让失败元素看起来像成功;不拉取路线图等无关数据。
+        request.setHeader("X-Goog-FieldMask", "originIndex,destinationIndex,status,condition,distanceMeters");
+        request.setEntity(new StringEntity(body.toJSONString(), ContentType.APPLICATION_JSON));
+        // 不自动重试或拆回单点请求,避免一次报价产生不受控的额外计费与延迟。
+        try (CloseableHttpClient client = HttpClients.custom().disableAutomaticRetries().build();
+             CloseableHttpResponse response = client.execute(request)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300) {
+                throw new IllegalStateException("google route matrix http status " + status);
+            }
+            return parseResponse(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8), origins.size());
+        } catch (IllegalStateException failure) {
+            throw failure;
+        } catch (Exception failure) {
+            // 不传播完整上游响应、坐标或请求头;入口按既有本地化错误提示路线不可用。
+            throw new IllegalStateException("google route matrix request failed");
+        }
+    }
+
+    JSONObject requestBody(List<GeoPoint> origins, GeoPoint destination) {
+        if (origins == null || origins.isEmpty() || origins.size() > MAX_ORIGINS) {
+            throw new IllegalArgumentException("invalid route matrix origins");
+        }
+        validatePoint(destination);
+        JSONArray starts = new JSONArray();
+        for (GeoPoint origin : origins) {
+            validatePoint(origin);
+            starts.add(waypoint(origin));
+        }
+        JSONObject body = new JSONObject();
+        // 有向道路可能有单行/绕行:必须门店→用户,不能将用户→门店的距离反用。
+        // 坐标相同的两个门店仍占两个起点位置,不能去重后打乱门店金额对应关系。
+        body.put("origins", starts);
+        body.put("destinations", List.of(waypoint(destination)));
+        body.put("travelMode", "DRIVE");
+        body.put("routingPreference", "TRAFFIC_UNAWARE");
+        return body;
+    }
+
+    List<BigDecimal> parseResponse(String response, int originCount) {
+        try {
+            JSONArray elements = JSON.parseArray(response);
+            if (originCount < 1 || originCount > MAX_ORIGINS
+                    || elements == null || elements.size() != originCount) {
+                throw new IllegalStateException();
+            }
+            BigDecimal[] distances = new BigDecimal[originCount];
+            for (Object value : elements) {
+                if (!(value instanceof JSONObject element)) throw new IllegalStateException();
+                int origin = integerOrZero(element, "originIndex");
+                int destination = integerOrZero(element, "destinationIndex");
+                JSONObject status = element.getJSONObject("status");
+                if (origin < 0 || origin >= originCount || destination != 0 || distances[origin] != null
+                        || (status != null && integerOrZero(status, "code") != 0)
+                        || !"ROUTE_EXISTS".equals(element.getString("condition"))
+                        || !(element.get("distanceMeters") instanceof Number)) {
+                    throw new IllegalStateException();
+                }
+                int meters = element.getBigDecimal("distanceMeters").intValueExact();
+                if (meters <= 0) throw new IllegalStateException();
+                // Google 可乱序返回,按索引归位;缺项/重复/部分失败都不能悄悄变成免费运费。
+                distances[origin] = BigDecimal.valueOf(meters);
+            }
+            if (Arrays.stream(distances).anyMatch(distance -> distance == null)) throw new IllegalStateException();
+            return List.copyOf(Arrays.asList(distances));
+        } catch (RuntimeException failure) {
+            throw new IllegalStateException("google route matrix invalid response");
+        }
+    }
+
+    /** protobuf JSON 可省略默认0的索引和状态码;其他数值必须精确为整数,不能截断小数。 */
+    private int integerOrZero(JSONObject element, String name) {
+        Object value = element.get(name);
+        if (value == null) return 0;
+        if (!(value instanceof Number)) throw new IllegalStateException();
+        return element.getBigDecimal(name).intValueExact();
+    }
+
+    private JSONObject waypoint(GeoPoint point) {
+        JSONObject latLng = new JSONObject();
+        latLng.put("latitude", point.latitude());
+        latLng.put("longitude", point.longitude());
+        JSONObject location = new JSONObject();
+        location.put("latLng", latLng);
+        JSONObject waypoint = new JSONObject();
+        waypoint.put("location", location);
+        JSONObject result = new JSONObject();
+        result.put("waypoint", waypoint);
+        return result;
+    }
+
+    private void validatePoint(GeoPoint point) {
+        if (point == null || point.latitude() == null || point.longitude() == null
+                || point.latitude().abs().compareTo(new BigDecimal("90")) > 0
+                || point.longitude().abs().compareTo(new BigDecimal("180")) > 0) {
+            throw new IllegalArgumentException("invalid route matrix coordinate");
+        }
+    }
+}

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/route/RouteDistanceMatrixProvider.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.order.route;
+
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 外卖报价的多起点、单终点道路距离接口;与闪送的整条多站路线含义不同。 */
+@FunctionalInterface
+public interface RouteDistanceMatrixProvider {
+    /**
+     * 返回每个起点到同一终点的正整数米,结果顺序、数量必须与 origins 完全一致。
+     * 任一元素不可用即抛错,由报价入口转为业务错误;不返回部分距离或直线兜底值。
+     */
+    List<BigDecimal> distancesToDestination(List<GeoPoint> origins, GeoPoint destination);
+}

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.pay;
 
 import com.ruoyi.app.pay.dto.LinePayOrderRequest;
+import com.ruoyi.app.order.MultiStoreCheckoutAppCapability;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -51,10 +52,16 @@ public class LinePayController {
     @Auth
     @PostMapping("/create")
     public AjaxResult create(@RequestHeader String token,
+                             @RequestHeader(value = MultiStoreCheckoutAppCapability.HEADER, required = false)
+                             String capability,
                              @RequestBody(required = false) LinePayOrderRequest request) {
         if (request == null || request.getDdId() == null || request.getDdId().trim().isEmpty()) {
             return AjaxResult.error(MessageUtils.message("line.pay.order.required"));
         }
+        // 旧 App 仍可支付普通单,但不得绕过 036 专用收银入口继续支付组合子单。
+        if (request.getDdId().trim().startsWith("MS") && !MultiStoreCheckoutAppCapability.supports(capability)) {
+            return MultiStoreCheckoutAppCapability.upgradeRequired();
+        }
         try {
             Long userId = Long.valueOf(new JwtUtil().getusid(token));
             return AjaxResult.success(linePayService.create(userId, request.getDdId().trim()));

+ 23 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java

@@ -11,6 +11,7 @@ import com.ruoyi.system.service.IPosOrderLineRefundService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.order.MultiStoreCheckoutOnlineFactService;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -25,6 +26,7 @@ public class LinePayFactService {
     private final PosOrderMapper orderMapper;
     private final LinePayOrderNotificationService notificationService;
     private final OrderLifecycleService orderLifecycleService;
+    private final MultiStoreCheckoutOnlineFactService multiStoreFacts;
 
     public LinePayFactService(IPosOrderService orderService,
                               IPosOrderLinePaymentService paymentService,
@@ -39,19 +41,31 @@ public class LinePayFactService {
         this(orderService, paymentService, refundService, orderMapper, null, null);
     }
 
-    @org.springframework.beans.factory.annotation.Autowired
     public LinePayFactService(IPosOrderService orderService,
                               IPosOrderLinePaymentService paymentService,
                               IPosOrderLineRefundService refundService,
                               PosOrderMapper orderMapper,
                               LinePayOrderNotificationService notificationService,
                               OrderLifecycleService orderLifecycleService) {
+        this(orderService, paymentService, refundService, orderMapper, notificationService,
+                orderLifecycleService, null);
+    }
+
+    @org.springframework.beans.factory.annotation.Autowired
+    public LinePayFactService(IPosOrderService orderService,
+                              IPosOrderLinePaymentService paymentService,
+                              IPosOrderLineRefundService refundService,
+                              PosOrderMapper orderMapper,
+                              LinePayOrderNotificationService notificationService,
+                              OrderLifecycleService orderLifecycleService,
+                              MultiStoreCheckoutOnlineFactService multiStoreFacts) {
         this.orderService = orderService;
         this.paymentService = paymentService;
         this.refundService = refundService;
         this.orderMapper = orderMapper;
         this.notificationService = notificationService;
         this.orderLifecycleService = orderLifecycleService;
+        this.multiStoreFacts = multiStoreFacts;
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -67,6 +81,8 @@ public class LinePayFactService {
         if (payment == null) {
             throw new ServiceException("LINE Pay payment not found");
         }
+        // 036 回调先锁批次再锁支付/子单;旧单没有 036 成员关联,完全沿原路径。
+        boolean multiStore = multiStoreFacts != null && multiStoreFacts.lockBatchIfAny(payment.getDdId());
         int marked = paymentService.markPaid(paymentId, payment.getVersion(), payment.getStatus(),
                 transactionId, paymentProvider, payTime);
         if (marked != 1 && !"PAID".equals(payment.getStatus())) {
@@ -79,12 +95,15 @@ public class LinePayFactService {
         }
         if (orderMapper != null) {
             int orderMarked = orderMapper.markLinePaid(payment.getDdId(), payment.getId());
+            String batchOutcome = multiStore ? multiStoreFacts.recordPaid(order.getId(), "LINE", "3",
+                    payment.getLineOrderId(), transactionId, order.getAmount(), orderMarked == 1) : "NOT_BATCH";
             if (orderMarked == 1 && !Long.valueOf(4L).equals(order.getState())
-                    && notifyOrder && notificationService != null) {
+                    && !multiStore && notifyOrder && notificationService != null
+                    && !"REFUND_REQUIRED".equals(batchOutcome)) {
                 order.setPayStatus(1L);
                 notificationService.paymentCaptured(order);
             } else if (orderMarked != 1 && !Long.valueOf(4L).equals(order.getState())
-                    && !isOnlyRecordedLinePayment(order, payment)) {
+                    && !multiStore && !isOnlyRecordedLinePayment(order, payment)) {
                 refundService.createIfAbsent(payment, "DUPLICATE_PAYMENT");
             }
         } else {
@@ -94,7 +113,7 @@ public class LinePayFactService {
         }
         payment.setStatus("PAID");
         payment.setTransactionId(transactionId);
-        if (Long.valueOf(4L).equals(order.getState())) {
+        if (Long.valueOf(4L).equals(order.getState()) && !multiStore) {
             refundService.createIfAbsent(payment, "TASK");
         }
     }

+ 32 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONArray;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.ruoyi.app.pay.dto.LinePayCreateResult;
 import com.ruoyi.app.pay.dto.LinePayQueryResult;
+import com.ruoyi.app.order.MultiStoreCheckoutOnlineFactService;
 import com.ruoyi.app.utils.linepay.LinePayClient;
 import com.ruoyi.app.utils.linepay.LinePayCredential;
 import com.ruoyi.app.utils.linepay.LinePayRequest;
@@ -55,6 +56,7 @@ public class LinePayService {
     private final LinePayGatewayAuditService auditService;
     private final PaymentCreateGuardService createGuard;
     private final LinePayOfflineService offlineService;
+    private final MultiStoreCheckoutOnlineFactService multiStoreFacts;
 
     public LinePayService(IPosOrderService orderService,
                           IPosStoreLinePayService credentialService,
@@ -63,10 +65,9 @@ public class LinePayService {
                           LinePayClient linePayClient,
                           LinePayFactService factService) {
         this(orderService, credentialService, paymentService, refundService,
-                linePayClient, factService, null, null, null);
+                linePayClient, factService, null, null, null, null);
     }
 
-    @Autowired
     public LinePayService(IPosOrderService orderService,
                           IPosStoreLinePayService credentialService,
                           IPosOrderLinePaymentService paymentService,
@@ -76,6 +77,21 @@ public class LinePayService {
                           LinePayGatewayAuditService auditService,
                           PaymentCreateGuardService createGuard,
                           LinePayOfflineService offlineService) {
+        this(orderService, credentialService, paymentService, refundService, linePayClient, factService,
+                auditService, createGuard, offlineService, null);
+    }
+
+    @Autowired
+    public LinePayService(IPosOrderService orderService,
+                          IPosStoreLinePayService credentialService,
+                          IPosOrderLinePaymentService paymentService,
+                          IPosOrderLineRefundService refundService,
+                          LinePayClient linePayClient,
+                          LinePayFactService factService,
+                          LinePayGatewayAuditService auditService,
+                          PaymentCreateGuardService createGuard,
+                          LinePayOfflineService offlineService,
+                          MultiStoreCheckoutOnlineFactService multiStoreFacts) {
         this.orderService = orderService;
         this.credentialService = credentialService;
         this.paymentService = paymentService;
@@ -85,6 +101,7 @@ public class LinePayService {
         this.auditService = auditService;
         this.createGuard = createGuard;
         this.offlineService = offlineService;
+        this.multiStoreFacts = multiStoreFacts;
     }
 
     public LinePayCreateResult create(Long userId, String ddId) {
@@ -96,7 +113,7 @@ public class LinePayService {
 
     private LinePayCreateResult createUnderLock(Long userId, String ddId) {
         PosOrder order = requirePayableOrder(userId, ddId);
-        requireSingleStoreOrder(order);
+        requireSingleStoreOrder(order, userId);
         PosStoreLinePay credential = credentialService.getEnabledCurrent(order.getMdId());
         if (credential == null) {
             throw new ServiceException("LINE Pay is not enabled for this store");
@@ -111,6 +128,10 @@ public class LinePayService {
         if (!selection.created()) {
             return createResult(intent, true);
         }
+        if (multiStoreFacts != null && isMultiStoreCandidate(order)) {
+            // 在实际调用 LINE Request API 前持久化发起证据,缓冲期只承认此时点已发出的交易。
+            multiStoreFacts.recordDispatch(order.getId(), "LINE", PAY_TYPE_LINE, intent.getLineOrderId());
+        }
         try {
             LinePayResponse response = gatewayCall("REQUEST", "APP", intent,
                     () -> linePayClient.request(toCredential(credential),
@@ -573,15 +594,21 @@ public class LinePayService {
         return order;
     }
 
-    private void requireSingleStoreOrder(PosOrder order) {
+    private void requireSingleStoreOrder(PosOrder order, Long userId) {
         String parentDdId = order.getParentDdId() == null
                 ? String.valueOf(order.getDdId()) : order.getParentDdId();
         long count = orderService.count(new QueryWrapper<PosOrder>().eq("parent_dd_id", parentDdId));
-        if (count > 1) {
+        if (count > 1 && (multiStoreFacts == null || !isMultiStoreCandidate(order)
+                || !multiStoreFacts.authorizeCreate(String.valueOf(order.getDdId()), userId, PAY_TYPE_LINE))) {
             throw new ServiceException("LINE Pay supports single-store orders only");
         }
     }
 
+    private boolean isMultiStoreCandidate(PosOrder order) {
+        return order != null && order.getDdId() != null && String.valueOf(order.getDdId()).startsWith("MS")
+                && order.getParentDdId() != null && !order.getParentDdId().equals(order.getDdId());
+    }
+
     private PosOrderLinePayment selectForApp(PosOrder order, List<PosOrderLinePayment> attempts) {
         if (attempts == null || attempts.isEmpty()) {
             return null;

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.user;
 
 import java.util.List;
 import java.util.Objects;
+import java.util.Date;
 import jakarta.servlet.http.HttpServletResponse;
 
 import com.alibaba.fastjson.JSONObject;
@@ -67,6 +68,7 @@ public class RiderPositionController extends BaseController
             rider.setRiderId(Long.valueOf(id));
             rider.setLatitude(riderPosition.getLatitude());
             rider.setLongitude(riderPosition.getLongitude());
+            rider.setReportedAt(new Date());
             if(user.getCid()!=null && !Objects.equals(user.getCid(), "")){
                 rider.setCid(user.getCid());
             }
@@ -90,6 +92,7 @@ public class RiderPositionController extends BaseController
             rider.setRiderId(Long.valueOf(id));
             rider.setLatitude(riderPosition.getLatitude());
             rider.setLongitude(riderPosition.getLongitude());
+            rider.setReportedAt(new Date());
             if(user.getCid()!=null && !Objects.equals(user.getCid(), "")){
                 rider.setCid(user.getCid());
             }

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

@@ -463,8 +463,11 @@ stall.owner.name.exist=Tên đăng nhập đã tồn tại
 multi.store.config.invalid=Cấu hình thanh toán nhiều cửa hàng không hợp lệ
 multi.store.config.conflict=Cấu hình đã thay đổi hoặc chưa được khởi tạo. Vui lòng làm mới hoặc thực hiện bản cập nhật cơ sở dữ liệu 036
 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.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
 multi.store.checkout.group.changed=Trạng thái nhóm giao hàng đã thay đổi, vui lòng tải lại
 rider.accept.position.expired=Vị trí tài xế đã cũ, vui lòng cập nhật vị trí
 rider.accept.out.of.range=Quá xa cửa hàng đầu tiên để nhận đơn

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

@@ -449,8 +449,11 @@ stall.owner.name.exist=Username already exists
 multi.store.config.invalid=Invalid multi-store checkout configuration
 multi.store.config.conflict=Configuration changed or is not initialized. Refresh or apply database migration 036
 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.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
 multi.store.checkout.group.changed=Delivery group changed; refresh and try again
 rider.accept.position.expired=Rider location is stale; update your location
 rider.accept.out.of.range=Too far from the first store to accept this task

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

@@ -450,8 +450,11 @@ stall.owner.name.exist=ชื่อผู้ใช้นี้มีอยู่
 multi.store.config.invalid=การตั้งค่าการชำระเงินหลายร้านค้าไม่ถูกต้อง
 multi.store.config.conflict=การตั้งค่าเปลี่ยนแปลงหรือยังไม่ได้เริ่มต้น โปรดรีเฟรชหรือใช้การย้ายฐานข้อมูล 036
 multi.store.checkout.disabled=ยังไม่เปิดใช้การชำระเงินหลายร้าน
+multi.store.checkout.upgrade.required=โปรดอัปเดตแอปเพื่อใช้การชำระเงินหลายร้านและรับงานจัดส่งเป็นกลุ่ม
 multi.store.checkout.invalid=คำขอชำระเงินหลายร้านไม่ถูกต้องหรือมีร้านที่เข้าร่วมน้อยเกินไป
 multi.store.checkout.group.accept.required=ต้องรับงานจัดส่งหลายร้านทั้งกลุ่มพร้อมกัน
+multi.store.checkout.batch.cancel.required=โปรดยกเลิกผ่านขั้นตอนคำสั่งซื้อหลายร้าน
+multi.store.checkout.admin.read.only=โปรดจัดการสถานะคำสั่งซื้อหลายร้านผ่านขั้นตอนของชุดคำสั่งซื้อ
 multi.store.checkout.group.changed=สถานะกลุ่มจัดส่งเปลี่ยนไป โปรดรีเฟรชแล้วลองอีกครั้ง
 rider.accept.position.expired=ตำแหน่งไรเดอร์หมดอายุ โปรดอัปเดตตำแหน่ง
 rider.accept.out.of.range=อยู่ไกลจากร้านแรกเกินไป ไม่สามารถรับงานได้

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

@@ -18,3 +18,6 @@ promotion.freeDelivery.quoteChanged=Báo giá đã thay đổi, vui lòng lấy
 promotion.freeDelivery.invalidSnapshot=Dữ liệu ưu đãi của đơn hàng không đầy đủ, không thể xử lý tiếp
 promotion.freeDelivery.configConflict=Cấu hình miễn phí vận chuyển của cửa hàng bị xung đột, vui lòng liên hệ quản lý
 promotion.freeDelivery.duplicateOrder=Đơn hàng đã được gửi, vui lòng không đặt hàng lặp lại
+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
+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

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

@@ -451,8 +451,11 @@ stall.owner.name.exist=用户名已存在
 multi.store.config.invalid=多店结算配置参数不合法
 multi.store.config.conflict=配置已变化或尚未初始化,请刷新或先执行 036 数据库迁移
 multi.store.checkout.disabled=多店结算暂未开放
+multi.store.checkout.upgrade.required=请更新应用后使用多店结算和配送组功能
 multi.store.checkout.invalid=多店结算请求无效或参与店铺不足
 multi.store.checkout.group.accept.required=多店配送组必须整组接单
+multi.store.checkout.batch.cancel.required=请通过多店批次流程取消订单
+multi.store.checkout.admin.read.only=多店子单不能在普通订单状态入口修改,请通过批次流程处理
 multi.store.checkout.group.changed=配送组状态已变化,请刷新后重试
 rider.accept.position.expired=骑手位置已过期,请更新定位
 rider.accept.out.of.range=距离首店过远,无法接单

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

@@ -450,8 +450,11 @@ stall.owner.name.exist=用戶名已存在
 multi.store.config.invalid=多店結算設定參數不合法
 multi.store.config.conflict=設定已變更或尚未初始化,請重新整理或先執行 036 資料庫遷移
 multi.store.checkout.disabled=多店結算暫未開放
+multi.store.checkout.upgrade.required=請更新應用程式後使用多店結算與配送組功能
 multi.store.checkout.invalid=多店結算請求無效或參與店鋪不足
 multi.store.checkout.group.accept.required=多店配送組必須整組接單
+multi.store.checkout.batch.cancel.required=請透過多店批次流程取消訂單
+multi.store.checkout.admin.read.only=多店子單不能在一般訂單狀態入口修改,請透過批次流程處理
 multi.store.checkout.group.changed=配送組狀態已變更,請重新整理後再試
 rider.accept.position.expired=騎手位置已過期,請更新定位
 rider.accept.out.of.range=距離首店過遠,無法接單

+ 25 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java

@@ -46,10 +46,12 @@ class OmgPaymentControllerTest {
     void exposesOnlyExplicitCreateContract() throws Exception {
         RequestMapping mapping = OmgPaymentController.class.getAnnotation(RequestMapping.class);
         assertArrayEquals(new String[]{"/pay/omg"}, mapping.value());
-        Method create = OmgPaymentController.class.getDeclaredMethod("create", String.class, OmgCreatePaymentRequest.class);
+        Method create = OmgPaymentController.class.getDeclaredMethod("create", String.class,
+                String.class, OmgCreatePaymentRequest.class);
         assertArrayEquals(new String[]{"/create"}, create.getAnnotation(PostMapping.class).value());
         assertEquals("token", create.getParameters()[0].getAnnotation(RequestHeader.class).name());
-        assertNotNull(create.getParameters()[1].getAnnotation(RequestBody.class));
+        assertNotNull(create.getParameters()[1].getAnnotation(RequestHeader.class));
+        assertNotNull(create.getParameters()[2].getAnnotation(RequestBody.class));
         assertEquals(Set.of("orderId", "paymentMethod"), java.util.Arrays.stream(OmgCreatePaymentRequest.class.getDeclaredFields())
                 .map(Field::getName).collect(Collectors.toSet()));
         assertTrue(java.util.Arrays.stream(create.getParameterTypes()).noneMatch(Map.class::isAssignableFrom));
@@ -69,7 +71,7 @@ class OmgPaymentControllerTest {
         request.setPaymentMethod("CREDIT");
         ListAppender<ILoggingEvent> logs = captureLogs();
 
-        AjaxResult result = controller.create("SECRET_TOKEN", request);
+        AjaxResult result = controller.create("SECRET_TOKEN", null, request);
 
         assertSame(response, result.get("data"));
         verify(service).create(5L, "DD-1\r\nforged=true", OmgPaymentMethod.CREDIT);
@@ -88,7 +90,7 @@ class OmgPaymentControllerTest {
         OmgPaymentCreateService service = mock(OmgPaymentCreateService.class);
         when(resolver.requireUserId(anyString())).thenReturn(5L);
         OmgPaymentController controller = controller(resolver, service);
-        AjaxResult business = controller.create("token", null);
+        AjaxResult business = controller.create("token", null, null);
         assertEquals("PAYMENT_METHOD_INVALID", ((OmgPaymentErrorResponse) business.get("data")).status());
 
         RuntimeException failure = new RuntimeException("unexpected");
@@ -97,7 +99,7 @@ class OmgPaymentControllerTest {
         request.setOrderId("DD-2");
         request.setPaymentMethod("CREDIT");
         ListAppender<ILoggingEvent> logs = captureLogs();
-        AjaxResult unexpected = controller.create("token", request);
+        AjaxResult unexpected = controller.create("token", null, request);
         assertEquals("PAYMENT_CREATION_FAILED", ((OmgPaymentErrorResponse) unexpected.get("data")).status());
         assertTrue(logs.list.stream().anyMatch(event -> event.getThrowableProxy() != null
                 && event.getThrowableProxy().getMessage().equals("unexpected")));
@@ -114,7 +116,7 @@ class OmgPaymentControllerTest {
             request.setOrderId("DD-1");
             request.setPaymentMethod("CREDIT");
 
-            AjaxResult result = controller(resolver, service).create("token", request);
+            AjaxResult result = controller(resolver, service).create("token", null, request);
 
             assertEquals(code.name(), ((OmgPaymentErrorResponse) result.get("data")).status());
         }
@@ -128,7 +130,7 @@ class OmgPaymentControllerTest {
         OmgPaymentController controller = controller(resolver, mock(OmgPaymentCreateService.class));
         ListAppender<ILoggingEvent> logs = captureLogs();
 
-        AjaxResult result = controller.create("NEVER_LOG_THIS", null);
+        AjaxResult result = controller.create("NEVER_LOG_THIS", null, null);
 
         assertEquals("AUTH_REQUIRED", ((OmgPaymentErrorResponse) result.get("data")).status());
         assertTrue(logs.list.stream().noneMatch(event -> event.getFormattedMessage().contains("NEVER_LOG_THIS")));
@@ -146,13 +148,28 @@ class OmgPaymentControllerTest {
             request.setOrderId("DD-1");
             request.setPaymentMethod(paymentMethod);
 
-            AjaxResult result = controller.create("token", request);
+            AjaxResult result = controller.create("token", null, request);
 
             assertEquals("PAYMENT_METHOD_INVALID", ((OmgPaymentErrorResponse) result.get("data")).status());
         }
         verifyNoInteractions(service);
     }
 
+    @Test
+    void multiStoreChildNeedsNewAppCapabilityButOrdinaryOrderDoesNot() {
+        OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
+        OmgPaymentCreateService service = mock(OmgPaymentCreateService.class);
+        OmgPaymentController controller = controller(resolver, service);
+        OmgCreatePaymentRequest request = new OmgCreatePaymentRequest();
+        request.setOrderId("MS-CHILD-001");
+        request.setPaymentMethod("CREDIT");
+
+        AjaxResult rejected = controller.create("token", null, request);
+
+        assertEquals("UPGRADE_REQUIRED", rejected.get("errorCode"));
+        verifyNoInteractions(service);
+    }
+
     private static ListAppender<ILoggingEvent> captureLogs() {
         Logger logger = (Logger) LoggerFactory.getLogger(OmgPaymentController.class);
         ListAppender<ILoggingEvent> appender = new ListAppender<>();

+ 28 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

@@ -1,5 +1,6 @@
 package com.ruoyi.app.omgpay;
 
+import com.ruoyi.app.order.MultiStoreCheckoutOnlineFactService;
 import com.ruoyi.system.domain.PosStoreOmg;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
@@ -144,6 +145,33 @@ class OmgPaymentCreateServiceTest {
                 "KEY", "IV");
     }
 
+    @Test
+    void selectedMultiStoreMemberCanCreateOwnOmgFormOnlyBeforeDeadline() {
+        MultiStoreCheckoutOnlineFactService batchFacts = mock(MultiStoreCheckoutOnlineFactService.class);
+        service = new OmgPaymentCreateService(attempts, credentials, generator, formFactory, batchFacts);
+        OmgPaymentOrderSnapshot order = payableOrder();
+        order.setId(100L); order.setDdId("MS-100"); order.setParentDdId("MS-PARENT");
+        order.setPayType("2");
+        when(attempts.selectOrderForUpdate("MS-100")).thenReturn(order);
+        when(batchFacts.authorizeCreate("MS-100", 5L, "2")).thenReturn(true);
+        when(credentials.getEnabledCredential(77L)).thenReturn(credential());
+        when(generator.generate()).thenReturn("OMG12345678901234567");
+        when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString(),
+                any(OmgPaymentMethod.class))).thenReturn(new OmgPaymentForm("stage", Map.of()));
+        OmgPaymentAttempt created = new OmgPaymentAttempt(); created.setId(9L);
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString())).thenReturn(created);
+
+        service.create(5L, "MS-100", OmgPaymentMethod.CREDIT);
+        var ordered = inOrder(batchFacts, attempts);
+        ordered.verify(batchFacts).lockBatchIfAny("MS-100");
+        ordered.verify(attempts).selectOrderForUpdate("MS-100");
+        verify(batchFacts).recordDispatch(100L, "OMG", "2", "OMG12345678901234567");
+
+        assertThrows(OmgPaymentBusinessException.class,
+                () -> service.create(5L, "MS-100", OmgPaymentMethod.APPLE_PAY));
+    }
+
     @Test
     void productionCheckoutPersistsEnvironmentSnapshotBeforeReturningForm() {
         OmgPaymentAttempt attempt = new OmgPaymentAttempt();

+ 20 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.omgpay;
 
 import com.ruoyi.app.order.DeliveryOrderNotificationService;
+import com.ruoyi.app.order.MultiStoreCheckoutOnlineFactService;
 import com.ruoyi.app.push.BusinessPushService;
 import com.ruoyi.app.push.RecipientApp;
 import com.ruoyi.app.omgpay.dto.OmgNotifyField;
@@ -129,6 +130,25 @@ class OmgPaymentNotifyServiceTest {
         assertEquals("123456", update.getValue().getCreditRefundId());
     }
 
+    @Test
+    void multiStoreCaptureDoesNotActivateBeforeOrderPaymentWrite() {
+        MultiStoreCheckoutOnlineFactService facts = mock(MultiStoreCheckoutOnlineFactService.class);
+        ReflectionTestUtils.setField(service, "multiStoreFacts", facts);
+        when(facts.lockBatchIfAny("DD-1")).thenReturn(true);
+        OmgPaymentOrderSnapshot batchOrder = order(0L, 0L);
+        batchOrder.setId(1L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(batchOrder);
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(0);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        var sequence = inOrder(attempts, facts);
+        sequence.verify(attempts).markOrderPaid("DD-1");
+        sequence.verify(facts).recordPaid(eq(1L), eq("OMG"), eq("2"),
+                eq("OMG123"), eq("GW123"), eq(100), eq(false));
+    }
+
     @Test
     void failureMarksAttemptFailedWithoutChangingOrder() {
         when(attempts.markFailed(any())).thenReturn(1);

+ 20 - 6
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRetryControllerTest.java

@@ -31,10 +31,11 @@ class OmgPaymentRetryControllerTest {
     @Test
     void exposesAuthenticatedRetryContractAndReturnsNewForm() throws Exception {
         Method method = OmgPaymentController.class.getDeclaredMethod(
-                "retry", String.class, OmgRetryPaymentRequest.class);
+                "retry", String.class, String.class, OmgRetryPaymentRequest.class);
         assertArrayEquals(new String[]{"/retry"}, method.getAnnotation(PostMapping.class).value());
         assertEquals("token", method.getParameters()[0].getAnnotation(RequestHeader.class).name());
-        assertNotNull(method.getParameters()[1].getAnnotation(RequestBody.class));
+        assertNotNull(method.getParameters()[1].getAnnotation(RequestHeader.class));
+        assertNotNull(method.getParameters()[2].getAnnotation(RequestBody.class));
         assertEquals(Set.of("orderId", "paymentMethod"),
                 java.util.Arrays.stream(OmgRetryPaymentRequest.class.getDeclaredFields())
                         .map(Field::getName).collect(Collectors.toSet()));
@@ -53,7 +54,7 @@ class OmgPaymentRetryControllerTest {
         AjaxResult result;
         try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
             messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
-            result = controller(resolver, retryService).retry("token", request);
+            result = controller(resolver, retryService).retry("token", null, request);
         }
 
         assertSame(response, result.get("data"));
@@ -74,13 +75,13 @@ class OmgPaymentRetryControllerTest {
             request.setPaymentMethod("CREDIT");
             OmgPaymentController controller = controller(resolver, retryService);
 
-            AjaxResult business = controller.retry("token", request);
+            AjaxResult business = controller.retry("token", null, request);
             assertEquals("PAYMENT_RETRY_NOT_AVAILABLE",
                     ((OmgPaymentErrorResponse) business.get("data")).status());
 
             org.mockito.Mockito.doThrow(new IllegalStateException("db"))
                     .when(retryService).retry(5L, "DD-1", OmgPaymentMethod.CREDIT);
-            AjaxResult unexpected = controller.retry("token", request);
+            AjaxResult unexpected = controller.retry("token", null, request);
             assertEquals("PAYMENT_RETRY_FAILED",
                     ((OmgPaymentErrorResponse) unexpected.get("data")).status());
         }
@@ -100,7 +101,7 @@ class OmgPaymentRetryControllerTest {
                 request.setOrderId("DD-1");
                 request.setPaymentMethod(paymentMethod);
 
-                AjaxResult result = controller.retry("token", request);
+                AjaxResult result = controller.retry("token", null, request);
 
                 assertEquals("PAYMENT_METHOD_INVALID", ((OmgPaymentErrorResponse) result.get("data")).status());
             }
@@ -108,6 +109,19 @@ class OmgPaymentRetryControllerTest {
         }
     }
 
+    @Test
+    void oldAppCannotRetryMultiStoreGatewayAttempt() {
+        OmgPaymentController controller = controller(mock(OmgPaymentTokenUserResolver.class),
+                mock(OmgPaymentRetryService.class));
+        OmgRetryPaymentRequest request = new OmgRetryPaymentRequest();
+        request.setOrderId("MS-CHILD-001");
+        request.setPaymentMethod("CREDIT");
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            assertEquals("UPGRADE_REQUIRED", controller.retry("token", null, request).get("errorCode"));
+        }
+    }
+
     private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
                                                     OmgPaymentRetryService retryService) {
         return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),

+ 46 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/FoodDeliveryPromotionQuoteServiceTest.java

@@ -10,6 +10,7 @@ import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import java.math.BigDecimal;
@@ -23,6 +24,8 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.MockedStatic;
 
 import com.ruoyi.app.order.dto.FoodDeliveryPromotionQuoteRequest;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.FoodSpecRelation;
@@ -67,6 +70,7 @@ class FoodDeliveryPromotionQuoteServiceTest
     private final PosFreightMapper freight = mock(PosFreightMapper.class);
     private final SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
     private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
+    private final RouteDistanceMatrixProvider routeMatrix = mock(RouteDistanceMatrixProvider.class);
 
     private FoodDeliveryPromotionQuoteService service;
 
@@ -98,7 +102,7 @@ class FoodDeliveryPromotionQuoteServiceTest
     private FoodDeliveryPromotionQuoteService subject()
     {
         return new FoodDeliveryPromotionQuoteService(stores, addresses, foods, relations, specs,
-                values, freight, selfDelivery, promotions);
+                values, freight, selfDelivery, promotions, routeMatrix);
     }
 
     // ==================== 归属与输入校验 ====================
@@ -275,6 +279,47 @@ class FoodDeliveryPromotionQuoteServiceTest
         assertEquals(0, new BigDecimal("2000").compareTo(contextCaptor.getValue().getDeliveryFee()));
     }
 
+    @Test
+    void ordinaryDeliveryUsesOneByOneMatrixAndKeepsExistingFreightAmounts()
+    {
+        when(routeMatrix.distancesToDestination(any(), any())).thenReturn(List.of(new BigDecimal("3500")));
+        when(promotions.calculate(any(), eq(USER_ID), any())).thenAnswer(call -> {
+            PromotionDeliveryContext context = call.getArgument(2);
+            PromotionCalcResponse result = new PromotionCalcResponse();
+            result.setOriginalAmount(new BigDecimal("120"));
+            result.setFinalAmount(new BigDecimal("120"));
+            result.setDeliveryFee(context.getDeliveryFee());
+            result.setPayableAmount(new BigDecimal("120").add(context.getDeliveryFee()));
+            return result;
+        });
+        PromotionCalcResponse response = service.quote(request(), USER_ID);
+        assertEquals(new BigDecimal("30"), response.getDeliveryFee());
+        assertEquals(new BigDecimal("150"), response.getPayableAmount());
+        verify(routeMatrix).distancesToDestination(List.of(new GeoPoint(new BigDecimal("25.0330"),
+                        new BigDecimal("121.5654"))),
+                new GeoPoint(new BigDecimal("25.0400"), new BigDecimal("121.5700")));
+    }
+
+    @Test
+    void matrixFailureKeepsExistingLocalizedFreightError()
+    {
+        when(routeMatrix.distancesToDestination(any(), any())).thenThrow(new IllegalStateException("unavailable"));
+        ServiceException failure = assertThrows(ServiceException.class, () -> service.quote(request(), USER_ID));
+        assertEquals("promotion.freeDelivery.freightUnavailable", failure.getMessage());
+    }
+
+    @Test
+    void pickupAndDineInDoNotQueryMatrix()
+    {
+        for (int type : List.of(1, 2)) {
+            FoodDeliveryPromotionQuoteRequest input = request();
+            input.setOrderType(type);
+            input.setAddressId(null);
+            service.quote(input, USER_ID);
+        }
+        verifyNoInteractions(routeMatrix);
+    }
+
     // ==================== 辅助构造 ====================
 
     private void assertFreight(int callIndex, String meters, String expectedFee)

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

@@ -0,0 +1,86 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.utils.DateUtil;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutOutboxEvent;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+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 MultiStoreCheckoutActivationServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final MultiStoreCheckoutActivationService service =
+            new MultiStoreCheckoutActivationService(checkout, orders);
+
+    @Test
+    void scheduledGroupWaitsForOriginalOpeningTime() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 16, 0);
+        arrange(now, "2026-10-20 18:00-18:30");
+        try (MockedStatic<DateUtil> dates = mockStatic(DateUtil.class)) {
+            dates.when(DateUtil::getAppoinmentOrderHandleTime).thenReturn(60);
+            assertFalse(service.openActivation(10L, now));
+        }
+        verify(checkout, never()).openWaitingGroup(eq(9L), any());
+    }
+
+    @Test
+    void dueGroupOpensAllPaidMembersOnceAndQueuesPersistentNotifications() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 17, 0);
+        arrange(now, "2026-10-20 18:00-18:30");
+        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);
+        try (MockedStatic<DateUtil> dates = mockStatic(DateUtil.class)) {
+            dates.when(DateUtil::getAppoinmentOrderHandleTime).thenReturn(60);
+            assertTrue(service.openActivation(10L, now));
+        }
+        verify(checkout).openWaitingGroup(9L, now);
+        verify(checkout).openGroupMemberOrder(eq(101L), any(), eq(now));
+        verify(checkout).openGroupMemberOrder(eq(102L), any(), eq(now));
+        verify(checkout).markActivationOpened(10L, now);
+    }
+
+    private void arrange(LocalDateTime now, String window) {
+        OrderCheckoutOutboxEvent event = new OrderCheckoutOutboxEvent();
+        event.setId(10L); event.setEventType("CHECKOUT_ACTIVATED");
+        event.setAggregateId(9L); event.setStatus("PENDING");
+        when(checkout.selectOutboxByIdForUpdate(10L)).thenReturn(event);
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L); batch.setCheckoutStatus("ACTIVE");
+        batch.setActivatedAt(now.minusMinutes(2));
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setId(77L); group.setBatchId(9L); group.setStatus("WAITING");
+        when(checkout.selectGroupByBatchIdForUpdate(9L)).thenReturn(group);
+        OrderCheckoutMember first = new OrderCheckoutMember();
+        first.setPosOrderId(101L);
+        OrderCheckoutMember second = new OrderCheckoutMember();
+        second.setPosOrderId(102L);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(first, second));
+        PosOrder firstOrder = new PosOrder();
+        firstOrder.setId(101L); firstOrder.setDelryTime(window); firstOrder.setState(0L);
+        PosOrder secondOrder = new PosOrder();
+        secondOrder.setId(102L); secondOrder.setDelryTime(window); secondOrder.setState(0L);
+        when(orders.selectById(101L)).thenReturn(firstOrder);
+        when(orders.selectById(102L)).thenReturn(secondOrder);
+    }
+}

+ 106 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutActiveMemberCancellationServiceTest.java

@@ -0,0 +1,106 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+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 MultiStoreCheckoutActiveMemberCancellationServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final DeliveryOrderNotificationService notifications = mock(DeliveryOrderNotificationService.class);
+    private final MultiStoreCheckoutActiveMemberCancellationService service =
+            new MultiStoreCheckoutActiveMemberCancellationService(checkout, orders, notifications);
+
+    @Test
+    void cancellingOneWaitingMemberRefundsOnlyItAndInvalidatesOldGroupVersion() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 10);
+        arrangeWaiting();
+        when(checkout.cancelActiveGroupMember(101L)).thenReturn(1);
+        when(checkout.bumpWaitingGroupVersion(9L, now)).thenReturn(1);
+        OrderCheckoutPaymentAttempt paid = new OrderCheckoutPaymentAttempt();
+        paid.setMemberId(1L); paid.setChannel("LINE"); paid.setMerchantOrderNo("L-101");
+        when(checkout.selectPaidAttemptsByBatchId(9L)).thenReturn(List.of(paid));
+
+        assertTrue(service.cancelByUser(9L, 101L, 33L, now));
+
+        verify(checkout).bumpWaitingGroupVersion(9L, now);
+        verify(checkout).insertRefundIntent(1L, "USER_CANCEL", "LINE", "L-101", null, 86, now);
+        verify(checkout).returnCouponIfValid(501L, 101L, now);
+        verify(checkout, never()).cancelActiveBatch(eq(9L), any(), any());
+        verify(notifications).notifyOrderCancelled(orders.selectById(101L), 33L);
+    }
+
+    @Test
+    void acceptedGroupRejectsUserCancellationWithoutRefund() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 10);
+        arrangeWaiting();
+        checkout.selectGroupByBatchIdForUpdate(9L).setStatus("ACCEPTED");
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.group.changed"))
+                    .thenReturn("group changed");
+            assertThrows(ServiceException.class, () -> service.cancelByUser(9L, 101L, 33L, now));
+        }
+        verify(checkout, never()).cancelActiveGroupMember(any());
+        verify(checkout, never()).insertRefundIntent(any(), any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void lastMemberCancellationClosesGroupAndParent() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 10);
+        arrangeWaiting();
+        orders.selectById(102L).setState(4L);
+        when(checkout.cancelActiveGroupMember(101L)).thenReturn(1);
+        when(checkout.cancelWaitingGroup(9L)).thenReturn(1);
+        when(checkout.cancelActiveBatch(9L, "USER_CANCEL", now)).thenReturn(1);
+        when(checkout.cancelParentOrder("MS-9")).thenReturn(1);
+        when(checkout.selectPaidAttemptsByBatchId(9L)).thenReturn(List.of());
+
+        assertTrue(service.cancelByUser(9L, 101L, 33L, now));
+        verify(checkout).cancelWaitingGroup(9L);
+        verify(checkout).cancelActiveBatch(9L, "USER_CANCEL", now);
+        verify(checkout).cancelParentOrder("MS-9");
+    }
+
+    private void arrangeWaiting() {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L); batch.setParentDdId("MS-9"); batch.setUserId(33L); batch.setCheckoutStatus("ACTIVE");
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setId(77L); group.setBatchId(9L); group.setStatus("WAITING"); group.setVersion(0L);
+        when(checkout.selectGroupByBatchIdForUpdate(9L)).thenReturn(group);
+        OrderCheckoutMember a = new OrderCheckoutMember();
+        a.setId(1L); a.setPosOrderId(101L); a.setAmount(86); a.setCouponId(501L);
+        OrderCheckoutMember b = new OrderCheckoutMember();
+        b.setId(2L); b.setPosOrderId(102L); b.setAmount(86);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(a, b));
+        PosOrder first = new PosOrder();
+        first.setId(101L); first.setUserId(33L); first.setState(0L); first.setQsId(null);
+        first.setDeliveryStatus(0L); first.setPayStatus(1L); first.setAfterSaleStatus(0L);
+        PosOrder second = new PosOrder();
+        second.setId(102L); second.setState(0L);
+        when(orders.selectById(101L)).thenReturn(first);
+        when(orders.selectById(102L)).thenReturn(second);
+    }
+}

+ 78 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutAdminQueryServiceTest.java

@@ -0,0 +1,78 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreCardView;
+import com.ruoyi.app.order.dto.MultiStoreMemberView;
+import com.ruoyi.system.domain.MultiStoreAdminOrderLink;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.MultiStoreCheckoutQueryMapper;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutAdminQueryServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final MultiStoreCheckoutQueryMapper mapper = mock(MultiStoreCheckoutQueryMapper.class);
+    private final MultiStoreCheckoutQueryService cards = mock(MultiStoreCheckoutQueryService.class);
+    private final MultiStoreCheckoutAdminQueryService service =
+            new MultiStoreCheckoutAdminQueryService(checkout, mapper, cards);
+
+    @Test
+    void enrichesOnlyCurrentPageMembersFromRealAssociation() {
+        PosOrder ordinary = new PosOrder();
+        ordinary.setId(1L);
+        ordinary.setDdId("FD1");
+        PosOrder member = new PosOrder();
+        member.setId(2L);
+        member.setDdId("MS123001");
+        MultiStoreAdminOrderLink link = new MultiStoreAdminOrderLink();
+        link.setOrderId(2L);
+        link.setBatchId(7L);
+        link.setParentOrderNo("MS123");
+        link.setCheckoutStatus("PENDING");
+        when(mapper.selectAdminOrderLinks(List.of(2L))).thenReturn(List.of(link));
+
+        service.enrichOrders(List.of(ordinary, member));
+
+        assertNull(ordinary.getMultiStore());
+        assertEquals(7L, member.getMultiStore().getBatchId());
+        assertEquals("PENDING", member.getMultiStore().getCheckoutStatus());
+    }
+
+    @Test
+    void ordinaryPageDoesNotTouchMultiStoreTables() {
+        PosOrder ordinary = new PosOrder();
+        ordinary.setId(1L);
+        ordinary.setDdId("FD1");
+        service.enrichOrders(List.of(ordinary));
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void adminDetailUsesBatchOwnerOnlyForLookupAndExposesNoCustomerActions() {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(7L);
+        batch.setUserId(21L);
+        MultiStoreCardView detail = new MultiStoreCardView();
+        detail.setAllowedActions(List.of("CANCEL_BATCH"));
+        MultiStoreMemberView member = new MultiStoreMemberView();
+        member.setAllowedActions(List.of("CANCEL_MEMBER"));
+        detail.setMembers(List.of(member));
+        when(checkout.selectBatchById(7L)).thenReturn(batch);
+        when(cards.batchDetail(7L, 21L)).thenReturn(detail);
+
+        MultiStoreCardView result = service.batchDetail(7L);
+
+        assertEquals(List.of(), result.getAllowedActions());
+        assertEquals(List.of(), result.getMembers().get(0).getAllowedActions());
+        assertThrows(RuntimeException.class, () -> service.batchDetail(8L));
+    }
+}

+ 197 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java

@@ -0,0 +1,197 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreBatchCreateRequest;
+import com.ruoyi.app.order.dto.MultiStoreQuoteRequest;
+import com.ruoyi.app.order.dto.MultiStoreQuoteView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoAddress;
+import com.ruoyi.system.domain.MultiStoreCheckoutConfigRow;
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.mapper.InfoAddressMapper;
+import com.ruoyi.system.mapper.MultiStoreCheckoutConfigMapper;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.OrderParentMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.anyString;
+import static org.mockito.Mockito.doAnswer;
+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 MultiStoreCheckoutBatchServiceTest {
+    private MockedStatic<MessageUtils> messages;
+    private final MultiStoreCheckoutQuoteService quotes = mock(MultiStoreCheckoutQuoteService.class);
+    private final MultiStoreCheckoutConfigMapper config = mock(MultiStoreCheckoutConfigMapper.class);
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final OrderParentMapper parents = mock(OrderParentMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final InfoAddressMapper addresses = mock(InfoAddressMapper.class);
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final OrderPromotionHelper promotions = mock(OrderPromotionHelper.class);
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private MultiStoreCheckoutBatchService subject() {
+        return new MultiStoreCheckoutBatchService(quotes, config, checkout, parents, orders,
+                addresses, stores, promotions);
+    }
+
+    @Test
+    void rejectsUnconfirmedParticipantBeforeWritingAnyOrder() {
+        arrangeQuote();
+
+        assertThrows(ServiceException.class, () -> subject().create(
+                new MultiStoreBatchCreateRequest("retry-1", "quote-1", List.of(1L, 3L)), 42L));
+
+        verify(checkout, never()).insertOrGetBatch(any());
+        verify(parents, never()).insert(any(OrderParent.class));
+    }
+
+    @Test
+    void createsPendingBatchWithServerPricesNoDefaultCodAndFrozenAddress() {
+        arrangeQuote();
+        when(config.selectCurrentForUpdate()).thenReturn(policy());
+        InfoAddress address = new InfoAddress();
+        address.setId(10L); address.setUserId(42L);
+        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
+        when(addresses.selectById(10L)).thenReturn(address);
+        when(stores.selectById(1L)).thenReturn(store(1));
+        when(stores.selectById(2L)).thenReturn(store(2));
+        PromotionCalcResponse food = new PromotionCalcResponse();
+        food.setOriginalAmount(new BigDecimal("50")); food.setFinalAmount(new BigDecimal("50"));
+        food.setDeliveryFee(new BigDecimal("36"));
+        food.setDeliveryDiscountAmount(BigDecimal.ZERO);
+        food.setCustomerDeliveryFee(new BigDecimal("36"));
+        food.setPayableAmount(new BigDecimal("86"));
+        when(quotes.priceFoodForBatch(any(), any(), any())).thenReturn(food);
+        doAnswer(call -> {
+            PosOrder order = call.getArgument(0);
+            PromotionCalcResponse calculated = call.getArgument(1);
+            order.setAmount(calculated.getPayableAmount().intValueExact());
+            order.setFreight(calculated.getDeliveryFee().doubleValue());
+            order.setFoodAmount(calculated.getOriginalAmount().intValueExact());
+            order.setPromotionPricingVersion(2);
+            order.setMerchantDeliverySubsidy(calculated.getDeliveryDiscountAmount());
+            order.setCustomerDeliveryFee(calculated.getCustomerDeliveryFee());
+            return null;
+        }).when(promotions).applyVersion2Pricing(any(PosOrder.class), any(PromotionCalcResponse.class));
+        when(promotions.buildServerFoodJson(any())).thenReturn("[]");
+        when(parents.insert(any(OrderParent.class))).thenReturn(1);
+        when(checkout.insertMember(any())).thenReturn(1);
+        when(checkout.insertGroup(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);
+
+        assertEquals(99L, result.batchId());
+        assertEquals("PENDING", result.checkoutStatus());
+        assertEquals(172, result.totalAmount());
+        assertEquals(2, result.members().size());
+        var captured = org.mockito.ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders, org.mockito.Mockito.times(2)).insert(captured.capture());
+        for (PosOrder order : captured.getAllValues()) {
+            assertEquals(86, order.getAmount());
+            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(Boolean.FALSE, order.getIsDisplay());
+            assertEquals(null, order.getEffectiveAt());
+            assertTrue(order.getShAddress().contains("22.6000"));
+        }
+    }
+
+    @Test
+    void retryReturnsCommittedBatchWithoutRequotingOrCreatingAnotherParent() {
+        OrderCheckoutBatch existing = new OrderCheckoutBatch();
+        existing.setId(99L); existing.setParentDdId("MS-EXISTING");
+        existing.setCheckoutStatus("PENDING"); existing.setTotalAmount(172);
+        // 使用同样的请求 token/参与集合产生稳定的幂等摘要。
+        String payload = "quote-1|[1,2]";
+        try {
+            byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
+                    .digest(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+            existing.setRequestHash(java.util.HexFormat.of().formatHex(digest));
+        } catch (java.security.NoSuchAlgorithmException exception) {
+            throw new AssertionError(exception);
+        }
+        when(checkout.selectByUserAndRequestKey(42L, "retry-1")).thenReturn(existing);
+        when(checkout.selectMembersByBatchId(99L)).thenReturn(List.of());
+
+        var result = subject().create(new MultiStoreBatchCreateRequest(
+                "retry-1", "quote-1", List.of(1L, 2L)), 42L);
+
+        assertEquals("MS-EXISTING", result.parentOrderNo());
+        verify(quotes, never()).loadSnapshot(any(), any());
+        verify(parents, never()).insert(any(OrderParent.class));
+    }
+
+    private void arrangeQuote() {
+        MultiStoreQuoteRequest input = new MultiStoreQuoteRequest(10L, true, List.of(
+                new MultiStoreQuoteRequest.Store(1L, null,
+                        List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null),
+                new MultiStoreQuoteRequest.Store(2L, null,
+                        List.of(new MultiStoreQuoteRequest.Item(22L, 1, List.of())), null, null)));
+        when(quotes.loadSnapshot("quote-1", 42L)).thenReturn(new MultiStoreCheckoutQuoteService.QuoteSnapshot(
+                42L, input, "fingerprint", System.currentTimeMillis() + 60_000));
+        var price1 = new MultiStoreQuoteView.StorePrice(1L, new BigDecimal("50"), new BigDecimal("50"),
+                new BigDecimal("40"), new BigDecimal("36"), new BigDecimal("36"), BigDecimal.ZERO,
+                new BigDecimal("86"), List.of("1", "3"));
+        var price2 = new MultiStoreQuoteView.StorePrice(2L, new BigDecimal("50"), new BigDecimal("50"),
+                new BigDecimal("40"), new BigDecimal("36"), new BigDecimal("36"), BigDecimal.ZERO,
+                new BigDecimal("86"), List.of("1", "2"));
+        when(quotes.quote(input, 42L)).thenReturn(new MultiStoreQuoteView("fresh", System.currentTimeMillis() + 60_000,
+                7, 1L, 1000, List.of(1L, 2L), List.of(price1, price2), List.of(),
+                new BigDecimal("0.9"), "fingerprint"));
+    }
+
+    private MultiStoreCheckoutConfigRow policy() {
+        MultiStoreCheckoutConfigRow row = new MultiStoreCheckoutConfigRow();
+        row.setId(1); row.setEnabled(1); row.setVersion(7L);
+        row.setFenceMeters(1000); row.setMaxStores(5); row.setConfirmationBufferMinutes(5);
+        row.setTiersJson("[{\"minimumStores\":2,\"ratio\":0.9}]");
+        return row;
+    }
+
+    private PosStore store(int id) {
+        PosStore store = new PosStore();
+        store.setId(id); store.setUserId((long) id); store.setState(0L);
+        store.setLatitude(new BigDecimal("22.6010")); store.setLongitude(new BigDecimal("120.3000"));
+        store.setPosName("store-" + id);
+        return store;
+    }
+}

+ 116 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCancellationServiceTest.java

@@ -0,0 +1,116 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutCancellationServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final MultiStoreCheckoutCancellationService service = new MultiStoreCheckoutCancellationService(checkout, orders);
+
+    @Test
+    void recoveryScanReadsPersistedPendingDeadlineInsteadOfAppTimer() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 14, 20);
+        when(checkout.selectExpiredPendingBatchIds(now, 200)).thenReturn(List.of(9L));
+
+        org.junit.jupiter.api.Assertions.assertEquals(List.of(9L), service.expiredCandidates(now, 200));
+    }
+
+    @Test
+    void bufferKeepsAlreadyDispatchedOnlinePaymentOpenUntilFinalDeadline() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 14, 17);
+        OrderCheckoutBatch batch = batch(now.plusMinutes(3));
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+
+        assertFalse(service.cancelExpired(9L, now));
+        verify(checkout, never()).cancelPendingBatch(eq(9L), any(), any());
+    }
+
+    @Test
+    void expiredAppointmentClosesBatchEvenBeforePaymentBufferEnds() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 14, 18);
+        OrderCheckoutBatch batch = batch(now.plusMinutes(2));
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(member(1L, 101L), member(2L, 102L)));
+        PosOrder first = new PosOrder();
+        first.setDelryTime("2026-10-19 14:05-14:15");
+        when(orders.selectById(101L)).thenReturn(first);
+        when(checkout.cancelPendingBatch(eq(9L), eq("APPOINTMENT_EXPIRED"), eq(now))).thenReturn(1);
+        when(checkout.cancelOrderMember(101L)).thenReturn(1);
+        when(checkout.cancelOrderMember(102L)).thenReturn(1);
+        when(checkout.cancelParentOrder("MS-9")).thenReturn(1);
+        when(checkout.cancelWaitingGroup(9L)).thenReturn(1);
+        when(checkout.selectPaidAttemptsByBatchId(9L)).thenReturn(List.of());
+
+        assertTrue(service.cancelExpired(9L, now));
+        verify(checkout).cancelPendingBatch(9L, "APPOINTMENT_EXPIRED", now);
+    }
+
+    @Test
+    void finalDeadlineClosesEveryMemberAndRefundsOnlyCapturedAttempts() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 14, 20);
+        OrderCheckoutBatch batch = batch(now);
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        when(checkout.cancelPendingBatch(eq(9L), eq("PAYMENT_TIMEOUT"), eq(now))).thenReturn(1);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(couponMember(1L, 101L, 501L), member(2L, 102L)));
+        when(checkout.cancelOrderMember(101L)).thenReturn(1);
+        when(checkout.cancelOrderMember(102L)).thenReturn(1);
+        when(checkout.cancelParentOrder("MS-9")).thenReturn(1);
+        when(checkout.cancelWaitingGroup(9L)).thenReturn(1);
+        OrderCheckoutPaymentAttempt paid = new OrderCheckoutPaymentAttempt();
+        paid.setMemberId(1L);
+        paid.setChannel("LINE");
+        paid.setMerchantOrderNo("L-101");
+        paid.setGatewayTransactionId("TX-101");
+        paid.setState("PAID");
+        when(checkout.selectPaidAttemptsByBatchId(9L)).thenReturn(List.of(paid));
+
+        assertTrue(service.cancelExpired(9L, now));
+        verify(checkout).cancelOrderMember(101L);
+        verify(checkout).cancelOrderMember(102L);
+        verify(checkout).insertRefundIntent(eq(1L), eq("PAYMENT_TIMEOUT"), eq("LINE"),
+                eq("L-101"), eq("TX-101"), eq(86), eq(now));
+        verify(checkout, never()).insertRefundIntent(eq(2L), any(), any(), any(), any(), any(), any());
+        verify(checkout).returnCouponIfValid(501L, 101L, now);
+    }
+
+    private OrderCheckoutBatch batch(LocalDateTime finalDeadline) {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L);
+        batch.setParentDdId("MS-9");
+        batch.setCheckoutStatus("PENDING");
+        batch.setFinalDeadlineAt(finalDeadline);
+        return batch;
+    }
+
+    private OrderCheckoutMember member(Long id, Long orderId) {
+        OrderCheckoutMember member = new OrderCheckoutMember();
+        member.setId(id);
+        member.setPosOrderId(orderId);
+        member.setAmount(86);
+        return member;
+    }
+
+    private OrderCheckoutMember couponMember(Long id, Long orderId, Long couponId) {
+        OrderCheckoutMember member = member(id, orderId);
+        member.setCouponId(couponId);
+        return member;
+    }
+}

+ 66 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCapabilityGateTest.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+
+class MultiStoreCheckoutCapabilityGateTest {
+    private final MultiStoreCheckoutQuoteService quoteService = mock(MultiStoreCheckoutQuoteService.class);
+    private final MultiStoreDeliveryGroupAcceptanceService acceptance = mock(MultiStoreDeliveryGroupAcceptanceService.class);
+    private final MultiStoreCheckoutBatchService batches = mock(MultiStoreCheckoutBatchService.class);
+    private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
+    private final MultiStoreCheckoutActiveMemberCancellationService memberCancellation =
+            mock(MultiStoreCheckoutActiveMemberCancellationService.class);
+    private final MockMvc mvc = MockMvcBuilders.standaloneSetup(
+            new MultiStoreCheckoutQuoteController(quoteService),
+            new MultiStoreCheckoutController(acceptance, batches, cancellation, memberCancellation)).build();
+
+    @Test
+    void missingCapabilityRejectsEveryNewUserAndRiderOperationBeforeBusinessServices() throws Exception {
+        String[][] operations = {
+                {"/quote", "user"},
+                {"/batches", "user"},
+                {"/batches/cancel", "user"},
+                {"/batches/members/cancel", "user"},
+                {"/groups/accept", "rider"}
+        };
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.upgrade.required"))
+                    .thenReturn("Please upgrade the app");
+            for (String[] operation : operations) {
+                mvc.perform(post("/system/multiStoreCheckout" + operation[0])
+                                .header("token", JwtUtil.token("33", operation[1]))
+                                .contentType(MediaType.APPLICATION_JSON)
+                                .content("{}"))
+                        .andExpect(jsonPath("$.errorCode").value("UPGRADE_REQUIRED"))
+                        .andExpect(jsonPath("$.msg").value("Please upgrade the app"));
+            }
+        }
+        verifyNoInteractions(quoteService, acceptance, batches, cancellation, memberCancellation);
+    }
+
+    @Test
+    void unknownCapabilityCannotOpenNewCheckout() throws Exception {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.upgrade.required"))
+                    .thenReturn("Please upgrade the app");
+            mvc.perform(post("/system/multiStoreCheckout/quote")
+                            .header("token", JwtUtil.token("33", "user"))
+                            .header("X-Multi-Store-Capability", "unknown")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content("{}"))
+                    .andExpect(jsonPath("$.errorCode").value("UPGRADE_REQUIRED"));
+        }
+        verifyNoInteractions(quoteService);
+    }
+}

+ 128 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutCodServiceTest.java

@@ -0,0 +1,128 @@
+package com.ruoyi.app.order;
+
+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.PosStore;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+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.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.ArgumentMatchers.eq;
+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 MultiStoreCheckoutCodServiceTest {
+    private MockedStatic<MessageUtils> messages;
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final PaymentMethodGateService payments = mock(PaymentMethodGateService.class);
+    private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private MultiStoreCheckoutCodService subject() {
+        return new MultiStoreCheckoutCodService(checkout, orders, stores, payments, cancellation);
+    }
+
+    @Test
+    void firstCodConfirmationDoesNotActivateOtherUnpaidStore() {
+        arrange(List.of(member(1, "UNSELECTED"), member(2, "UNSELECTED")),
+                LocalDateTime.now().plusMinutes(1));
+
+        assertEquals("PENDING", subject().confirm(99L, 100L, 42L));
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+        verify(checkout, never()).insertActivationEvent(eq(99L), any());
+    }
+
+    @Test
+    void lastCodConfirmationActivatesExactlyOnceAndRecordsFirstActivationTime() {
+        arrange(List.of(member(1, "COD_CONFIRMED"), member(2, "UNSELECTED")),
+                LocalDateTime.now().plusMinutes(1));
+
+        assertEquals("ACTIVE", subject().confirm(99L, 200L, 42L));
+        verify(checkout).activateIfPending(eq(99L), any(LocalDateTime.class));
+        verify(checkout).insertActivationEvent(eq(99L), any(LocalDateTime.class));
+    }
+
+    @Test
+    void expiryAndUnresolvedOnlineAttemptCannotBeTurnedIntoCod() {
+        arrange(List.of(member(1, "UNSELECTED"), member(2, "UNSELECTED")),
+                LocalDateTime.now().minusSeconds(1));
+        assertThrows(ServiceException.class, () -> subject().confirm(99L, 100L, 42L));
+        verify(checkout, never()).updateMemberCodReady(any(), any());
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                LocalDateTime.now().plusMinutes(1));
+        assertThrows(ServiceException.class, () -> subject().confirm(99L, 100L, 42L));
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+    }
+
+    @Test
+    void fullyReadyButExpiredAppointmentCannotActivate() {
+        arrange(List.of(member(1, "COD_CONFIRMED"), member(2, "UNSELECTED")),
+                LocalDateTime.now().plusMinutes(1));
+        LocalDateTime yesterday = LocalDateTime.now().minusDays(1);
+        String window = yesterday.toLocalDate() + " 14:05-14:30";
+        PosOrder first = orders.selectById(100L);
+        first.setDelryTime(window);
+
+        when(cancellation.cancelExpired(eq(99L), any())).thenReturn(true);
+        assertEquals("CANCELLED", subject().confirm(99L, 200L, 42L));
+        verify(cancellation).cancelExpired(eq(99L), any());
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+    }
+
+    private void arrange(List<OrderCheckoutMember> members, LocalDateTime deadline) {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(99L); batch.setUserId(42L); batch.setCheckoutStatus("PENDING");
+        batch.setOperationDeadlineAt(deadline); batch.setFinalDeadlineAt(deadline.plusMinutes(5));
+        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());
+            when(orders.selectById(member.getPosOrderId())).thenReturn(order);
+            PosStore store = new PosStore(); store.setId(member.getStoreId().intValue());
+            store.setUserId(member.getStoreId());
+            when(stores.selectById(member.getStoreId())).thenReturn(store);
+        }
+        when(checkout.updateMemberCodReady(any(), any())).thenReturn(1);
+        when(checkout.updateCodOrder(any())).thenReturn(1);
+        when(checkout.activateIfPending(any(), any())).thenReturn(1);
+    }
+
+    private OrderCheckoutMember member(int storeId, String state) {
+        OrderCheckoutMember member = new OrderCheckoutMember();
+        member.setId((long) storeId); member.setBatchId(99L);
+        member.setStoreId((long) storeId); member.setPosOrderId(100L * storeId);
+        member.setPaymentState(state);
+        return member;
+    }
+}

+ 27 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutConfigServiceTest.java

@@ -43,7 +43,7 @@ class MultiStoreCheckoutConfigServiceTest {
     @Test
     void validSaveUsesVersionCasAndPersistsDynamicTiersInDedicatedRow() {
         MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
-        when(mapper.selectCurrent()).thenReturn(row(7L));
+        when(mapper.selectCurrentForUpdate()).thenReturn(row(7L));
         when(mapper.updateIfVersion(any(MultiStoreCheckoutConfigRow.class))).thenReturn(1);
 
         MultiStoreCheckoutConfig saved = new MultiStoreCheckoutConfigService(mapper).save(validPolicy(7), 123L);
@@ -60,7 +60,7 @@ class MultiStoreCheckoutConfigServiceTest {
     @Test
     void staleVersionAndInvalidPolicyNeverOverwriteExistingConfig() {
         MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
-        when(mapper.selectCurrent()).thenReturn(row(8L));
+        when(mapper.selectCurrentForUpdate()).thenReturn(row(8L));
         MultiStoreCheckoutConfigService subject = new MultiStoreCheckoutConfigService(mapper);
 
         assertThrows(IllegalStateException.class, () -> subject.save(validPolicy(7), 123L));
@@ -73,6 +73,31 @@ class MultiStoreCheckoutConfigServiceTest {
         verify(mapper, never()).updateIfVersion(any(MultiStoreCheckoutConfigRow.class));
     }
 
+    @Test
+    void loweringStoreLimitRejectsExistingUnacceptedOversizedGroup() {
+        MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
+        when(mapper.selectCurrentForUpdate()).thenReturn(row(7L));
+        when(mapper.hasUnacceptedGroupOverLimit(3)).thenReturn(true);
+        MultiStoreCheckoutConfig smaller = new MultiStoreCheckoutConfig(true, 1000, 3, 5,
+                Map.of(2, new BigDecimal("0.9"), 3, new BigDecimal("0.8")), 7);
+
+        assertThrows(IllegalStateException.class,
+                () -> new MultiStoreCheckoutConfigService(mapper).save(smaller, 123L));
+        verify(mapper, never()).updateIfVersion(any(MultiStoreCheckoutConfigRow.class));
+    }
+
+    @Test
+    void loweringStoreLimitChecksPendingGroupsBeforeSaving() {
+        MultiStoreCheckoutConfigMapper mapper = mock(MultiStoreCheckoutConfigMapper.class);
+        when(mapper.selectCurrentForUpdate()).thenReturn(row(7L));
+        when(mapper.updateIfVersion(any(MultiStoreCheckoutConfigRow.class))).thenReturn(1);
+        MultiStoreCheckoutConfig smaller = new MultiStoreCheckoutConfig(true, 1000, 3, 5,
+                Map.of(2, new BigDecimal("0.9"), 3, new BigDecimal("0.8")), 7);
+
+        assertEquals(8, new MultiStoreCheckoutConfigService(mapper).save(smaller, 123L).version());
+        verify(mapper).hasUnacceptedGroupOverLimit(3);
+    }
+
     private MultiStoreCheckoutConfig validPolicy(long version) {
         return new MultiStoreCheckoutConfig(true, 1500, 6, 8,
                 Map.of(2, new BigDecimal("0.9"), 3, new BigDecimal("0.8"),

+ 88 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutControllerTest.java

@@ -0,0 +1,88 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreGroupAcceptRequest;
+import com.ruoyi.app.order.dto.MultiStoreBatchCreateRequest;
+import com.ruoyi.app.order.dto.MultiStoreBatchCreatedView;
+import com.ruoyi.app.order.dto.MultiStoreBatchCancelRequest;
+import com.ruoyi.app.order.dto.MultiStoreMemberCancelRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.List;
+import java.time.LocalDateTime;
+
+class MultiStoreCheckoutControllerTest {
+    private final MultiStoreDeliveryGroupAcceptanceService acceptance = mock(MultiStoreDeliveryGroupAcceptanceService.class);
+    private final MultiStoreCheckoutBatchService batches = mock(MultiStoreCheckoutBatchService.class);
+    private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
+    private final MultiStoreCheckoutActiveMemberCancellationService memberCancellation =
+            mock(MultiStoreCheckoutActiveMemberCancellationService.class);
+    private final MultiStoreCheckoutController controller =
+            new MultiStoreCheckoutController(acceptance, batches, cancellation, memberCancellation);
+
+    @Test
+    void createAndCancelBatchUseAuthenticatedUser() {
+        MultiStoreBatchCreateRequest create = new MultiStoreBatchCreateRequest("key-1", "quote-1", List.of(1L, 2L));
+        MultiStoreBatchCreatedView created = mock(MultiStoreBatchCreatedView.class);
+        when(batches.create(create, 33L)).thenReturn(created);
+        when(cancellation.cancelByUser(org.mockito.ArgumentMatchers.eq(9L),
+                org.mockito.ArgumentMatchers.eq(33L), org.mockito.ArgumentMatchers.any(LocalDateTime.class)))
+                .thenReturn(true);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("success");
+            assertEquals(created, controller.createBatch(JwtUtil.token("33", "user"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1, create).get("data"));
+            assertEquals(true, controller.cancelBatch(JwtUtil.token("33", "user"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreBatchCancelRequest(9L, "USER_CANCEL", "retry-1")).get("data"));
+            verify(batches).create(create, 33L);
+        }
+    }
+
+    @Test
+    void cancelMemberUsesAuthenticatedUserRatherThanClientIdentity() {
+        when(memberCancellation.cancelByUser(org.mockito.ArgumentMatchers.eq(9L),
+                org.mockito.ArgumentMatchers.eq(101L), org.mockito.ArgumentMatchers.eq(33L),
+                org.mockito.ArgumentMatchers.any(LocalDateTime.class))).thenReturn(true);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("success");
+            assertEquals(true, controller.cancelMember(JwtUtil.token("33", "user"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreMemberCancelRequest(9L, 101L, "USER_CANCEL", "retry-1")).get("data"));
+        }
+    }
+
+    @Test
+    void groupAcceptUsesAuthenticatedRiderAndExplicitBody() {
+        when(acceptance.accept(77L, 0L, 88L)).thenReturn(77L);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("success");
+            AjaxResult result = controller.acceptGroup(JwtUtil.token("88", "rider"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreGroupAcceptRequest(77L, 0L, "retry-1"));
+
+            assertEquals(77L, result.get("data"));
+            verify(acceptance).accept(77L, 0L, 88L);
+        }
+    }
+
+    @Test
+    void missingRequestIdIsRejectedBeforeAssignment() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.invalid")).thenReturn("invalid");
+            assertThrows(ServiceException.class, () -> controller.acceptGroup(JwtUtil.token("88", "rider"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreGroupAcceptRequest(77L, 0L, "")));
+        }
+    }
+}

+ 89 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutGroupCompletionServiceTest.java

@@ -0,0 +1,89 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutGroupCompletionServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final MultiStoreCheckoutGroupCompletionService service =
+            new MultiStoreCheckoutGroupCompletionService(checkout, orders);
+    private final LocalDateTime now = LocalDateTime.of(2026, 10, 19, 21, 0);
+
+    @Test
+    void allDeliveredFinishesGroupAndParentOnlyOnce() {
+        arrange(3L, 3L);
+        when(checkout.finishAcceptedGroup(9L, 88L, "FINISHED", now)).thenReturn(1);
+        when(checkout.completeParentOrder("MS-9")).thenReturn(1);
+
+        assertTrue(service.finalizeIfTerminal(9L, now));
+
+        verify(checkout).finishAcceptedGroup(9L, 88L, "FINISHED", now);
+        verify(checkout).completeParentOrder("MS-9");
+        verify(checkout, never()).cancelActiveBatch(9L, "ALL_CHILDREN_CANCELLED", now);
+    }
+
+    @Test
+    void unfinishedPickupKeepsGroupAcceptedEvenIfAnotherChildDelivered() {
+        arrange(3L, 1L);
+
+        assertFalse(service.finalizeIfTerminal(9L, now));
+
+        verify(checkout, never()).finishAcceptedGroup(9L, 88L, "FINISHED", now);
+    }
+
+    @Test
+    void allCancelledClosesGroupAndBatch() {
+        arrange(4L, 4L);
+        when(checkout.finishAcceptedGroup(9L, 88L, "CANCELLED", now)).thenReturn(1);
+        when(checkout.cancelActiveBatch(9L, "ALL_CHILDREN_CANCELLED", now)).thenReturn(1);
+        when(checkout.cancelParentOrder("MS-9")).thenReturn(1);
+
+        assertTrue(service.finalizeIfTerminal(9L, now));
+
+        verify(checkout).cancelActiveBatch(9L, "ALL_CHILDREN_CANCELLED", now);
+        verify(checkout).cancelParentOrder("MS-9");
+    }
+
+    @Test
+    void alreadyFinishedScanIsIdempotent() {
+        arrange(3L, 3L);
+        OrderDeliveryGroup group = checkout.selectGroupByBatchIdForUpdate(9L);
+        group.setStatus("FINISHED");
+
+        assertFalse(service.finalizeIfTerminal(9L, now));
+
+        verify(checkout, never()).completeParentOrder("MS-9");
+    }
+
+    private void arrange(Long firstState, Long secondState) {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L); batch.setCheckoutStatus("ACTIVE"); batch.setParentDdId("MS-9");
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setBatchId(9L); group.setStatus("ACCEPTED"); group.setRiderId(88L);
+        when(checkout.selectGroupByBatchIdForUpdate(9L)).thenReturn(group);
+        OrderCheckoutMember first = new OrderCheckoutMember(); first.setPosOrderId(101L);
+        OrderCheckoutMember second = new OrderCheckoutMember(); second.setPosOrderId(102L);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(first, second));
+        PosOrder a = new PosOrder(); a.setId(101L); a.setState(firstState); a.setQsId(88L);
+        PosOrder b = new PosOrder(); b.setId(102L); b.setState(secondState); b.setQsId(88L);
+        when(orders.selectById(101L)).thenReturn(a);
+        when(orders.selectById(102L)).thenReturn(b);
+    }
+}

+ 49 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutLegacyGuardTest.java

@@ -0,0 +1,49 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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 MultiStoreCheckoutLegacyGuardTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final MultiStoreCheckoutLegacyGuard guard = new MultiStoreCheckoutLegacyGuard(checkout);
+
+    @Test
+    void rejectsSingleCancelForRealBatchMember() {
+        PosOrder order = new PosOrder();
+        order.setId(101L); order.setDdId("MS-101");
+        when(checkout.selectBatchIdByOrderId(101L)).thenReturn(9L);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.batch.cancel.required"))
+                    .thenReturn("Cancel through batch");
+            assertThrows(ServiceException.class, () -> guard.assertSingleCancelAllowed(order));
+        }
+    }
+
+    @Test
+    void ordinaryOrderNeverQueriesUnmigratedBatchTable() {
+        PosOrder order = new PosOrder();
+        order.setId(101L); order.setDdId("FD-101");
+
+        guard.assertSingleCancelAllowed(order);
+
+        verify(checkout, never()).selectBatchIdByOrderId(101L);
+    }
+
+    @Test
+    void ordinaryParentDetailNeverQueriesUnmigratedBatchTable() {
+        assertFalse(guard.isBatchParent("FD-101"));
+        verify(checkout, never()).selectBatchIdByParentDdId("FD-101");
+    }
+}

+ 33 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutMapperXmlTest.java

@@ -0,0 +1,33 @@
+package com.ruoyi.app.order;
+
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** 不依赖未执行的 036 数据库迁移,也要先验证新增 Mapper 可由 MyBatis 正常加载。 */
+class MultiStoreCheckoutMapperXmlTest {
+    @Test
+    void loadsAllCheckoutMapperStatements() throws Exception {
+        parse("mapper/system/OrderCheckoutMapper.xml",
+                "com.ruoyi.system.mapper.OrderCheckoutMapper.selectTerminalAcceptedGroupBatchIds");
+        parse("mapper/system/MultiStoreCheckoutQueryMapper.xml",
+                "com.ruoyi.system.mapper.MultiStoreCheckoutQueryMapper.selectRiderTasks");
+        parse("mapper/system/MultiStoreCheckoutRefundProjectionMapper.xml",
+                "com.ruoyi.system.mapper.MultiStoreCheckoutRefundProjectionMapper.selectByBatchId");
+        parse("mapper/system/MultiStoreRefundFollowupMapper.xml",
+                "com.ruoyi.system.mapper.MultiStoreRefundFollowupMapper.select");
+    }
+
+    private void parse(String resource, String statement) throws Exception {
+        Configuration configuration = new Configuration();
+        try (InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource)) {
+            if (stream == null) throw new IllegalStateException("Missing mapper: " + resource);
+            new XMLMapperBuilder(stream, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        assertTrue(configuration.hasStatement(statement), statement);
+    }
+}

+ 136 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantCancellationServiceTest.java

@@ -0,0 +1,136 @@
+package com.ruoyi.app.order;
+
+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.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.service.MerchantAccessContext;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+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.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 MultiStoreCheckoutMerchantCancellationServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final DeliveryOrderNotificationService notifications = mock(DeliveryOrderNotificationService.class);
+    private final MultiStoreCheckoutMerchantCancellationService service =
+            new MultiStoreCheckoutMerchantCancellationService(checkout, orders, notifications);
+    private final MerchantAccessContext merchant = new MerchantAccessContext(44L, 44L, true, Set.of(11L));
+
+    @Test
+    void acceptedRiderKeepsOtherStoreAndOnlyUnpickedStoreIsRefunded() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 30);
+        arrange();
+        when(checkout.cancelAcceptedGroupMember(101L, 88L)).thenReturn(1);
+        when(checkout.bumpAcceptedGroupVersion(9L, 88L, now)).thenReturn(1);
+        OrderCheckoutPaymentAttempt paid = new OrderCheckoutPaymentAttempt();
+        paid.setMemberId(1L); paid.setChannel("LINE"); paid.setMerchantOrderNo("L-101");
+        paid.setGatewayTransactionId("TX-101");
+        when(checkout.selectPaidAttemptsByBatchId(9L)).thenReturn(List.of(paid));
+
+        assertTrue(service.cancelUnavailable(9L, 101L, merchant, now));
+
+        verify(checkout).bumpAcceptedGroupVersion(9L, 88L, now);
+        verify(checkout).insertRefundIntent(1L, "MERCHANT_UNAVAILABLE", "LINE", "L-101", "TX-101", 132, now);
+        verify(checkout).returnCouponIfValid(501L, 101L, now);
+        verify(notifications).notifyOrderCancelled(orders.selectById(101L), 44L);
+        verify(checkout, never()).cancelActiveBatch(any(), any(), any());
+    }
+
+    @Test
+    void pickupWinsRaceAndMerchantCannotCancel() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 30);
+        arrange();
+        when(checkout.cancelAcceptedGroupMember(101L, 88L)).thenReturn(0);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.group.changed"))
+                    .thenReturn("changed");
+            assertThrows(ServiceException.class, () -> service.cancelUnavailable(9L, 101L, merchant, now));
+        }
+        verify(checkout, never()).insertRefundIntent(any(), any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void unrelatedMerchantCannotCancelAnotherStore() {
+        arrange();
+        MerchantAccessContext other = new MerchantAccessContext(55L, 55L, true, Set.of(12L));
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.invalid")).thenReturn("invalid");
+            assertThrows(ServiceException.class, () -> service.cancelUnavailable(9L, 101L, other,
+                    LocalDateTime.of(2026, 10, 19, 20, 30)));
+        }
+        verify(checkout, never()).cancelAcceptedGroupMember(any(), any());
+    }
+
+    @Test
+    void lastEffectiveMemberCancellationClosesWholeGroup() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 30);
+        arrange();
+        orders.selectById(102L).setState(4L);
+        when(checkout.cancelAcceptedGroupMember(101L, 88L)).thenReturn(1);
+        when(checkout.finishAcceptedGroup(9L, 88L, "CANCELLED", now)).thenReturn(1);
+        when(checkout.cancelActiveBatch(9L, "MERCHANT_UNAVAILABLE", now)).thenReturn(1);
+        when(checkout.cancelParentOrder("MS-9")).thenReturn(1);
+
+        assertTrue(service.cancelUnavailable(9L, 101L, merchant, now));
+
+        verify(checkout).finishAcceptedGroup(9L, 88L, "CANCELLED", now);
+        verify(checkout).cancelActiveBatch(9L, "MERCHANT_UNAVAILABLE", now);
+        verify(checkout).cancelParentOrder("MS-9");
+    }
+
+    @Test
+    void finalUnpickedMemberCancelledAfterOtherDeliveryCompletesGroup() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 19, 20, 30);
+        arrange();
+        orders.selectById(102L).setState(3L);
+        when(checkout.cancelAcceptedGroupMember(101L, 88L)).thenReturn(1);
+        when(checkout.finishAcceptedGroup(9L, 88L, "FINISHED", now)).thenReturn(1);
+        when(checkout.completeParentOrder("MS-9")).thenReturn(1);
+
+        assertTrue(service.cancelUnavailable(9L, 101L, merchant, now));
+
+        verify(checkout).finishAcceptedGroup(9L, 88L, "FINISHED", now);
+        verify(checkout).completeParentOrder("MS-9");
+        verify(checkout, never()).cancelActiveBatch(any(), any(), any());
+    }
+
+    private void arrange() {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L); batch.setParentDdId("MS-9"); batch.setCheckoutStatus("ACTIVE");
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setId(77L); group.setBatchId(9L); group.setStatus("ACCEPTED"); group.setRiderId(88L);
+        when(checkout.selectGroupByBatchIdForUpdate(9L)).thenReturn(group);
+        OrderCheckoutMember a = new OrderCheckoutMember();
+        a.setId(1L); a.setPosOrderId(101L); a.setAmount(132); a.setCouponId(501L);
+        OrderCheckoutMember b = new OrderCheckoutMember();
+        b.setId(2L); b.setPosOrderId(102L); b.setAmount(132);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(a, b));
+        PosOrder target = new PosOrder();
+        target.setId(101L); target.setMdId(11L); target.setState(1L); target.setDeliveryStatus(1L);
+        target.setQsId(88L); target.setPayStatus(1L); target.setAfterSaleStatus(0L);
+        PosOrder other = new PosOrder();
+        other.setId(102L); other.setMdId(12L); other.setState(1L); other.setDeliveryStatus(1L);
+        other.setQsId(88L);
+        when(orders.selectById(101L)).thenReturn(target);
+        when(orders.selectById(102L)).thenReturn(other);
+    }
+}

+ 55 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutMerchantControllerTest.java

@@ -0,0 +1,55 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreMerchantCancelRequest;
+import com.ruoyi.app.pay.LinePayOrderGuard;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.MerchantAccessContext;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutMerchantControllerTest {
+    @Test
+    void authenticatedMerchantCancelsOnlyAuthorizedStoreOrder() {
+        IInfoUserService users = mock(IInfoUserService.class);
+        MerchantStoreAccessService access = mock(MerchantStoreAccessService.class);
+        PosOrderMapper orders = mock(PosOrderMapper.class);
+        MultiStoreCheckoutMerchantCancellationService cancellation =
+                mock(MultiStoreCheckoutMerchantCancellationService.class);
+        MultiStoreCheckoutMerchantController controller =
+                new MultiStoreCheckoutMerchantController(users, access, orders, mock(LinePayOrderGuard.class), cancellation);
+        InfoUser merchant = new InfoUser();
+        merchant.setUserId(44L); merchant.setUserType("1");
+        when(users.getById(44L)).thenReturn(merchant);
+        MerchantAccessContext context = new MerchantAccessContext(44L, 44L, true, Set.of(11L));
+        when(access.resolve(44L)).thenReturn(context);
+        PosOrder order = new PosOrder();
+        order.setId(101L); order.setMdId(11L);
+        when(orders.selectById(101L)).thenReturn(order);
+        when(cancellation.cancelUnavailable(eq(9L), eq(101L), eq(context), any(LocalDateTime.class)))
+                .thenReturn(true);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("success");
+            assertEquals(true, controller.cancelUnavailable(JwtUtil.token("44", "merchant"),
+                    new MultiStoreMerchantCancelRequest(9L, 101L, "MERCHANT_UNAVAILABLE", "retry-1"))
+                    .get("data"));
+        }
+        verify(cancellation).cancelUnavailable(eq(9L), eq(101L), eq(context), any(LocalDateTime.class));
+    }
+}

+ 97 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutNoRiderTimeoutServiceTest.java

@@ -0,0 +1,97 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutNoRiderTimeoutServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final DeliveryOrderNotificationService notifications = mock(DeliveryOrderNotificationService.class);
+    private final MultiStoreCheckoutNoRiderTimeoutService service =
+            new MultiStoreCheckoutNoRiderTimeoutService(checkout, orders, notifications);
+
+    @Test
+    void paymentOfFirstMemberDoesNotStartTimeoutBeforeWholeBatchOpens() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 17, 40);
+        arrange(now.minusMinutes(60), now.minusMinutes(20), "2026-10-20 18:00-18:30");
+
+        assertFalse(service.cancelExpired(9L, now, 30));
+
+        verify(checkout, never()).cancelActiveBatch(eq(9L), any(), any());
+    }
+
+    @Test
+    void expiredWaitingGroupClosesAllUnassignedMembersAndRefundsCapturedAttempt() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 18, 31);
+        arrange(now.minusMinutes(91), now.minusMinutes(61), "2026-10-20 18:00-18:30");
+        when(checkout.cancelActiveBatch(eq(9L), eq("AUTO_NO_RIDER"), eq(now))).thenReturn(1);
+        when(checkout.cancelWaitingGroup(9L)).thenReturn(1);
+        when(checkout.cancelOrderMember(101L)).thenReturn(1);
+        when(checkout.cancelOrderMember(102L)).thenReturn(1);
+        when(checkout.cancelParentOrder("MS-9")).thenReturn(1);
+        OrderCheckoutPaymentAttempt paid = new OrderCheckoutPaymentAttempt();
+        paid.setMemberId(1L); paid.setChannel("LINE"); paid.setMerchantOrderNo("L-101");
+        paid.setGatewayTransactionId("TX-101");
+        when(checkout.selectPaidAttemptsByBatchId(9L)).thenReturn(List.of(paid));
+        checkout.selectMembersByBatchId(9L).get(0).setCouponId(501L);
+
+        assertTrue(service.cancelExpired(9L, now, 30));
+
+        verify(checkout).insertRefundIntent(eq(1L), eq("AUTO_NO_RIDER"), eq("LINE"),
+                eq("L-101"), eq("TX-101"), eq(86), eq(now));
+        verify(notifications).notifyNoRiderCancelled(orders.selectById(101L));
+        verify(notifications).notifyNoRiderCancelled(orders.selectById(102L));
+        verify(checkout).returnCouponIfValid(501L, 101L, now);
+    }
+
+    @Test
+    void acceptedGroupCannotBeCancelledByNoRiderScanner() {
+        LocalDateTime now = LocalDateTime.of(2026, 10, 20, 18, 31);
+        arrange(now.minusMinutes(91), now.minusMinutes(61), null);
+        checkout.selectGroupByBatchIdForUpdate(9L).setStatus("ACCEPTED");
+
+        assertFalse(service.cancelExpired(9L, now, 30));
+        verify(checkout, never()).cancelActiveBatch(eq(9L), any(), any());
+    }
+
+    private void arrange(LocalDateTime activatedAt, LocalDateTime openedAt, String window) {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L); batch.setParentDdId("MS-9");
+        batch.setCheckoutStatus("ACTIVE"); batch.setActivatedAt(activatedAt);
+        when(checkout.selectBatchByIdForUpdate(9L)).thenReturn(batch);
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setId(77L); group.setBatchId(9L); group.setStatus("WAITING"); group.setOpenedAt(openedAt);
+        when(checkout.selectGroupByBatchIdForUpdate(9L)).thenReturn(group);
+        OrderCheckoutMember a = new OrderCheckoutMember();
+        a.setId(1L); a.setPosOrderId(101L); a.setAmount(86);
+        OrderCheckoutMember b = new OrderCheckoutMember();
+        b.setId(2L); b.setPosOrderId(102L); b.setAmount(86);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(a, b));
+        PosOrder first = new PosOrder();
+        first.setId(101L); first.setDdId("MS-9001"); first.setState(0L);
+        first.setDeliveryStatus(0L); first.setDelryTime(window);
+        PosOrder second = new PosOrder();
+        second.setId(102L); second.setDdId("MS-9002"); second.setState(0L);
+        second.setDeliveryStatus(0L);
+        when(orders.selectById(101L)).thenReturn(first);
+        when(orders.selectById(102L)).thenReturn(second);
+    }
+}

+ 79 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutNotificationServiceTest.java

@@ -0,0 +1,79 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.OrderCheckoutBatch;
+import com.ruoyi.system.domain.OrderCheckoutMember;
+import com.ruoyi.system.domain.OrderCheckoutOutboxEvent;
+import com.ruoyi.system.domain.OrderDeliveryGroup;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+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.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutNotificationServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final DeliveryOrderNotificationService notifications = mock(DeliveryOrderNotificationService.class);
+    private final MultiStoreCheckoutNotificationService service =
+            new MultiStoreCheckoutNotificationService(checkout, orders, notifications);
+
+    @Test
+    void openedEventQueuesOneGroupAlertAndEachMerchantOrder() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange();
+        when(checkout.claimActivationNotification(10L, now)).thenReturn(1);
+        when(checkout.markActivationNotified(10L, now)).thenReturn(1);
+
+        assertTrue(service.dispatch(10L, now));
+
+        verify(notifications).notifyMultiStoreGroupAvailable(eq(77L), eq(10L), any(), any(), eq(true));
+        verify(checkout).markActivationNotified(10L, now);
+    }
+
+    @Test
+    void failedEnqueueLeavesDurableEventForRetry() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange();
+        when(checkout.claimActivationNotification(10L, now)).thenReturn(1);
+        doThrow(new IllegalStateException("push unavailable"))
+                .when(notifications).notifyMultiStoreGroupAvailable(eq(77L), eq(10L), any(), any(), eq(true));
+
+        assertFalse(service.dispatch(10L, now));
+
+        verify(checkout).retryActivationNotification(10L, now, now.plusMinutes(1));
+        verify(checkout, never()).markActivationNotified(10L, now);
+    }
+
+    private void arrange() {
+        OrderCheckoutOutboxEvent event = new OrderCheckoutOutboxEvent();
+        event.setId(10L); event.setEventType("CHECKOUT_ACTIVATED");
+        event.setAggregateId(9L); event.setStatus("SENDING");
+        when(checkout.selectOutboxById(10L)).thenReturn(event);
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(9L); batch.setCheckoutStatus("ACTIVE");
+        when(checkout.selectBatchById(9L)).thenReturn(batch);
+        OrderDeliveryGroup group = new OrderDeliveryGroup();
+        group.setId(77L); group.setOpenedAt(LocalDateTime.now()); group.setStatus("WAITING");
+        when(checkout.selectGroupById(77L)).thenReturn(group);
+        when(checkout.selectGroupByBatchId(9L)).thenReturn(group);
+        OrderCheckoutMember a = new OrderCheckoutMember(); a.setPosOrderId(101L);
+        OrderCheckoutMember b = new OrderCheckoutMember(); b.setPosOrderId(102L);
+        when(checkout.selectMembersByBatchId(9L)).thenReturn(List.of(a, b));
+        PosOrder first = new PosOrder(); first.setId(101L); first.setState(0L); first.setIsDisplay(true);
+        PosOrder second = new PosOrder(); second.setId(102L); second.setState(0L); second.setIsDisplay(true);
+        when(orders.selectById(101L)).thenReturn(first);
+        when(orders.selectById(102L)).thenReturn(second);
+    }
+}

+ 197 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineFactServiceTest.java

@@ -0,0 +1,197 @@
+package com.ruoyi.app.order;
+
+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.OrderCheckoutPaymentAttempt;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+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.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.ArgumentMatchers.eq;
+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 MultiStoreCheckoutOnlineFactServiceTest {
+    private MockedStatic<MessageUtils> messages;
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final MultiStoreCheckoutCancellationService cancellation = mock(MultiStoreCheckoutCancellationService.class);
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private MultiStoreCheckoutOnlineFactService subject() {
+        return new MultiStoreCheckoutOnlineFactService(checkout, orders, cancellation);
+    }
+
+    @Test
+    void dispatchRequiresPendingMemberBeforeOperationDeadline() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                "PENDING", now.plusMinutes(1), now.plusMinutes(6));
+        when(checkout.insertDispatchedAttempt(eq(1L), eq("LINE"), eq("3"), eq("L-1"), any()))
+                .thenReturn(1);
+        subject().recordDispatch(100L, "LINE", "3", "L-1");
+        verify(checkout).insertDispatchedAttempt(eq(1L), eq("LINE"), eq("3"), eq("L-1"), any());
+
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                "PENDING", now.minusSeconds(1), now.plusMinutes(5));
+        assertThrows(ServiceException.class, () -> subject().recordDispatch(100L, "LINE", "3", "L-2"));
+        verify(checkout, never()).insertDispatchedAttempt(eq(1L), eq("LINE"), eq("3"), eq("L-2"), any());
+    }
+
+    @Test
+    void directGatewayCreateRequiresSelectedMemberAndOpenBatch() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                "PENDING", now.plusMinutes(1), now.plusMinutes(6));
+        when(checkout.selectBatchIdByOrderNo("MS-100")) .thenReturn(99L);
+        when(checkout.selectMemberByOrderNo("MS-100")).thenReturn(member(1, "ONLINE_PENDING"));
+        OrderCheckoutBatch batch = checkout.selectBatchByIdForUpdate(99L);
+        batch.setUserId(42L);
+        assertEquals(true, subject().authorizeCreate("MS-100", 42L, "3"));
+        assertThrows(ServiceException.class, () -> subject().authorizeCreate("MS-100", 43L, "3"));
+        batch.setOperationDeadlineAt(now.minusSeconds(1));
+        assertThrows(ServiceException.class, () -> subject().authorizeCreate("MS-100", 42L, "3"));
+        assertEquals(false, subject().authorizeCreate("OLD-100", 42L, "3"));
+    }
+
+    @Test
+    void verifiedPaidFactWaitsForOtherStoreThenActivatesOnce() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                "PENDING", now.plusMinutes(1), now.plusMinutes(6));
+        when(checkout.selectAttempt("LINE", "L-1")).thenReturn(attempt(now.minusSeconds(5)));
+        when(checkout.markAttemptPaid(eq(1L), eq("TX-1"), any())).thenReturn(1);
+        when(checkout.updateMemberOnlinePaid(eq(1L), eq(86), eq(1L), any())).thenReturn(1);
+
+        assertEquals("PENDING", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86));
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+
+        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());
+    }
+
+    @Test
+    void lateOrUnissuedPaymentCreatesRefundIntentWithoutReopeningBatch() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                "CANCELLED", now.minusMinutes(5), now.minusSeconds(1));
+        when(checkout.selectAttempt("LINE", "L-1")).thenReturn(attempt(now.minusMinutes(7)));
+        when(checkout.markAttemptPaid(eq(1L), eq("TX-1"), any())).thenReturn(1);
+        assertEquals("REFUND_REQUIRED", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86));
+        verify(checkout).insertRefundIntent(eq(1L), eq("LATE_PAYMENT"), eq("LINE"),
+                eq("L-1"), eq("TX-1"), eq(86), any());
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "UNSELECTED")),
+                "PENDING", now.plusMinutes(1), now.plusMinutes(6));
+        when(checkout.selectAttempt("LINE", "L-2")).thenReturn(null);
+        assertEquals("REFUND_REQUIRED", subject().recordPaid(100L, "LINE", "3", "L-2", "TX-2", 86));
+        verify(checkout).insertRefundIntent(eq(1L), eq("UNVERIFIED_DISPATCH"), eq("LINE"),
+                eq("L-2"), eq("TX-2"), eq(86), any());
+    }
+
+    @Test
+    void failedOrderPaymentWriteNeverMakesMemberReady() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "COD_CONFIRMED")),
+                "PENDING", now.plusMinutes(1), now.plusMinutes(6));
+        when(checkout.selectAttempt("LINE", "L-1")).thenReturn(attempt(now.minusSeconds(5)));
+        when(checkout.markAttemptPaid(eq(1L), eq("TX-1"), any())).thenReturn(1);
+
+        assertEquals("REFUND_REQUIRED", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86, false));
+        verify(checkout, never()).updateMemberOnlinePaid(eq(1L), eq(86), eq(1L), any());
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+    }
+
+    @Test
+    void replayOfAcceptedAttemptIsNotMistakenForSecondCharge() {
+        LocalDateTime now = LocalDateTime.now();
+        OrderCheckoutMember paid = member(1, "ONLINE_PAID");
+        paid.setAcceptedPaymentAttemptId(1L);
+        arrange(List.of(paid, member(2, "COD_CONFIRMED")),
+                "ACTIVE", now.minusMinutes(5), now.minusMinutes(1));
+        OrderCheckoutPaymentAttempt accepted = attempt(now.minusMinutes(7));
+        accepted.setState("PAID");
+        accepted.setGatewayTransactionId("TX-1");
+        when(checkout.selectAttempt("LINE", "L-1")).thenReturn(accepted);
+
+        assertEquals("ACTIVE", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86, false));
+        verify(checkout, never()).insertRefundIntent(eq(1L), anyString(), eq("LINE"),
+                eq("L-1"), eq("TX-1"), eq(86), any());
+    }
+
+    @Test
+    void finalOnlinePaymentAfterAppointmentEndClosesWholeBatch() {
+        LocalDateTime now = LocalDateTime.now();
+        arrange(List.of(member(1, "ONLINE_PENDING"), member(2, "COD_CONFIRMED")),
+                "PENDING", now.plusMinutes(1), now.plusMinutes(6));
+        PosOrder first = orders.selectById(100L);
+        first.setDelryTime(now.minusDays(1).toLocalDate() + " 14:05-14:30");
+        when(checkout.selectAttempt("LINE", "L-1")).thenReturn(attempt(now.minusSeconds(5)));
+        when(checkout.markAttemptPaid(eq(1L), eq("TX-1"), any())).thenReturn(1);
+        when(checkout.updateMemberOnlinePaid(eq(1L), eq(86), eq(1L), any())).thenReturn(1);
+        when(cancellation.cancelExpired(eq(99L), any())).thenReturn(true);
+
+        assertEquals("REFUND_REQUIRED", subject().recordPaid(100L, "LINE", "3", "L-1", "TX-1", 86));
+        verify(cancellation).cancelExpired(eq(99L), any());
+        verify(checkout, never()).activateIfPending(eq(99L), any());
+    }
+
+    private void arrange(List<OrderCheckoutMember> members, String status,
+                         LocalDateTime operationDeadline, LocalDateTime finalDeadline) {
+        when(checkout.selectBatchIdByOrderId(100L)).thenReturn(99L);
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(99L); batch.setCheckoutStatus(status);
+        batch.setOperationDeadlineAt(operationDeadline); batch.setFinalDeadlineAt(finalDeadline);
+        when(checkout.selectBatchByIdForUpdate(99L)).thenReturn(batch);
+        when(checkout.selectMembersByBatchId(99L)).thenReturn(members);
+        PosOrder first = new PosOrder(); first.setId(100L); first.setDelryTime(null);
+        when(orders.selectById(100L)).thenReturn(first);
+    }
+
+    private OrderCheckoutMember member(int store, String state) {
+        OrderCheckoutMember member = new OrderCheckoutMember();
+        member.setId((long) store); member.setBatchId(99L); member.setPosOrderId(100L * store);
+        member.setStoreId((long) store); member.setPaymentState(state);
+        member.setPayType(store == 1 ? "3" : "1"); member.setAmount(86);
+        return member;
+    }
+
+    private OrderCheckoutPaymentAttempt attempt(LocalDateTime dispatchedAt) {
+        OrderCheckoutPaymentAttempt attempt = new OrderCheckoutPaymentAttempt();
+        attempt.setId(1L); attempt.setMemberId(1L);
+        attempt.setChannel("LINE"); attempt.setPayType("3");
+        attempt.setMerchantOrderNo("L-1"); attempt.setState("DISPATCHED");
+        attempt.setDispatchedAt(dispatchedAt);
+        return attempt;
+    }
+}

+ 102 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutOnlineSelectionServiceTest.java

@@ -0,0 +1,102 @@
+package com.ruoyi.app.order;
+
+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.PosStore;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.system.mapper.PosStoreMapper;
+import com.ruoyi.system.service.PaymentMethodGateService;
+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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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 MultiStoreCheckoutOnlineSelectionServiceTest {
+    private MockedStatic<MessageUtils> messages;
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final PosStoreMapper stores = mock(PosStoreMapper.class);
+    private final PaymentMethodGateService payments = mock(PaymentMethodGateService.class);
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    private MultiStoreCheckoutOnlineSelectionService subject() {
+        return new MultiStoreCheckoutOnlineSelectionService(checkout, orders, stores, payments);
+    }
+
+    @Test
+    void choosesOneMerchantMethodWithoutClaimingPaymentOrOpeningFulfillment() {
+        arrange("UNSELECTED", null, LocalDateTime.now().plusMinutes(2));
+        when(checkout.updateMemberOnlinePending(1L, "3")).thenReturn(1);
+        when(checkout.updateOnlineOrderType(100L, "3")).thenReturn(1);
+
+        assertEquals("ONLINE_PENDING", subject().select(99L, 100L, 42L, "3"));
+        verify(payments).assertUsableForOrder("3", PaymentMethodGateService.GateScope.MERCHANT, 1L, 1L, 0L);
+        verify(checkout).updateMemberOnlinePending(1L, "3");
+        verify(checkout).updateOnlineOrderType(100L, "3");
+        verify(checkout, never()).activateIfPending(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
+    }
+
+    @Test
+    void rejectsExpiredSelectionAndUnresolvedAttemptMethodSwitch() {
+        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));
+        assertThrows(ServiceException.class, () -> subject().select(99L, 100L, 42L, "2"));
+        verify(checkout, never()).updateMemberOnlinePending(1L, "2");
+    }
+
+    @Test
+    void refusesTransferAndForeignBatchBeforeAnyPaymentWrite() {
+        arrange("UNSELECTED", null, LocalDateTime.now().plusMinutes(2));
+        assertThrows(ServiceException.class, () -> subject().select(99L, 100L, 42L, "6"));
+        assertThrows(ServiceException.class, () -> subject().select(99L, 100L, 43L, "3"));
+        verify(checkout, never()).updateMemberOnlinePending(1L, "6");
+        verify(checkout, never()).updateMemberOnlinePending(1L, "3");
+    }
+
+    private void arrange(String state, String payType, LocalDateTime deadline) {
+        OrderCheckoutBatch batch = new OrderCheckoutBatch();
+        batch.setId(99L); batch.setUserId(42L); batch.setCheckoutStatus("PENDING");
+        batch.setOperationDeadlineAt(deadline);
+        when(checkout.selectBatchByIdForUpdate(99L)).thenReturn(batch);
+        OrderCheckoutMember member = new OrderCheckoutMember();
+        member.setId(1L); member.setBatchId(99L); member.setPosOrderId(100L);
+        member.setStoreId(1L); member.setPaymentState(state); member.setPayType(payType);
+        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);
+        when(orders.selectById(100L)).thenReturn(order);
+        PosStore store = new PosStore();
+        store.setId(1); store.setUserId(1L);
+        when(stores.selectById(1L)).thenReturn(store);
+    }
+}

+ 75 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutPaymentSelectionControllerTest.java

@@ -0,0 +1,75 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreMemberPaymentRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutPaymentSelectionControllerTest {
+    private final MultiStoreCheckoutCodService cod = mock(MultiStoreCheckoutCodService.class);
+    private final MultiStoreCheckoutOnlineSelectionService online = mock(MultiStoreCheckoutOnlineSelectionService.class);
+    private final MultiStoreCheckoutPaymentSelectionController controller =
+            new MultiStoreCheckoutPaymentSelectionController(cod, online);
+
+    @Test
+    void selectsCodForTokenUserAndReturnsRealBatchState() {
+        when(cod.confirm(9L, 101L, 33L)).thenReturn("PENDING");
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("success");
+            AjaxResult result = controller.select(JwtUtil.token("33", "user"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreMemberPaymentRequest(9L, 101L, "1", "request-1"));
+            assertEquals("PENDING", result.get("data"));
+            verify(cod).confirm(9L, 101L, 33L);
+            verifyNoInteractions(online);
+        }
+    }
+
+    @Test
+    void onlineSelectionDoesNotClaimGatewayPayment() {
+        when(online.select(9L, 101L, 33L, "3")).thenReturn("ONLINE_PENDING");
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("no.action.success")).thenReturn("success");
+            AjaxResult result = controller.select(JwtUtil.token("33", "user"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreMemberPaymentRequest(9L, 101L, "3", "request-1"));
+            assertEquals("ONLINE_PENDING", result.get("data"));
+            verify(online).select(9L, 101L, 33L, "3");
+            verifyNoInteractions(cod);
+        }
+    }
+
+    @Test
+    void rejectsTransferWithoutUpdatingPayment() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.invalid")).thenReturn("invalid");
+            assertThrows(ServiceException.class, () -> controller.select(JwtUtil.token("33", "user"),
+                    MultiStoreCheckoutAppCapability.BATCH_CARDS_V1,
+                    new MultiStoreMemberPaymentRequest(9L, 101L, "6", "request-1")));
+            verifyNoInteractions(cod, online);
+        }
+    }
+
+    @Test
+    void oldAppCannotSelectPayment() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.upgrade.required"))
+                    .thenReturn("upgrade");
+            AjaxResult result = controller.select(JwtUtil.token("33", "user"), null,
+                    new MultiStoreMemberPaymentRequest(9L, 101L, "1", "request-1"));
+            assertEquals("UPGRADE_REQUIRED", result.get("errorCode"));
+            verifyNoInteractions(cod, online);
+        }
+    }
+}

+ 30 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryControllerTest.java

@@ -0,0 +1,30 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+class MultiStoreCheckoutQueryControllerTest {
+    private final MultiStoreCheckoutQueryService queries = mock(MultiStoreCheckoutQueryService.class);
+    private final MultiStoreCheckoutQueryController controller = new MultiStoreCheckoutQueryController(queries);
+
+    @Test
+    void oldAppCannotReadBatchOrGroupDetails() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message("multi.store.checkout.upgrade.required"))
+                    .thenReturn("upgrade");
+            AjaxResult batch = controller.batchDetail(JwtUtil.token("21", "user"), null, 9L);
+            AjaxResult group = controller.groupDetail(JwtUtil.token("33", "rider"), null, 7L);
+            assertEquals("UPGRADE_REQUIRED", batch.get("errorCode"));
+            assertEquals("UPGRADE_REQUIRED", group.get("errorCode"));
+            verifyNoInteractions(queries);
+        }
+    }
+}

+ 201 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQueryServiceTest.java

@@ -0,0 +1,201 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.MultiStoreCardView;
+import com.ruoyi.app.order.dto.MultiStorePageView;
+import com.ruoyi.app.order.dto.MultiStoreRefundMemberView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.CheckoutCardRow;
+import com.ruoyi.system.domain.CheckoutMemberRow;
+import com.ruoyi.system.mapper.MultiStoreCheckoutQueryMapper;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IPosOrderService;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutQueryServiceTest {
+    private final MultiStoreCheckoutQueryMapper mapper = mock(MultiStoreCheckoutQueryMapper.class);
+    private final MultiStoreCheckoutRefundProjectionService refunds = mock(MultiStoreCheckoutRefundProjectionService.class);
+    private final IPosOrderService ordinaryOrders = mock(IPosOrderService.class);
+    private final MultiStoreCheckoutQueryService service =
+            new MultiStoreCheckoutQueryService(mapper, refunds, ordinaryOrders);
+
+    @Test
+    void userPageCountsBatchOnceAlongsideOrdinaryOrder() {
+        CheckoutCardRow batch = card("BATCH", 7L, "BATCH:7");
+        batch.setCheckoutStatus("PENDING");
+        batch.setCreatedAt(LocalDateTime.of(2026, 10, 19, 12, 0));
+        CheckoutCardRow order = card("ORDER", 9L, "ORDER:9");
+        PosOrder legacy = new PosOrder();
+        legacy.setId(9L);
+        legacy.setFood("legacy-snapshot");
+        when(ordinaryOrders.listByIds(List.of(9L))).thenReturn(List.of(legacy));
+        when(mapper.countUserCards(21L, "all")).thenReturn(2L);
+        when(mapper.selectUserCards(21L, "all", 0, 10)).thenReturn(List.of(batch, order));
+        when(mapper.selectMembersForBatches(List.of(7L))).thenReturn(List.of(
+                member(7L, 71L, 0L, "ONLINE_PAID", 100, 100),
+                member(7L, 72L, 0L, "COD_CONFIRMED", 80, 0)));
+
+        MultiStorePageView result = service.userCards(21L, 1, 10, "all");
+
+        assertEquals(2L, result.total());
+        assertEquals(List.of("BATCH:7", "ORDER:9"), result.records().stream().map(MultiStoreCardView::getCardKey).toList());
+        assertEquals(2, result.records().get(0).getReadyCount());
+        assertEquals(100, result.records().get(0).getOnlinePaidAmount());
+        assertEquals(80, result.records().get(0).getCodPromisedAmount());
+        assertEquals("legacy-snapshot", result.records().get(1).getLegacyOrder().getFood());
+    }
+
+    @Test
+    void deliveredMemberAndUnpickedMemberKeepGroupInPickupTab() {
+        CheckoutCardRow group = card("GROUP", 4L, "GROUP:4");
+        group.setBatchId(7L);
+        group.setGroupStatus("ACCEPTED");
+        when(mapper.selectGroupForRider(4L, 33L, 1000L)).thenReturn(group);
+        CheckoutMemberRow unpicked = member(7L, 72L, 1L, "COD_CONFIRMED", 80, 0);
+        unpicked.setState(2L);
+        when(mapper.selectMembersForBatches(List.of(7L))).thenReturn(List.of(
+                member(7L, 71L, 3L, "ONLINE_PAID", 100, 100), unpicked));
+
+        MultiStoreCardView detail = service.groupDetail(4L, 33L, 1000L);
+
+        assertEquals("toPickup", detail.getPrimaryTab());
+        assertEquals(1, detail.getPickedUpCount());
+        assertEquals(1, detail.getDeliveredCount());
+        assertEquals(2, detail.getActiveCount());
+        assertEquals(List.of("PICKUP_MEMBER"), detail.getMembers().get(1).getAllowedActions());
+    }
+
+    @Test
+    void terminalGroupDetailRetainsItsPrimaryTab() {
+        CheckoutCardRow completed = card("GROUP", 4L, "GROUP:4");
+        completed.setBatchId(7L);
+        completed.setGroupStatus("FINISHED");
+        when(mapper.selectGroupForRider(4L, 33L, 1000L)).thenReturn(completed);
+        when(mapper.selectMembersForBatches(List.of(7L)))
+                .thenReturn(List.of(member(7L, 71L, 3L, "ONLINE_PAID", 100, 100)));
+
+        assertEquals("completed", service.groupDetail(4L, 33L, 1000L).getPrimaryTab());
+
+        CheckoutCardRow cancelled = card("GROUP", 5L, "GROUP:5");
+        cancelled.setBatchId(8L);
+        cancelled.setGroupStatus("CANCELLED");
+        when(mapper.selectGroupForRider(5L, 33L, 1000L)).thenReturn(cancelled);
+        CheckoutMemberRow cancelledMember = member(8L, 81L, 0L, "ONLINE_PAID", 100, 100);
+        cancelledMember.setState(4L);
+        when(mapper.selectMembersForBatches(List.of(8L))).thenReturn(List.of(cancelledMember));
+
+        assertEquals("cancelled", service.groupDetail(5L, 33L, 1000L).getPrimaryTab());
+    }
+
+    @Test
+    void activeWaitingBatchAllowsOnlyPerStoreUserCancellation() {
+        CheckoutCardRow batch = card("BATCH", 7L, "BATCH:7");
+        batch.setCheckoutStatus("ACTIVE"); batch.setGroupStatus("WAITING");
+        when(mapper.selectBatchForUser(7L, 21L)).thenReturn(batch);
+        when(mapper.selectMembersForBatches(List.of(7L)))
+                .thenReturn(List.of(member(7L, 71L, 0L, "COD_CONFIRMED", 80, 0)));
+
+        MultiStoreCardView detail = service.batchDetail(7L, 21L);
+
+        assertEquals(List.of("CANCEL_MEMBER"), detail.getAllowedActions());
+    }
+
+    @Test
+    void refundAmountsUseGatewayFactsNotCancelledOrderFlags() {
+        CheckoutCardRow batch = card("BATCH", 7L, "BATCH:7");
+        CheckoutMemberRow member = member(7L, 71L, 0L, "ONLINE_PAID", 100, 100);
+        member.setMemberId(701L);
+        member.setState(4L);
+        member.setRefundStatus("REFUNDED"); // Legacy order flag must not be trusted.
+        member.setRefundAmount(100);
+        when(mapper.selectBatchForUser(7L, 21L)).thenReturn(batch);
+        when(mapper.selectMembersForBatches(List.of(7L))).thenReturn(List.of(member));
+        when(refunds.project(7L)).thenReturn(Map.of(701L,
+                new MultiStoreRefundMemberView("UNKNOWN", 100, 0, List.of())));
+
+        MultiStoreCardView detail = service.batchDetail(7L, 21L);
+
+        assertEquals(0, detail.getRefundedAmount());
+        assertEquals(100, detail.getRefundPendingAmount());
+        assertEquals("UNKNOWN", detail.getMembers().get(0).getRefundStatus());
+    }
+
+    @Test
+    void riderPageCountsGroupOnceAndCancelledCodIsNotCollectible() {
+        CheckoutCardRow group = card("GROUP", 4L, "GROUP:4");
+        group.setBatchId(7L);
+        group.setGroupStatus("ACCEPTED");
+        group.setPrimaryTab("toPickup");
+        CheckoutCardRow ordinary = card("ORDER", 9L, "ORDER:9");
+        when(mapper.countRiderTasks(33L, "toPickup", null, null, 1000L)).thenReturn(2L);
+        when(mapper.selectRiderTasks(33L, "toPickup", null, null, 1000L, 0, 10))
+                .thenReturn(List.of(group, ordinary));
+        CheckoutMemberRow active = member(7L, 71L, 1L, "COD_CONFIRMED", 80, 0);
+        CheckoutMemberRow cancelled = member(7L, 72L, 0L, "COD_CONFIRMED", 90, 0);
+        cancelled.setState(4L);
+        when(mapper.selectMembersForBatches(List.of(7L))).thenReturn(List.of(active, cancelled));
+
+        MultiStorePageView result = service.riderTasks(33L, 1, 10, "toPickup", null, null, 1000L);
+
+        assertEquals(2L, result.total());
+        assertEquals(1, result.records().get(0).getActiveCount());
+        assertEquals(1, result.records().get(0).getCancelledCount());
+        assertEquals(80, result.records().get(0).getCodToCollectAmount());
+        assertEquals(null, result.records().get(0).getMembers().get(1).getCodToCollectAmount());
+    }
+
+    @Test
+    void invalidPaginationIsRejectedBeforeQuery() {
+        try (org.mockito.MockedStatic<com.ruoyi.common.utils.MessageUtils> messages =
+                     org.mockito.Mockito.mockStatic(com.ruoyi.common.utils.MessageUtils.class)) {
+            messages.when(() -> com.ruoyi.common.utils.MessageUtils.message("multi.store.checkout.invalid"))
+                    .thenReturn("invalid");
+            assertThrows(ServiceException.class, () -> service.userCards(21L, 0, 10, "all"));
+            assertThrows(ServiceException.class, () -> service.userCards(21L, 1, 51, "all"));
+            assertThrows(ServiceException.class, () -> service.riderTasks(33L, 1, 10, "newTask",
+                    BigDecimal.ONE, null, 1000L));
+        }
+    }
+
+    @Test
+    void batchDetailRejectsOtherUserBeforeReadingMembers() {
+        try (org.mockito.MockedStatic<com.ruoyi.common.utils.MessageUtils> messages =
+                     org.mockito.Mockito.mockStatic(com.ruoyi.common.utils.MessageUtils.class)) {
+            messages.when(() -> com.ruoyi.common.utils.MessageUtils.message("multi.store.checkout.invalid"))
+                    .thenReturn("invalid");
+            assertThrows(ServiceException.class, () -> service.batchDetail(7L, 99L));
+        }
+    }
+
+    private static CheckoutCardRow card(String type, Long id, String key) {
+        CheckoutCardRow row = new CheckoutCardRow();
+        row.setCardType(type);
+        row.setCardId(id);
+        row.setCardKey(key);
+        row.setBatchId("BATCH".equals(type) ? id : null);
+        return row;
+    }
+
+    private static CheckoutMemberRow member(Long batchId, Long orderId, Long deliveryStatus,
+                                            String paymentState, int amount, int paid) {
+        CheckoutMemberRow row = new CheckoutMemberRow();
+        row.setBatchId(batchId);
+        row.setOrderId(orderId);
+        row.setDeliveryStatus(deliveryStatus);
+        row.setPaymentState(paymentState);
+        row.setOrderAmount(amount);
+        row.setPaidAmount(paid);
+        row.setState(0L);
+        row.setDiscountedFreight(20);
+        return row;
+    }
+}

+ 154 - 8
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutQuoteServiceTest.java

@@ -1,6 +1,8 @@
 package com.ruoyi.app.order;
 
 import com.ruoyi.app.order.dto.MultiStoreQuoteRequest;
+import com.ruoyi.app.flashdelivery.route.GeoPoint;
+import com.ruoyi.app.order.route.RouteDistanceMatrixProvider;
 import com.ruoyi.app.order.dto.MultiStoreQuoteView;
 import com.ruoyi.common.core.redis.RedisCache;
 import com.ruoyi.common.exception.ServiceException;
@@ -11,6 +13,7 @@ import com.ruoyi.system.domain.PosFood;
 import com.ruoyi.system.domain.PosFreight;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.dto.PromotionDeliveryContext;
 import com.ruoyi.system.mapper.FoodSpecRelationMapper;
 import com.ruoyi.system.mapper.FoodSpecsMapper;
 import com.ruoyi.system.mapper.FoodSpecsValueMapper;
@@ -26,6 +29,7 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.AfterEach;
 import org.mockito.MockedStatic;
+import org.mockito.ArgumentCaptor;
 
 import java.math.BigDecimal;
 import java.time.LocalDate;
@@ -42,6 +46,9 @@ import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 class MultiStoreCheckoutQuoteServiceTest {
@@ -73,10 +80,11 @@ class MultiStoreCheckoutQuoteServiceTest {
     private final SelfDeliveryService selfDelivery = mock(SelfDeliveryService.class);
     private final IPromotionCalcService promotions = mock(IPromotionCalcService.class);
     private final RedisCache redis = mock(RedisCache.class);
+    private final RouteDistanceMatrixProvider routeMatrix = mock(RouteDistanceMatrixProvider.class);
 
     private MultiStoreCheckoutQuoteService subject() {
         return new MultiStoreCheckoutQuoteService(config, addresses, stores, hours, foods,
-                relations, specs, values, freight, payments, qualifications, selfDelivery, promotions, redis);
+                relations, specs, values, freight, payments, qualifications, selfDelivery, promotions, redis, routeMatrix);
     }
 
     @Test
@@ -113,21 +121,81 @@ class MultiStoreCheckoutQuoteServiceTest {
         when(freight.getyunjia()).thenReturn(List.of(fare));
         when(payments.listAvailable(eq(PaymentMethodGateService.GateScope.MERCHANT), anyLong(), anyLong(), eq(0L)))
                 .thenReturn(List.of(new PaymentMethodGateService.PayMethodOption("COD", "1", true)));
-        PromotionCalcResponse promo = new PromotionCalcResponse();
-        promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
-        when(promotions.calculate(any(), eq(42L))).thenReturn(promo);
+        when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
+            PromotionDeliveryContext delivery = call.getArgument(2);
+            PromotionCalcResponse promo = new PromotionCalcResponse();
+            promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
+            promo.setDeliveryFee(delivery.getDeliveryFee());
+            promo.setDeliveryDiscountAmount(BigDecimal.ZERO);
+            promo.setCustomerDeliveryFee(delivery.getDeliveryFee());
+            promo.setPayableAmount(new BigDecimal("50").add(delivery.getDeliveryFee()));
+            return promo;
+        });
 
-        MultiStoreQuoteView quote = subject().quote(request(), 42L);
+        MultiStoreCheckoutQuoteService service = subject();
+        // 围栏仍按直线,运费须按道路路线;5km 路线不能被约 0.1km 直线代替。
+        when(routeMatrix.distancesToDestination(any(), any()))
+                .thenReturn(List.of(new BigDecimal("5000"), new BigDecimal("5000")));
+        MultiStoreQuoteView quote = service.quote(request(), 42L);
 
         assertEquals(List.of(1L, 2L), quote.participatingStoreIds());
         assertEquals("OUTSIDE_FENCE", quote.excludedStores().get(0).reason());
-        assertEquals(new BigDecimal("40"), quote.stores().get(0).originalFreight());
-        assertEquals(new BigDecimal("36"), quote.stores().get(0).discountedFreight());
-        assertEquals(new BigDecimal("86"), quote.stores().get(0).amount());
+        assertEquals(new BigDecimal("80"), quote.stores().get(0).originalFreight());
+        assertEquals(new BigDecimal("72"), quote.stores().get(0).discountedFreight());
+        assertEquals(new BigDecimal("72"), quote.stores().get(0).customerDeliveryFee());
+        assertEquals(new BigDecimal("122"), quote.stores().get(0).amount());
         assertTrue(quote.quoteToken() != null && !quote.quoteToken().isBlank());
         verify(redis).setCacheObject(any(String.class), any(String.class), eq(5), eq(TimeUnit.MINUTES));
     }
 
+    @Test
+    void merchantFreeDeliveryUsesDiscountedFreightAsSubsidyBase() {
+        when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.8")), 1));
+        InfoAddress address = new InfoAddress();
+        address.setId(10L); address.setUserId(42L);
+        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
+        when(addresses.selectById(10L)).thenReturn(address);
+        when(stores.selectById(1L)).thenReturn(store(1, "22.6010", "120.3000"));
+        when(stores.selectById(2L)).thenReturn(store(2, "22.6030", "120.3000"));
+        when(stores.selectById(3L)).thenReturn(store(3, "22.6300", "120.3000"));
+        OperatingHours open = new OperatingHours();
+        open.setMdId(1L); open.setDayOfWeek(LocalDate.now().getDayOfWeek().getValue());
+        open.setIsOpen(true); open.setStartTime("00:00"); open.setEndTime("23:59");
+        when(hours.selectOperatingHoursList(any(OperatingHours.class))).thenReturn(List.of(open));
+        for (long id : List.of(11L, 22L)) {
+            PosFood food = new PosFood();
+            food.setId(id); food.setMdid(id == 11L ? 1L : 2L);
+            food.setPrice(new BigDecimal("50")); food.setStackingUp("0");
+            when(foods.selectById(id)).thenReturn(food);
+        }
+        when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
+        PosFreight fare = new PosFreight();
+        fare.setStartingFare(40L); fare.setStartingDistance(1L); fare.setFreight(10L); fare.setDistance(1L);
+        when(freight.getyunjia()).thenReturn(List.of(fare));
+        when(payments.listAvailable(eq(PaymentMethodGateService.GateScope.MERCHANT), anyLong(), anyLong(), eq(0L)))
+                .thenReturn(List.of(new PaymentMethodGateService.PayMethodOption("COD", "1", true)));
+        when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
+            PromotionDeliveryContext delivery = call.getArgument(2);
+            assertEquals(new BigDecimal("32"), delivery.getDeliveryFee());
+            PromotionCalcResponse promo = new PromotionCalcResponse();
+            promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
+            promo.setDeliveryFee(delivery.getDeliveryFee());
+            promo.setDeliveryDiscountAmount(delivery.getDeliveryFee());
+            promo.setCustomerDeliveryFee(BigDecimal.ZERO);
+            promo.setPayableAmount(new BigDecimal("50"));
+            return promo;
+        });
+        MultiStoreCheckoutQuoteService service = subject();
+        when(routeMatrix.distancesToDestination(any(), any()))
+                .thenReturn(List.of(new BigDecimal("1000"), new BigDecimal("1000")));
+        MultiStoreQuoteView quote = service.quote(request(), 42L);
+        assertEquals(new BigDecimal("32"), quote.stores().get(0).discountedFreight());
+        assertEquals(new BigDecimal("32"), quote.stores().get(0).merchantDeliverySubsidy());
+        assertEquals(BigDecimal.ZERO, quote.stores().get(0).customerDeliveryFee());
+        assertEquals(new BigDecimal("50"), quote.stores().get(0).amount());
+    }
+
     @Test
     void rejectsForeignAddressBeforeAnyQuote() {
         when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
@@ -137,6 +205,84 @@ class MultiStoreCheckoutQuoteServiceTest {
         assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
     }
 
+    @Test
+    void batchesOnlyEligibleStoresAndKeepsEachStoresOwnDistanceAndFreight() {
+        stubEligibleStores();
+        when(routeMatrix.distancesToDestination(any(), any()))
+                .thenReturn(List.of(new BigDecimal("1000"), new BigDecimal("5000")));
+
+        MultiStoreQuoteView quote = subject().quote(request(), 42L);
+
+        assertEquals(List.of(1L, 2L), quote.participatingStoreIds());
+        assertEquals(new BigDecimal("40"), quote.stores().get(0).originalFreight());
+        assertEquals(new BigDecimal("80"), quote.stores().get(1).originalFreight());
+        assertEquals(new BigDecimal("86"), quote.stores().get(0).amount());
+        assertEquals(new BigDecimal("122"), quote.stores().get(1).amount());
+        ArgumentCaptor<List<GeoPoint>> origins = ArgumentCaptor.forClass(List.class);
+        ArgumentCaptor<GeoPoint> destination = ArgumentCaptor.forClass(GeoPoint.class);
+        verify(routeMatrix).distancesToDestination(origins.capture(), destination.capture());
+        assertEquals(List.of(new GeoPoint(new BigDecimal("22.6010"), new BigDecimal("120.3000")),
+                new GeoPoint(new BigDecimal("22.6030"), new BigDecimal("120.3000"))), origins.getValue());
+        assertEquals(new BigDecimal("22.6000"), destination.getValue().latitude());
+        assertEquals("OUTSIDE_FENCE", quote.excludedStores().get(0).reason());
+    }
+
+    @Test
+    void matrixFailureOrIncompleteDistancesCannotIssueQuoteToken() {
+        stubEligibleStores();
+        when(routeMatrix.distancesToDestination(any(), any())).thenThrow(new IllegalStateException("element failed"));
+        ServiceException failure = assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
+        assertEquals("multi.store.checkout.freight.invalid", failure.getMessage());
+        doReturn(List.of(new BigDecimal("1000"))).when(routeMatrix).distancesToDestination(any(), any());
+        assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
+        verify(redis, never()).setCacheObject(any(String.class), any(String.class), any(Integer.class), any(TimeUnit.class));
+    }
+
+    @Test
+    void fewerThanTwoEligibleStoresFailBeforeAnyMatrixRequest() {
+        stubEligibleStores();
+        when(stores.selectById(2L)).thenReturn(store(2, "22.6300", "120.3000"));
+        assertThrows(ServiceException.class, () -> subject().quote(request(), 42L));
+        verifyNoInteractions(routeMatrix);
+    }
+
+    private void stubEligibleStores() {
+        when(config.current()).thenReturn(new MultiStoreCheckoutConfig(true, 1000, 5, 5,
+                Map.of(2, new BigDecimal("0.9")), 1));
+        InfoAddress address = new InfoAddress();
+        address.setId(10L); address.setUserId(42L);
+        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
+        when(addresses.selectById(10L)).thenReturn(address);
+        when(stores.selectById(1L)).thenReturn(store(1, "22.6010", "120.3000"));
+        when(stores.selectById(2L)).thenReturn(store(2, "22.6030", "120.3000"));
+        when(stores.selectById(3L)).thenReturn(store(3, "22.6300", "120.3000"));
+        OperatingHours open = new OperatingHours();
+        open.setMdId(1L); open.setDayOfWeek(LocalDate.now().getDayOfWeek().getValue());
+        open.setIsOpen(true); open.setStartTime("00:00"); open.setEndTime("23:59");
+        when(hours.selectOperatingHoursList(any(OperatingHours.class))).thenReturn(List.of(open));
+        for (long id : List.of(11L, 22L)) {
+            PosFood food = new PosFood();
+            food.setId(id); food.setMdid(id == 11L ? 1L : 2L);
+            food.setPrice(new BigDecimal("50")); food.setStackingUp("0");
+            when(foods.selectById(id)).thenReturn(food);
+        }
+        when(relations.selectFoodSpecRelationList(any())).thenReturn(List.of());
+        PosFreight fare = new PosFreight();
+        fare.setStartingFare(40L); fare.setStartingDistance(1L); fare.setFreight(10L); fare.setDistance(1L);
+        when(freight.getyunjia()).thenReturn(List.of(fare));
+        when(payments.listAvailable(eq(PaymentMethodGateService.GateScope.MERCHANT), anyLong(), anyLong(), eq(0L)))
+                .thenReturn(List.of(new PaymentMethodGateService.PayMethodOption("COD", "1", true)));
+        when(promotions.calculate(any(), eq(42L), any(PromotionDeliveryContext.class))).thenAnswer(call -> {
+            PromotionDeliveryContext delivery = call.getArgument(2);
+            PromotionCalcResponse promo = new PromotionCalcResponse();
+            promo.setOriginalAmount(new BigDecimal("50")); promo.setFinalAmount(new BigDecimal("50"));
+            promo.setDeliveryFee(delivery.getDeliveryFee()); promo.setDeliveryDiscountAmount(BigDecimal.ZERO);
+            promo.setCustomerDeliveryFee(delivery.getDeliveryFee());
+            promo.setPayableAmount(new BigDecimal("50").add(delivery.getDeliveryFee()));
+            return promo;
+        });
+    }
+
     private MultiStoreQuoteRequest request() {
         return new MultiStoreQuoteRequest(10L, true, List.of(
                 new MultiStoreQuoteRequest.Store(1L, null, List.of(new MultiStoreQuoteRequest.Item(11L, 1, List.of())), null, null),

+ 80 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutRefundBridgeServiceTest.java

@@ -0,0 +1,80 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.omgpay.OmgPaymentRefundService;
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.system.domain.OrderCheckoutRefundIntent;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.mapper.OrderCheckoutMapper;
+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.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MultiStoreCheckoutRefundBridgeServiceTest {
+    private final OrderCheckoutMapper checkout = mock(OrderCheckoutMapper.class);
+    private final IPosOrderLinePaymentService linePayments = mock(IPosOrderLinePaymentService.class);
+    private final LinePayRefundService lineRefunds = mock(LinePayRefundService.class);
+    private final IOmgPaymentAttemptService omgAttempts = mock(IOmgPaymentAttemptService.class);
+    private final OmgPaymentRefundService omgRefunds = mock(OmgPaymentRefundService.class);
+    private final MultiStoreCheckoutRefundBridgeService service = new MultiStoreCheckoutRefundBridgeService(
+            checkout, linePayments, lineRefunds, omgAttempts, omgRefunds);
+
+    @Test
+    void lineIntentUsesExistingIdempotentRefundQueueForExactTransaction() {
+        OrderCheckoutRefundIntent intent = intent("LINE", "L-1", "TX-1", 86);
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(77L); payment.setLineOrderId("L-1");
+        payment.setTransactionId("TX-1"); payment.setAmount(86); payment.setStatus("PAID");
+        when(checkout.selectRefundIntentForUpdate(1L)).thenReturn(intent);
+        when(linePayments.getByLineOrderId("L-1")).thenReturn(payment);
+        when(checkout.markRefundBridged(1L)).thenReturn(1);
+
+        service.bridge(1L);
+
+        verify(lineRefunds).createRefundIntent(77L, "MULTI_STORE");
+        verify(checkout).markRefundBridged(1L);
+    }
+
+    @Test
+    void omgIntentUsesExistingRefundQueueForExactCapturedAttempt() {
+        OrderCheckoutRefundIntent intent = intent("OMG", "OMG-1", "GW-1", 86);
+        OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+        attempt.setId(88L); attempt.setMerchantTradeNo("OMG-1");
+        attempt.setTradeNo("GW-1"); attempt.setAmount(86); attempt.setAttemptStatus(1);
+        when(checkout.selectRefundIntentForUpdate(1L)).thenReturn(intent);
+        when(omgAttempts.selectByMerchantTradeNo("OMG-1")).thenReturn(attempt);
+        when(checkout.markRefundBridged(1L)).thenReturn(1);
+
+        service.bridge(1L);
+
+        verify(omgRefunds).createRefundIntentForAttempt(88L, "MULTI_STORE");
+        verify(checkout).markRefundBridged(1L);
+    }
+
+    @Test
+    void mismatchedGatewayTransactionNeverQueuesRefundForAnotherPayment() {
+        OrderCheckoutRefundIntent intent = intent("LINE", "L-1", "TX-1", 86);
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(77L); payment.setLineOrderId("L-1");
+        payment.setTransactionId("TX-OTHER"); payment.setAmount(86); payment.setStatus("PAID");
+        when(checkout.selectRefundIntentForUpdate(1L)).thenReturn(intent);
+        when(linePayments.getByLineOrderId("L-1")).thenReturn(payment);
+
+        assertThrows(IllegalStateException.class, () -> service.bridge(1L));
+        org.mockito.Mockito.verifyNoInteractions(lineRefunds);
+    }
+
+    private OrderCheckoutRefundIntent intent(String channel, String merchantOrderNo,
+                                             String transactionId, int amount) {
+        OrderCheckoutRefundIntent intent = new OrderCheckoutRefundIntent();
+        intent.setId(1L); intent.setChannel(channel); intent.setStatus("PENDING");
+        intent.setMerchantOrderNo(merchantOrderNo);
+        intent.setGatewayTransactionId(transactionId); intent.setAmount(amount);
+        return intent;
+    }
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff