Browse Source

调整外卖订单为骑手先接单

支付成功后开放外送订单给骑手。
骑手接单后通知商家并开放商家操作。
统一订单取消以及用户、商家和骑手推送处理。
qmj 1 day ago
parent
commit
1f34e984bc
26 changed files with 581 additions and 87 deletions
  1. 24 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  2. 143 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  3. 2 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  4. 10 27
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  5. 16 16
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  6. 13 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  7. 25 21
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  8. 1 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java
  9. 16 8
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderNotificationService.java
  10. 3 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  11. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  12. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  13. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 3 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 27 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  16. 71 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java
  17. 12 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  18. 74 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java
  19. 64 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java
  20. 5 1
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderPositionMapper.java
  21. 8 0
      specs/006-orderstate/plan.md
  22. 27 0
      specs/006-orderstate/spec.md
  23. 7 0
      specs/006-orderstate/tasks.md
  24. 2 2
      specs/020-omg-payment-rebuild/plan.md
  25. 11 4
      specs/020-omg-payment-rebuild/spec.md
  26. 8 2
      specs/020-omg-payment-rebuild/tasks.md

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -1,9 +1,14 @@
 package com.ruoyi.app.omgpay;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IPosOrderService;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
@@ -34,6 +39,10 @@ public class OmgPaymentNotifyService {
 
     private final IOmgPaymentAttemptService attempts;
     private final OmgCheckMacSigner signer;
+    @Autowired(required = false)
+    private IPosOrderService posOrderService;
+    @Autowired(required = false)
+    private DeliveryOrderNotificationService deliveryOrderNotificationService;
 
     public OmgPaymentNotifyService(IOmgPaymentAttemptService attempts, OmgCheckMacSigner signer) {
         this.attempts = attempts;
@@ -142,6 +151,7 @@ public class OmgPaymentNotifyService {
         attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
         if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
             requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
+            openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
@@ -160,6 +170,20 @@ public class OmgPaymentNotifyService {
         return OmgPaymentSettlementResult.PAID;
     }
 
+    private void openDeliveryOrderToRiders(String ddId, Long state) {
+        if (Long.valueOf(4L).equals(state) || posOrderService == null
+                || deliveryOrderNotificationService == null) {
+            return;
+        }
+        PosOrder paidOrder = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getDdId, ddId));
+        if (paidOrder == null) {
+            return;
+        }
+        paidOrder.setPayStatus(1L);
+        deliveryOrderNotificationService.notifyOrderAvailable(paidOrder);
+    }
+
     private boolean verifyTrust(OmgNotifyRequest request, OmgPaymentAttempt attempt) {
         if (!request.value("MerchantID").equals(attempt.getMerchantId())) {
             return false;

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

@@ -0,0 +1,143 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.framework.manager.AsyncManager;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.system.mapper.RiderPositionMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.TimerTask;
+
+/** 外送订单开放接单及取消后的推送。 */
+@Service
+public class DeliveryOrderNotificationService {
+    private static final int RIDER_NOTIFICATION_LIMIT = 20;
+
+    private final RiderPositionMapper riderPositionMapper;
+    private final PushEventService pushEventService;
+    private final IInfoUserService infoUserService;
+
+    public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
+                                            PushEventService pushEventService) {
+        this(riderPositionMapper, pushEventService, null);
+    }
+
+    @Autowired
+    public DeliveryOrderNotificationService(RiderPositionMapper riderPositionMapper,
+                                            PushEventService pushEventService,
+                                            IInfoUserService infoUserService) {
+        this.riderPositionMapper = riderPositionMapper;
+        this.pushEventService = pushEventService;
+        this.infoUserService = infoUserService;
+    }
+
+    /** 支付完成且仍待骑手接单时,通知附近可接单骑手。 */
+    public void notifyOrderAvailable(PosOrder order) {
+        if (!isAvailableForRider(order)) {
+            return;
+        }
+        runAfterCommit(() -> {
+            List<RiderPosition> riders = riderPositionMapper.getAcceptRiderList(
+                    order.getLongitude(), order.getLatitude(), RIDER_NOTIFICATION_LIMIT);
+            if (riders != null) {
+                riders.forEach(rider -> sendAvailableOrderToRider(rider, order));
+            }
+        });
+    }
+
+    /** 已分配骑手的外送订单取消后,通知除取消人外的相关方。 */
+    public void notifyOrderCancelled(PosOrder order, Long operatorId) {
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || order.getQsId() == null || infoUserService == null) {
+            return;
+        }
+        runAfterCommit(() -> {
+            Set<Long> recipientIds = new LinkedHashSet<>();
+            recipientIds.add(order.getUserId());
+            recipientIds.add(order.getShId());
+            recipientIds.add(order.getQsId());
+            recipientIds.remove(null);
+            recipientIds.remove(operatorId);
+            if (recipientIds.isEmpty()) {
+                return;
+            }
+            List<InfoUser> recipients = infoUserService.listByIds(recipientIds);
+            if (recipients != null) {
+                recipients.forEach(recipient -> sendCancelledOrder(recipient, order));
+            }
+        });
+    }
+
+    void sendAvailableOrderToRider(RiderPosition rider, PosOrder order) {
+        if (rider == null || rider.getRiderId() == null) {
+            return;
+        }
+        String ddId = String.valueOf(order.getDdId());
+        String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
+        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.order", body, "", ddId);
+            }
+        });
+    }
+
+    void sendCancelledOrder(InfoUser recipient, PosOrder order) {
+        if (recipient == null || recipient.getUserId() == null) {
+            return;
+        }
+        String ddId = String.valueOf(order.getDdId());
+        String body = OrderPushBodyDto.getJson(ddId, "4", 0);
+        AsyncManager.me().execute(new TimerTask() {
+            @Override
+            public void run() {
+                PayPush push = new PayPush();
+                if (recipient.getUserId().equals(order.getQsId())) {
+                    PayPush.qsPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
+                            "no.message.push.message", "no.message.push.order.cancelled", body, "", ddId);
+                } else if (recipient.getUserId().equals(order.getShId())) {
+                    PayPush.shPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
+                            "no.message.push.message", "no.message.push.order.cancelled", body, "", ddId);
+                } else {
+                    PayPush.userPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
+                            "no.message.push.message", "no.message.push.order.cancelled", body, "", ddId);
+                }
+            }
+        });
+    }
+
+    private boolean isAvailableForRider(PosOrder order) {
+        return order != null
+                && Long.valueOf(0L).equals(order.getType())
+                && Long.valueOf(0L).equals(order.getState())
+                && Long.valueOf(0L).equals(order.getDeliveryStatus())
+                && Long.valueOf(1L).equals(order.getPayStatus())
+                && Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                && order.getQsId() == null;
+    }
+
+    private static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+}

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

@@ -133,8 +133,9 @@ public class OrderLifecycleService {
     @Transactional(rollbackFor = Exception.class)
     public void acceptDeliveryByRider(Long id, Long riderId) {
         PosOrder order = requireOrder(id);
-        if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(2L).equals(order.getState())
+        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())
                 || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null) {
             throw conflict();
         }

+ 10 - 27
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -44,7 +44,6 @@ import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
 import com.ruoyi.system.mapper.PosFoodMapper;
 import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosOrderPromotionMapper;
-import com.ruoyi.system.mapper.RiderPositionMapper;
 import com.ruoyi.system.mapper.UserBillingMapper;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
@@ -122,13 +121,13 @@ public class PosOrderController extends BaseController {
     @Autowired
     private IUserWalletService userWalletService;
     @Autowired
-    private RiderPositionMapper riderPositionMapper;
-    @Autowired
     private IPointsTransactionService pointsTransactionService;
 
     @Autowired
     private PushEventService pushEventService;
     @Autowired
+    private DeliveryOrderNotificationService deliveryOrderNotificationService;
+    @Autowired
     private IPosOrderRatingService posOrderRatingService;
     @Autowired
     private IFoodStatisticsService foodStatisticsService;
@@ -269,7 +268,7 @@ public class PosOrderController extends BaseController {
                 boolean org = posOrderService.saveOrUpdate(posOrder);
                 PosOrder order = posOrderService.getById(posOrder.getId());
                 if (order.getState() == 0 && order.getCollectPayment().equals("1")) {
-                    sendHdfkMessage(order, push);
+                    sendHdfkMessage(order);
                 }
                 //作废退回积分
                 if (posOrder.getState() == 10 && order.getPoints() != null && order.getPoints() > 0) {
@@ -998,7 +997,6 @@ public class PosOrderController extends BaseController {
         System.out.println("========== 接收到的orderDTO ==========");
         System.out.println(mapper.writeValueAsString(orderDTO));
         JwtUtil jwtUtil = new JwtUtil();
-        PayPush push = new PayPush();
         DateUtil dateUtil = new DateUtil();
         String id = jwtUtil.getusid(token);
         QueryWrapper<OperatingHours> wrapper = new QueryWrapper<>();
@@ -1140,6 +1138,10 @@ public class PosOrderController extends BaseController {
         posOrder.setJuli(orderDTO.getJuli());
         posOrder.setPayUrl(orderDTO.getPayUrl());
         posOrder.setCollectPayment(orderDTO.getCollectPayment());
+        posOrder.setAfterSaleStatus(0L);
+        posOrder.setDeliveryStatus(Long.valueOf(0L).equals(orderDTO.getType()) ? 0L : null);
+        posOrder.setPayStatus(Long.valueOf(0L).equals(orderDTO.getType())
+                && "1".equals(orderDTO.getCollectPayment()) ? 1L : 0L);
 
         // posOrder.setCretim(dateUtil.getDatetim(orderDTO.getCretim()));
         //创建时间改成后端生成
@@ -1207,7 +1209,7 @@ public class PosOrderController extends BaseController {
             PosOrder order = posOrderService.getOne(querywra);
             //货到付款、类型外卖,推送给骑手有新订单了
             if (order.getCollectPayment().equals("1") && order.getType() == 0) {
-                sendHdfkMessage(order, push);
+                sendHdfkMessage(order);
             }
 
             //存在使用优惠券的话,核销优惠券
@@ -1241,28 +1243,9 @@ public class PosOrderController extends BaseController {
      * 货到付款发送推送
      *
      * @param order
-     * @param push
-     */
-    private void sendHdfkMessage(PosOrder order, PayPush push) {
-        InfoUser sh = infoUserService.getById(order.getShId());
-        push.shpush(sh.getCid(), MessageUtils.message("no.message.push.message"), MessageUtils.message("no.message.push.new.order"), OrderPushBodyDto.getJson(String.valueOf(order.getDdId()), String.valueOf(order.getState()), 0));
-        pushEventService.PublisherEvent(sh.getUserId(), MessageUtils.message("no.message.push.message"), MessageUtils.message("no.message.push.new.order"), OrderPushBodyDto.getJson(String.valueOf(order.getDdId()), String.valueOf(order.getState()), 0));
-        long sendQsPushStart = System.currentTimeMillis(); // sendQsPush 开始时间
-        if (order.getType() == 0) {
-            sendQsPush(order, push);
-        }
-        long sendQsPushEnd = System.currentTimeMillis(); // sendQsPush 结束时间
-        System.out.println("sendQsPush 执行耗时: " + (sendQsPushEnd - sendQsPushStart) + " ms");
-    }
-
-    /**
-     * 推送可接单骑手
-     *
-     * @param order
-     * @param push
      */
-    protected void sendQsPush(PosOrder order, PayPush push) {
-
+    private void sendHdfkMessage(PosOrder order) {
+        deliveryOrderNotificationService.notifyOrderAvailable(order);
     }
 
     //核销积分

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

@@ -179,18 +179,13 @@ public class PosOrderQsOprateController extends BaseController {
                         QueryWrapper<PosOrder> wrapper = new QueryWrapper<>();
                         wrapper.eq("qs_id", qsId);
                         wrapper.in("delivery_status", 1, 2);
+                        wrapper.in("state", 0, 1, 2);
+                        wrapper.eq("after_sale_status", 0);
                         List<PosOrder> orsts = posOrderService.list(wrapper);
                         if (orsts.size() > 0) {
                             throw new ServiceException(MessageUtils.message("no.exist.undelivered.order"));
                         }
                     }
-                    String userMessage = MessageUtils.message("no.message.push.delivery.personnel.receiving.order");
-                    if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 2L) {
-                        userMessage = MessageUtils.message("no.message.push.delivery.personnel.qspsz.order");
-                    } else if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 3L) {
-                        userMessage = MessageUtils.message("no.message.push.delivery.personnel.qsysd.order");
-                    }
-
                     boolean delivered = posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 3L;
                     boolean org;
                     if (delivered) {
@@ -213,7 +208,6 @@ public class PosOrderQsOprateController extends BaseController {
                         }
                         InfoUser user = infoUserService.getById(orst.getUserId());
                         InfoUser shu = infoUserService.getById(orst.getShId());
-                        String finalUserMessage = userMessage;
                         releaseInfinally = false;
                         TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
                             @Override
@@ -222,7 +216,7 @@ public class PosOrderQsOprateController extends BaseController {
                                     lock.unlock();
                                 }
                                 // 给用户推送(骑手已接单/配送中/已送达)
-                                if (!StringUtils.isEmpty(user.getCid())) {
+                                if (user != null && !StringUtils.isEmpty(user.getCid())) {
                                     String userTitle = "no.message.push.message";
                                     String userContent = "no.message.push.delivery.personnel.receiving.order";
                                     if (posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 2L) {
@@ -231,7 +225,7 @@ public class PosOrderQsOprateController extends BaseController {
                                         userContent = "no.message.push.delivery.personnel.qsysd.order";
                                     }
                                     final String finalUserContent = userContent;
-                                    String userBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(posOrder.getState()));
+                                    String userBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(orst.getState()));
                                     Long userUserId = user.getUserId();
                                     String userCid = user.getCid();
                                     String userCidType = "";
@@ -244,10 +238,10 @@ public class PosOrderQsOprateController extends BaseController {
                                     });
                                 }
                                 // 给商家推送(骑手已接单时)
-                                if (!StringUtils.isEmpty(shu.getCid()) && posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
+                                if (shu != null && posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
                                     String shTitle = "no.message.push.message";
-                                    String shContent = "no.message.push.delivery.personnel.receiving.order";
-                                    String shBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(posOrder.getState()));
+                                    String shContent = "no.message.push.rider.accepted.merchant";
+                                    String shBody = OrderPushBodyDto.getJson(String.valueOf(orst.getDdId()), String.valueOf(orst.getState()));
                                     Long shUserId = shu.getUserId();
                                     String shCid = shu.getCid();
                                     String shCidType = "";
@@ -341,7 +335,9 @@ public class PosOrderQsOprateController extends BaseController {
         wrapper.eq(PosOrder::getIsDisplay,true);
         switch (tab) {
             case "newTask":
-                wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getState, 2L).eq(PosOrder::getAfterSaleStatus, 0L);
+                wrapper.eq(PosOrder::getType, 0L).eq(PosOrder::getDeliveryStatus, 0L)
+                        .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
+                        .eq(PosOrder::getAfterSaleStatus, 0L).isNull(PosOrder::getQsId);
                 if (longitude != null && latitude != null) {
                     // 距离上限(公里)从字典取,未配置则不过滤
                     Integer limitKm = getNewTaskDistanceLimit();
@@ -356,10 +352,14 @@ public class PosOrderQsOprateController extends BaseController {
                 }
                 break;
             case "toPickup":
-                wrapper.eq(PosOrder::getDeliveryStatus, 1L).eq(PosOrder::getQsId, riderId).eq(PosOrder::getAfterSaleStatus, 0L).orderByAsc(PosOrder::getCretim);
+                wrapper.eq(PosOrder::getDeliveryStatus, 1L).eq(PosOrder::getQsId, riderId)
+                        .in(PosOrder::getState, 0L, 1L, 2L)
+                        .eq(PosOrder::getAfterSaleStatus, 0L).orderByAsc(PosOrder::getCretim);
                 break;
             case "delivering":
-                wrapper.eq(PosOrder::getDeliveryStatus, 2L).eq(PosOrder::getQsId, riderId).eq(PosOrder::getAfterSaleStatus, 0L).orderByAsc(PosOrder::getCretim);
+                wrapper.eq(PosOrder::getDeliveryStatus, 2L).eq(PosOrder::getQsId, riderId)
+                        .eq(PosOrder::getState, 2L).eq(PosOrder::getAfterSaleStatus, 0L)
+                        .orderByAsc(PosOrder::getCretim);
                 break;
                 //已送达
             case "completed":

+ 13 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -70,6 +70,8 @@ public class PosOrderShOprateController extends BaseController {
     private IPosOrderLinePaymentService linePaymentService;
     @Autowired
     private OrderLifecycleService orderLifecycleService;
+    @Autowired
+    private DeliveryOrderNotificationService deliveryOrderNotificationService;
 
 
 
@@ -291,6 +293,7 @@ public class PosOrderShOprateController extends BaseController {
         InfoUser currentUser = currentMerchant(token);
         linePayOrderGuard.requireMerchantOwnership(order, currentUser);
         linePayOrderGuard.requirePaidBeforeAccept(order);
+        requireRiderAssigned(order);
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(1L);
@@ -303,7 +306,7 @@ public class PosOrderShOprateController extends BaseController {
 
     /**
      * 商家出餐:state 从 1 改为 2
-     * 外送订单额外设置 deliveryStatus=0(等待骑手接单)
+     * 外送订单保持 deliveryStatus=1(骑手已接单)
      */
     @Anonymous
     @Auth
@@ -315,13 +318,10 @@ public class PosOrderShOprateController extends BaseController {
         }
         linePayOrderGuard.requireMerchantOwnership(order, currentMerchant(token));
         linePayOrderGuard.requirePaidBeforeAccept(order);
+        requireRiderAssigned(order);
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(2L);
-        // 外送订单:设置 deliveryStatus=0 等待骑手接单
-        if (order.getType() != null && order.getType() == 0L) {
-            update.setDeliveryStatus(0L);
-        }
         posOrderService.saveOrUpdate(update);
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
@@ -437,9 +437,17 @@ public class PosOrderShOprateController extends BaseController {
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "取消订单");
+        deliveryOrderNotificationService.notifyOrderCancelled(order, currentUser.getUserId());
         return AjaxResult.success();
     }
 
+    private void requireRiderAssigned(PosOrder order) {
+        if (Long.valueOf(0L).equals(order.getType())
+                && (!Long.valueOf(1L).equals(order.getDeliveryStatus()) || order.getQsId() == null)) {
+            throw new ServiceException(MessageUtils.message("no.order.rider.accept.required"));
+        }
+    }
+
     private InfoUser currentMerchant(String token) {
         Long userId = Long.valueOf(new JwtUtil().getusid(token));
         InfoUser user = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()

+ 25 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -85,6 +85,8 @@ public class UserOrderController extends BaseController {
     private LinePayRefundService linePayRefundService;
     @Autowired
     private OrderLifecycleService orderLifecycleService;
+    @Autowired
+    private DeliveryOrderNotificationService deliveryOrderNotificationService;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -246,10 +248,6 @@ public class UserOrderController extends BaseController {
             posOrder.setAmount(item.getAmount());
             posOrder.setRemarks(item.getRemarks());
             posOrder.setType(item.getType());
-            //外卖设置配送状态为
-            if(0L==posOrder.getType()){
-                posOrder.setDeliveryStatus(0L);
-            }
             posOrder.setDelryTime(item.getDelryTime());
             posOrder.setFood(JSON.toJSONString(item.getFood()));
             if("1".equals(input.getPaymentMethod())){
@@ -277,7 +275,7 @@ public class UserOrderController extends BaseController {
                 posOrder.setState(0L);
             }
             posOrder.setAfterSaleStatus(0L);
-            posOrder.setDeliveryStatus(null);
+            posOrder.setDeliveryStatus(Long.valueOf(0L).equals(item.getType()) ? 0L : null);
             // 外送到付(payType=1):payStatus=1;其他:payStatus=0
             if (item.getType() != null && item.getType() == 0 && "1".equals(input.getPaymentMethod())) {
                 posOrder.setPayStatus(1L);
@@ -345,22 +343,27 @@ public class UserOrderController extends BaseController {
             String uName = uUser != null ? uUser.getNickName() : "";
             orderLogHelper.log(subddId, 4, userId, uName, "用户" + uName + "创建订单");
 
-            // 创建订单成功,给商家推送"您有新订单了"
-            InfoUser shUser = infoUserService.getById(item.getShId());
-            if (shUser != null && !StringUtils.isEmpty(shUser.getCid())) {
-                PayPush push = new PayPush();
-                String title = "no.message.push.message";
-                String content = "no.message.push.new.order";
-                String body = OrderPushBodyDto.getJson(subddId, String.valueOf(posOrder.getState()), 0);
-                Long shUserId = shUser.getUserId();
-                String cid = shUser.getCid();
-
-                AsyncManager.me().execute(new TimerTask() {
-                    @Override
-                    public void run() {
-                        PayPush.shPushHandleLocal(push, pushEventService, shUserId, cid, title, content, body, "", subddId);
-                    }
-                });
+            if (Long.valueOf(0L).equals(posOrder.getType())) {
+                // 到付订单创建后立即开放;在线支付订单由支付成功回调开放。
+                deliveryOrderNotificationService.notifyOrderAvailable(posOrder);
+            } else {
+                // 自取和堂食保持原流程,创建后直接通知商家。
+                InfoUser shUser = infoUserService.getById(item.getShId());
+                if (shUser != null && !StringUtils.isEmpty(shUser.getCid())) {
+                    PayPush push = new PayPush();
+                    String title = "no.message.push.message";
+                    String content = "no.message.push.new.order";
+                    String body = OrderPushBodyDto.getJson(subddId, String.valueOf(posOrder.getState()), 0);
+                    Long shUserId = shUser.getUserId();
+                    String cid = shUser.getCid();
+
+                    AsyncManager.me().execute(new TimerTask() {
+                        @Override
+                        public void run() {
+                            PayPush.shPushHandleLocal(push, pushEventService, shUserId, cid, title, content, body, "", subddId);
+                        }
+                    });
+                }
             }
         }
     }
@@ -746,6 +749,7 @@ public class UserOrderController extends BaseController {
         InfoUser uUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(userId)));
         String uName = uUser != null ? uUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 4, Long.valueOf(userId), uName, "用户" + uName + "取消订单");
+        deliveryOrderNotificationService.notifyOrderCancelled(order, Long.valueOf(userId));
 
         return success("取消订单成功");
     }

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java

@@ -81,6 +81,7 @@ public class LinePayFactService {
             int orderMarked = orderMapper.markLinePaid(payment.getDdId(), payment.getId());
             if (orderMarked == 1 && !Long.valueOf(4L).equals(order.getState())
                     && notifyOrder && notificationService != null) {
+                order.setPayStatus(1L);
                 notificationService.paymentCaptured(order);
             } else if (orderMarked != 1 && !Long.valueOf(4L).equals(order.getState())
                     && !isOnlyRecordedLinePayment(order, payment)) {

+ 16 - 8
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderNotificationService.java

@@ -1,6 +1,7 @@
 package com.ruoyi.app.pay;
 
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.utils.LocaleUtils;
@@ -25,13 +26,16 @@ public class LinePayOrderNotificationService {
     private final IInfoUserService infoUserService;
     private final PushEventService pushEventService;
     private final OrderLogHelper orderLogHelper;
+    private final DeliveryOrderNotificationService deliveryOrderNotificationService;
 
     public LinePayOrderNotificationService(IInfoUserService infoUserService,
                                            PushEventService pushEventService,
-                                           OrderLogHelper orderLogHelper) {
+                                           OrderLogHelper orderLogHelper,
+                                           DeliveryOrderNotificationService deliveryOrderNotificationService) {
         this.infoUserService = infoUserService;
         this.pushEventService = pushEventService;
         this.orderLogHelper = orderLogHelper;
+        this.deliveryOrderNotificationService = deliveryOrderNotificationService;
     }
 
     public void paymentCaptured(PosOrder order) {
@@ -54,13 +58,17 @@ public class LinePayOrderNotificationService {
                 new PayPush().apppush(user.getCid(), title, content, body);
                 pushEventService.PublisherEvent(user.getUserId(), title, content, body);
             }
-            InfoUser merchant = order.getShId() == null ? null : infoUserService.getById(order.getShId());
-            if (merchant != null) {
-                Locale merchantLocale = LocaleUtils.getUserLocale(merchant.getUserId());
-                String title = MessageUtils.message("no.message.push.message", merchantLocale);
-                String content = MessageUtils.message("no.message.push.new.order", merchantLocale);
-                new PayPush().shpush(merchant.getCid(), title, content, body);
-                pushEventService.PublisherEvent(merchant.getUserId(), title, content, body);
+            if (Long.valueOf(0L).equals(order.getType())) {
+                deliveryOrderNotificationService.notifyOrderAvailable(order);
+            } else {
+                InfoUser merchant = order.getShId() == null ? null : infoUserService.getById(order.getShId());
+                if (merchant != null) {
+                    Locale merchantLocale = LocaleUtils.getUserLocale(merchant.getUserId());
+                    String title = MessageUtils.message("no.message.push.message", merchantLocale);
+                    String content = MessageUtils.message("no.message.push.new.order", merchantLocale);
+                    new PayPush().shpush(merchant.getCid(), title, content, body);
+                    pushEventService.PublisherEvent(merchant.getUserId(), title, content, body);
+                }
             }
         } catch (Exception exception) {
             log.error("LINE Pay payment success push failed, ddId={}", order.getDdId(), exception);

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

@@ -104,6 +104,9 @@ no.order.id.error=Mã đơn hàng không đúng
 no.order.create.success=Đơn hàng đã được tạo thành công
 no.message.push.message=tin nhắn
 no.message.push.delivery.personnel.receiving.order=Người giao hàng đã nhận đơn
+no.message.push.rider.accepted.merchant=Tài xế đã nhận đơn, vui lòng xác nhận và chuẩn bị món
+no.message.push.order.cancelled=Đơn hàng đã bị hủy
+no.order.rider.accept.required=Vui lòng đợi tài xế nhận đơn trước khi thao tác
 no.message.push.delivery.personnel.qspsz.order=Shipper đang giao đến
 no.message.push.delivery.personnel.qsysd.order=Shipper đã giao xong.
 no.message.push.new.order=có lệnh mới

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

@@ -104,6 +104,9 @@ no.order.id.error=Order id is incorrect
 no.order.create.success=Order created successfully
 no.message.push.message=Message
 no.message.push.delivery.personnel.receiving.order=Rider has received the order
+no.message.push.rider.accepted.merchant=A rider has accepted the order. Please confirm and prepare it
+no.message.push.order.cancelled=The order has been cancelled
+no.order.rider.accept.required=Please wait for a rider to accept the order before operating it
 no.message.push.new.order=New order
 no.message.push.recharge.success=Recharge successful
 no.message.push.payment.success=Payment successful

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

@@ -104,6 +104,9 @@ no.order.id.error=Mã đơn hàng không đúng
 no.order.create.success=Đơn hàng đã được tạo thành công
 no.message.push.message=tin nhắn
 no.message.push.delivery.personnel.receiving.order=Người giao hàng đã nhận đơn
+no.message.push.rider.accepted.merchant=Tài xế đã nhận đơn, vui lòng xác nhận và chuẩn bị món
+no.message.push.order.cancelled=Đơn hàng đã bị hủy
+no.order.rider.accept.required=Vui lòng đợi tài xế nhận đơn trước khi thao tác
 no.message.push.new.order=có lệnh mới
 no.message.push.recharge.success=nạp tiền thành công
 no.message.push.payment.success=thanh toán thành công

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

@@ -104,6 +104,9 @@ no.order.id.error=订单id不正确
 no.order.create.success=创建订单成功
 no.message.push.message=消息
 no.message.push.delivery.personnel.receiving.order=骑手已接单
+no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备餐
+no.message.push.order.cancelled=订单已取消
+no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功

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

@@ -104,6 +104,9 @@ no.order.id.error=訂單id不正確
 no.order.create.success=創建訂單成功
 no.message.push.message=訊息
 no.message.push.delivery.personnel.receiving.order=騎手已接單
+no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
+no.message.push.order.cancelled=訂單已取消
+no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.recharge.success=充值成功
 no.message.push.payment.success=支付成功

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

@@ -1,12 +1,16 @@
 package com.ruoyi.app.omgpay;
 
+import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.omgpay.dto.OmgNotifyField;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
+import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
 import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
 import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
+import com.ruoyi.system.service.IPosOrderService;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.ArrayList;
 import java.math.BigDecimal;
@@ -118,6 +122,29 @@ class OmgPaymentNotifyServiceTest {
         verify(attempts).markOrderPaid("DD-1");
     }
 
+    @Test
+    void paidDeliveryOrderIsOpenedToRidersAfterOmgSettlement() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        DeliveryOrderNotificationService notifications = mock(DeliveryOrderNotificationService.class);
+        ReflectionTestUtils.setField(service, "posOrderService", orders);
+        ReflectionTestUtils.setField(service, "deliveryOrderNotificationService", notifications);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 0L));
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+        PosOrder paidOrder = new PosOrder();
+        paidOrder.setDdId("DD-1");
+        paidOrder.setType(0L);
+        paidOrder.setState(0L);
+        paidOrder.setDeliveryStatus(0L);
+        paidOrder.setAfterSaleStatus(0L);
+        when(orders.getOne(any())).thenReturn(paidOrder);
+
+        assertTrue(service.process(signedRequest(1, 0, 100)));
+
+        assertEquals(1L, paidOrder.getPayStatus());
+        verify(notifications).notifyOrderAvailable(paidOrder);
+    }
+
     @Test
     void rejectsSignatureMerchantAndAmountMismatchWithoutMutation() {
         OmgNotifyRequest badSignature = signedRequest(1, 0, 100);

+ 71 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java

@@ -0,0 +1,71 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.system.mapper.RiderPositionMapper;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DeliveryOrderNotificationServiceTest {
+
+    @Test
+    void notifiesNearbyAvailableRidersOnlyAfterDeliveryOrderIsPaid() {
+        RiderPositionMapper positions = mock(RiderPositionMapper.class);
+        DeliveryOrderNotificationService service = spy(new DeliveryOrderNotificationService(
+                positions, mock(PushEventService.class)));
+        PosOrder order = availableOrder();
+        RiderPosition rider = new RiderPosition();
+        rider.setRiderId(88L);
+        rider.setCid("rider-cid");
+        when(positions.getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20))
+                .thenReturn(List.of(rider));
+        doNothing().when(service).sendAvailableOrderToRider(rider, order);
+
+        service.notifyOrderAvailable(order);
+
+        verify(positions).getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20);
+        verify(service).sendAvailableOrderToRider(rider, order);
+
+        order.setPayStatus(0L);
+        service.notifyOrderAvailable(order);
+        verify(positions).getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20);
+        verify(service).sendAvailableOrderToRider(rider, order);
+    }
+
+    @Test
+    void ignoresOrdersThatAreNotOpenForRiderAssignment() {
+        RiderPositionMapper positions = mock(RiderPositionMapper.class);
+        DeliveryOrderNotificationService service = new DeliveryOrderNotificationService(
+                positions, mock(PushEventService.class));
+        PosOrder order = availableOrder();
+        order.setState(1L);
+
+        service.notifyOrderAvailable(order);
+
+        verify(positions, never()).getAcceptRiderList(order.getLongitude(), order.getLatitude(), 20);
+    }
+
+    private static PosOrder availableOrder() {
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-DELIVERY");
+        order.setType(0L);
+        order.setState(0L);
+        order.setDeliveryStatus(0L);
+        order.setPayStatus(1L);
+        order.setAfterSaleStatus(0L);
+        order.setLongitude(new BigDecimal("121.5000"));
+        order.setLatitude(new BigDecimal("25.0300"));
+        return order;
+    }
+}

+ 12 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -185,14 +185,26 @@ class OrderLifecycleServiceTest {
     @Test
     void riderAssignmentAndPickupRequireLegalStateAndOwnership() {
         PosOrder order = deliveryOrder();
+        order.setState(0L);
         order.setDeliveryStatus(0L);
         order.setQsId(null);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
 
+        order.setPayStatus(0L);
+        assertThrows(ServiceException.class,
+                () -> service.acceptDeliveryByRider(10L, 88L));
+
+        order.setPayStatus(1L);
+        order.setState(2L);
+        assertThrows(ServiceException.class,
+                () -> service.acceptDeliveryByRider(10L, 88L));
+
+        order.setState(0L);
         service.acceptDeliveryByRider(10L, 88L);
         order.setQsId(88L);
         order.setDeliveryStatus(1L);
+        order.setState(2L);
         service.pickupDeliveryByRider(10L, 88L, "proof");
 
         order.setQsId(99L);

+ 74 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -0,0 +1,74 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.common.utils.MessageUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+
+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.anyString;
+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 PosOrderQsOprateControllerTest {
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp() {
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    @Test
+    void newTasksContainOnlyPaidOrdersWaitingForRiderBeforeMerchantAcceptance() {
+        PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "posStoreService", mock(IPosStoreService.class));
+        ReflectionTestUtils.setField(controller, "infoUserService", mock(IInfoUserService.class));
+        Page<PosOrder> emptyPage = new Page<>(1, 10);
+        emptyPage.setRecords(Collections.emptyList());
+        when(orders.page(any(Page.class), any(Wrapper.class))).thenReturn(emptyPage);
+
+        controller.orderList(JwtUtil.token("88", "rider"), 1, 10, "newTask",
+                (BigDecimal) null, null);
+
+        ArgumentCaptor<LambdaQueryWrapper<PosOrder>> wrapperCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(orders).page(any(Page.class), wrapperCaptor.capture());
+        LambdaQueryWrapper<PosOrder> wrapper = wrapperCaptor.getValue();
+        String sql = wrapper.getSqlSegment().toLowerCase();
+        assertTrue(sql.contains("state"));
+        assertTrue(sql.contains("pay_status"));
+        assertFalse(wrapper.getParamNameValuePairs().containsValue(2L));
+        assertTrue(wrapper.getParamNameValuePairs().containsValue(1L));
+    }
+}

+ 64 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -2,6 +2,7 @@ package com.ruoyi.app.order;
 
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.pay.LinePayOrderGuard;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.InfoUser;
@@ -14,6 +15,7 @@ import com.ruoyi.system.service.IOrderParentService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.system.utils.OrderLogHelper;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -30,6 +32,7 @@ import static org.mockito.Mockito.mockStatic;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.when;
 
 class PosOrderShOprateControllerTest {
@@ -128,6 +131,67 @@ class PosOrderShOprateControllerTest {
                 captor.getValue().getOrderSource());
     }
 
+    @Test
+    void deliveryOrderCannotBeAcceptedByMerchantBeforeRiderAssignment() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        LinePayOrderGuard guard = mock(LinePayOrderGuard.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", guard);
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-DELIVERY");
+        order.setType(0L);
+        order.setState(0L);
+        order.setPayStatus(1L);
+        order.setDeliveryStatus(0L);
+        order.setShId(101L);
+        when(orders.getOne(any())).thenReturn(order);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+
+        assertThrows(ServiceException.class,
+                () -> controller.acceptOrder(JwtUtil.token("101", "merchant"), 10L));
+
+        verify(orders, never()).saveOrUpdate(any(PosOrder.class));
+    }
+
+    @Test
+    void dispatchingDeliveryOrderKeepsAssignedRiderStatus() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard", mock(LinePayOrderGuard.class));
+        ReflectionTestUtils.setField(controller, "orderLogHelper", mock(OrderLogHelper.class));
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(OrderInvoiceService.class));
+
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-DELIVERY");
+        order.setType(0L);
+        order.setState(1L);
+        order.setPayStatus(1L);
+        order.setDeliveryStatus(1L);
+        order.setQsId(88L);
+        order.setShId(101L);
+        when(orders.getOne(any())).thenReturn(order);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+        when(users.list(org.mockito.ArgumentMatchers
+                .<com.baomidou.mybatisplus.core.conditions.Wrapper<InfoUser>>any())).thenReturn(List.of());
+
+        controller.dispatchOrder(JwtUtil.token("101", "merchant"), 10L);
+
+        ArgumentCaptor<PosOrder> captor = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders).saveOrUpdate(captor.capture());
+        org.junit.jupiter.api.Assertions.assertEquals(2L, captor.getValue().getState());
+        org.junit.jupiter.api.Assertions.assertNull(captor.getValue().getDeliveryStatus());
+    }
+
     private static InfoUser user(Long id, String type, Long storeId) {
         InfoUser user = new InfoUser();
         user.setUserId(id);

+ 5 - 1
ruoyi-system/src/main/java/com/ruoyi/system/mapper/RiderPositionMapper.java

@@ -64,6 +64,10 @@ public interface RiderPositionMapper  extends BaseMapper<RiderPosition>
     public int deleteRiderPositionByIds(Long[] ids);
 
 
-    @Select("select * ,(st_distance(point(longitude,latitude),point(#{longitude},#{latitude}))*111195/1000 ) as juli from rider_position  WHERE rider_id not in (SELECT DISTINCT qs_id FROM `pos_order` where type=0 and state in (2,3,4) and qs_id is not null) and rider_id not in (SELECT user_id FROM info_user where offline='1' and user_type='2') ORDER BY juli asc LIMIT #{num}")
+    @Select("select rp.*,(st_distance(point(rp.longitude,rp.latitude),point(#{longitude},#{latitude}))*111195/1000) as juli "
+            + "from rider_position rp where not exists (select 1 from pos_order po where po.qs_id=rp.rider_id "
+            + "and po.type=0 and po.state in (0,1,2) and po.delivery_status in (1,2) and po.after_sale_status=0) "
+            + "and rp.rider_id not in (select user_id from info_user where offline='1' and user_type='2') "
+            + "order by juli asc limit #{num}")
     public List<RiderPosition> getAcceptRiderList(@Param("longitude") BigDecimal longitude, @Param("latitude")BigDecimal latitude,@Param("num")int num);
 }

+ 8 - 0
specs/006-orderstate/plan.md

@@ -100,3 +100,11 @@ E:\QtwCode\foodie\foodie-admin-vue\
 - 定向测试三个下单入口的自取配送费归零,并保留至少一个非自取配送费不变用例。
 - 定向测试出餐推送内容选择:自取使用专属 key 和取餐码参数,非自取使用原通用 key。
 - 修改完成后运行受影响定向测试及 `ruoyi-admin` 模块构建;完整回归结果单独记录。
+
+## 2026-08-28 增量实施设计
+
+外送订单继续复用 `state`、`deliveryStatus`、`payStatus` 和 `qsId`,不增加数据库字段。`deliveryStatus=0` 从“商家出餐后等待骑手”调整为“订单已支付并等待骑手”;骑手接单只推进配送状态,商家随后推进订单状态。
+
+新增订单配送推送服务统一处理两类跨入口副作用:到付创建或在线支付成功后通知附近可接单骑手,以及已分配骑手订单取消后的相关方通知。用户聚合下单、旧 `/addorder` 到付入口、LINE Pay 和 OMG 支付成功入口共用同一开放条件,避免不同支付方式形成不同履约顺序。
+
+商家接单和出餐在原支付、归属校验基础上增加骑手分配校验;骑手新任务查询增加 `state=0` 和 `payStatus=1` 条件。骑手占用查询只统计 `state IN (0,1,2)` 且 `deliveryStatus IN (1,2)` 的有效履约订单,取消订单不继续占用骑手。

+ 27 - 0
specs/006-orderstate/spec.md

@@ -692,6 +692,33 @@ wrapper.last("ORDER BY ST_Distance_Sphere(point(longitude, latitude), " +
 3. 自取订单出餐时,用户收到包含当前取餐码的专属到店领取通知。
 4. 外送和堂食订单出餐时,仍收到原有通用出餐通知。
 
+## 2026-08-28 增量:外送订单由骑手先接单
+
+本节覆盖本规格中原有“商家出餐后开放给骑手”的外送流程;自取和堂食流程不变。
+
+### 外送状态流转
+
+1. 外送订单创建时写入 `state=0`、`deliveryStatus=0`、`afterSaleStatus=0`。
+2. 到付订单创建后立即开放给骑手;在线支付订单必须在支付成功、`payStatus=1` 后才开放。
+3. 骑手仅可对 `state=0`、`deliveryStatus=0`、`payStatus=1` 且无售后的订单接单,接单后写入 `deliveryStatus=1` 和 `qsId`,`state` 保持为 0。
+4. 外送订单只有在 `deliveryStatus=1` 且 `qsId` 有值时,商家才可接单(`state=0→1`)或出餐(`state=1→2`)。商家出餐不得把配送状态重置为 0。
+5. 骑手取餐仍要求 `state=2`、`deliveryStatus=1`,完成后进入 `deliveryStatus=2`;送达后进入 `deliveryStatus=3`、`state=3`。
+
+### 推送规则
+
+- 订单开放时通知附近可接单骑手,不提前通知商家备餐。
+- 骑手接单后通知用户,并通知商家“骑手已接单,可以确认并备餐”。
+- 商家出餐后通知用户及已分配骑手。
+- 已分配骑手的订单被用户或商家取消时,通知除取消人外的相关用户、商家和骑手;取消订单不得继续占用骑手的可接单资格。
+
+### 验收场景
+
+1. 未支付在线外送订单不出现在骑手新任务列表,支付成功后才出现并触发骑手推送。
+2. 到付外送订单创建后立即出现在骑手新任务列表并触发骑手推送。
+3. 未分配骑手时,商家接单和出餐均被拒绝;骑手接单后商家可按顺序接单、出餐。
+4. 商家出餐后 `deliveryStatus` 仍为 1,已接单骑手可继续取餐和送达。
+5. 订单取消后,骑手可继续接取其他订单,并能在取消订单列表查看原订单。
+
 ## 参考资源
 
 - [美团外卖开放平台 — 订单状态](https://developer.waimai.meituan.com/home/doc/market/100)

+ 7 - 0
specs/006-orderstate/tasks.md

@@ -159,6 +159,13 @@
 - [ ] T032 为自取出餐推送补充文案分流测试,验证专属 key 携带当前 `pickUpNum`,其他订单继续使用通用 key
 - [ ] T033 增加五套后端 i18n 自取出餐文案,并接入现有用户 App 推送与 `push_message` 入库链路
 - [ ] T034 运行定向测试、`ruoyi-admin` 模块构建并记录未执行的完整回归项
+- [x] T035 补充骑手先接单、支付后开放及商家操作门槛的回归测试
+- [x] T036 调整用户聚合下单和旧 `/system/order/addorder`,初始化外送配送状态并按到付/在线支付控制开放时机
+- [x] T037 调整骑手新任务查询和接单状态机,仅允许已支付且商家未接单的外送订单由骑手接单
+- [x] T038 调整商家接单和出餐校验,要求骑手已分配并在出餐时保留配送状态
+- [x] T039 接入到付、LINE Pay 和 OMG 支付成功后的可接单骑手推送
+- [x] T040 调整骑手接单、商家出餐和订单取消推送,并解除取消订单对骑手可接单资格的占用
+- [x] T041 使用 JDK 21 完成验证:定向测试 52/52 通过,`ruoyi-admin` 模块构建通过;完整回归 272 个用例中 269 个通过,3 个原有 `OrderListPaymentFilterTest` 因缺少 `MessageUtils/SpringUtils` 测试上下文报空指针
 
 ---
 

+ 2 - 2
specs/020-omg-payment-rebuild/plan.md

@@ -11,7 +11,7 @@
 ## Global Constraints
 
 - OMG AIO 官方技术文件 V1.5.3 是支付协议唯一外部事实来源;不得从旧代码或 `specs/016-omg-payment` 复制行为。
-- 实现创建、最终付款结果回调、用户触发查询补偿,以及测试环境退款安全拒绝;取号通知、定时/批量补单、正式退款动作、推送、关账和正式环境全部不实现。
+- 实现创建、最终付款结果回调、用户触发查询补偿,以及测试环境退款安全拒绝;取号通知、定时/批量补单、正式退款动作、通用支付推送、关账和正式环境全部不实现。外送订单支付成功后的骑手开放推送按 2026-08-28 增量实现。
 - 新代码只能位于 `com.ruoyi.app.omgpay`、`com.ruoyi.system.omgpay` 及对应资源/测试目录。
 - `ruoyi-admin -> ruoyi-system`;`ruoyi-system` 禁止导入 `com.ruoyi.app.*`。
 - 每个门店独立使用 `pos_store_omg` 中已启用的 `MerchantID / HashKey / HashIV`;不修改可信凭证存储与管理代码。
@@ -106,7 +106,7 @@ raw application/x-www-form-urlencoded request
 - [x] 新表使用 InnoDB、参数化 MyBatis、生成列唯一键和 `FOR UPDATE`。
 - [x] 测试源码先于生产代码;按用户要求当前阶段不运行 Maven/编译/测试。
 - [x] 只保留 `pos_store_omg` 可信能力,旧支付/退款表运行时引用清零。
-- [x] 实现最终付款回调;不实现取号、查询、退款、推送或生产环境。
+- [x] 实现最终付款回调;不实现取号、查询、退款、通用支付推送或生产环境。外送订单支付成功后的骑手开放推送除外。
 
 结论:无须复杂性豁免。
 

+ 11 - 4
specs/020-omg-payment-rebuild/spec.md

@@ -6,7 +6,7 @@
 
 **Status**: 创建支付已实现;付款结果回调设计已批准并进入实现
 
-**Input**: 以 OMG 全方位金流 AIO 官方技术文件 V1.5.3(2026-07)为唯一外部事实来源,从零重建创建支付订单和 `ReturnURL` 付款结果回调;现有 OMG 支付代码、旧支付流水和 `specs/016-omg-payment` 均不作为需求或设计依据。查询、补单、退款与推送后续逐项重建。
+**Input**: 以 OMG 全方位金流 AIO 官方技术文件 V1.5.3(2026-07)为唯一外部事实来源,从零重建创建支付订单和 `ReturnURL` 付款结果回调;现有 OMG 支付代码、旧支付流水和 `specs/016-omg-payment` 均不作为需求或设计依据。查询、补单、退款与通用支付推送后续逐项重建;2026-08-28 起仅追加外送订单支付成功后的骑手开放推送
 
 ## 1. Scope and Trust Boundary
 
@@ -21,7 +21,7 @@
 - 在 `POST /pay/omg/query` 只查询订单当前有效支付尝试;可信已付款结果复用回调状态机补偿丢失回调。
 - 提供 `POST /pay/omg/refund` 的测试阶段拒绝契约:明确提示测试环境不支持退款,且不调用正式网关、不改本地状态。
 - 每次回调独立写入现有 `ipn_log`,并保存完整、可重放的 form-urlencoded 回传内容。
-- 成功回调只把订单 `payStatus` 更新为已付款,不推进订单、配送或推送流程
+- 成功回调只把订单 `payStatus` 更新为已付款,不推进订单或配送状态;外送订单支付成功后可触发骑手开放推送,其他通用支付推送仍不在本阶段实现
 - 停用旧 OMG Controller 及其补单、退款、定时任务和订单取消调用入口。
 - 定义 iOS 用户端 `pages/OrderList/buy/omgCheckout` 通过当前原生 WebView 的 `loaded` 与固定结果 URL 接管返回流程。
 
@@ -230,7 +230,7 @@ iOS 用户在 OMG 收银台完成或结束支付流程后,OMG 加载后端 `/p
 - **FR-033**: 每次回调 MUST 先以独立事务向现有 `ipn_log` 新增一行:`type="omg"`、`ip` 为请求来源、`cretim` 为接收时间、`ipn_log` 为完整可重放 form-urlencoded 内容。该写入失败只记录应用错误日志,不得阻断后续验签与付款处理。
 - **FR-034**: 回调 MUST 先按 `MerchantTradeNo` 读取并锁定新支付尝试,再使用该尝试的 `HashKey / HashIV` 快照验签;不得只凭请求 `MerchantID` 选择密钥,也不得使用门店当前可能已变更的凭证。
 - **FR-035**: 验签成功后 MUST 同时校验请求 `MerchantID`、`MerchantTradeNo`、`TradeAmt` 与尝试快照完全一致。交易号不存在、字段缺失/格式非法、商户或金额不符、验签失败及业务事务异常 MUST NOT 修改订单或尝试,并返回纯文本 `0|ERROR`。
-- **FR-036**: 当 `RtnCode=1` 时,无论 `SimulatePaid` 为 `0` 或 `1`,系统 MUST 把尝试标记 `PAID` 并保存网关交易事实;订单仅把 `pay_status` 从未付款改为已付款,不得修改 `state`、`delivery_status` 或触发接单、出餐、完成、推送、退款。
+- **FR-036**: 当 `RtnCode=1` 时,无论 `SimulatePaid` 为 `0` 或 `1`,系统 MUST 把尝试标记 `PAID` 并保存网关交易事实;订单仅把 `pay_status` 从未付款改为已付款,不得修改 `state`、`delivery_status` 或触发接单、出餐、完成、退款。若该订单为可配送的外送订单,MUST 在事务提交后触发可接单骑手推送。
 - **FR-037**: 当验签通过且 `RtnCode!=1` 时,系统 MUST 把非 `PAID` 尝试标记 `FAILED`,原样保存 `RtnCode/RtnMsg` 并释放活动尝试;订单保持未付款,下一次创建支付生成新的 `MerchantTradeNo`。
 - **FR-038**: 回调状态 MUST 成功优先且不可逆:`CREATED/FAILED -> PAID`,`CREATED -> FAILED`,`PAID` 不得降级。重复通知不得重复改变订单;正确处理或已处理的通知均返回精确 `1|OK`。
 - **FR-039**: 合法成功通知到达时,即使订单已取消也 MUST 记录付款事实并把订单 `pay_status` 更新为 `1`;系统 MUST 输出异常日志,但本阶段不得自动退款。
@@ -382,7 +382,14 @@ token: <login-token>
 - 在当前页面将全部 `formFields` POST 到返回的测试 `gatewayUrl`。
 - 分别使用 `CREDIT` 与 `APPLE_PAY` 创建并提交表单,确认直接进入所选 OMG 测试付款流程;不得出现超商快付或 AFTEE 选择项。
 - 确认不使用 iframe 或新窗口。
-- 创建和付款结果回调按本规格完成;查询、补单、取号、退款与推送不作为本阶段完成标准。
+- 创建和付款结果回调按本规格完成;查询、补单、取号、退款与通用支付推送不作为本阶段完成标准,外送订单支付成功后的骑手开放推送除外。
+
+## 2026-08-28 增量:外送订单支付成功后开放给骑手
+
+- OMG 首次成功结算且订单未取消时,读取已更新为 `payStatus=1` 的业务订单。
+- 仅当订单同时满足 `type=0`、`state=0`、`deliveryStatus=0`、`afterSaleStatus=0` 且未分配骑手时,事务提交后通知附近可接单骑手。
+- 本增量不得推进 `state` 或 `deliveryStatus`,不得提前通知商家备餐;商家通知由骑手实际接单后触发。
+- 重复成功回调、已支付订单和已取消订单不得重复触发骑手开放推送。
 
 ## Success Criteria
 

+ 8 - 2
specs/020-omg-payment-rebuild/tasks.md

@@ -4,7 +4,7 @@
 
 **Tests**: 本功能强制 TDD。每个生产任务先写失败测试并实际观察失败,再写最小实现。
 
-**Scope**: 完成测试环境创建支付订单、最终付款结果回调及用户触发的当前支付查询补偿;不实现取号、定时/批量补单、退款、推送、关账或正式环境。
+**Scope**: 完成测试环境创建支付订单、最终付款结果回调及用户触发的当前支付查询补偿;不实现取号、定时/批量补单、退款、通用支付推送、关账或正式环境。2026-08-28 增量仅追加外送订单支付成功后的骑手开放推送。
 
 ## Phase 1: 新持久化基础
 
@@ -179,6 +179,12 @@
 - [ ] T108 [US1] 全部 OMG 计划功能调整完成后,使用 JDK 21 统一运行渠道定向测试、OMG 回归和模块构建;核对最终 diff、暂存范围及无 SQL 变更
 - [ ] T109 [US1] 在 OMG stage 分别验收信用卡和 Apple Pay 直接流程,确认不出现超商快付、AFTEE、银联或其他渠道选择项
 
+## Phase 19: 外送订单支付后开放骑手
+
+- [x] T110 更新 OMG 规格,允许首次成功结算在不推进订单/配送状态的前提下触发外送骑手开放推送
+- [x] T111 在 `OmgPaymentNotifyService` 首次更新订单支付状态后加载业务订单,并复用配送推送服务按统一条件开放给骑手
+- [x] T112 补充 OMG 首次成功结算触发骑手开放推送测试,并复用统一开放条件拦截未支付或非待接单状态
+
 ## Dependencies & Execution Order
 
 - Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10 → Phase 11 → Phase 12 → Phase 13 → Phase 14 → Phase 15 → Phase 16 → Phase 17 → Phase 18。
@@ -191,7 +197,7 @@
 - `POST /pay/omg/create` 首次返回可提交的 stage 表单。
 - `POST /pay/omg/notify` 对所有实际字段验签,并幂等同步成功/失败支付事实。
 - 每次回调完整保存到现有 `ipn_log`;不新增该表字段。
-- 成功仅修改订单 `payStatus`,不推进业务状态或触发推送/退款
+- 成功仅修改订单 `payStatus`,不推进业务状态或触发退款;外送订单首次支付成功可触发骑手开放推送,其他通用支付推送不触发
 - 同订单重复/并发创建最多一条 `CREATED`,后续请求为 `PAYMENT_ATTEMPT_EXISTS`。
 - 官方检查码向量一致,实际发送字段无遗漏。
 - App 只提交 `CREDIT/APPLE_PAY`;新支付表单分别固定为 `ChoosePayment=Credit + UnionPay=2` 或 `ChoosePayment=ApplePay`,不发送 `ChoosePayment=ALL` 或 `IgnorePayment`。