Forráskód Böngészése

Merge branch 'test' into test-202609v1

qmj 3 napja
szülő
commit
58bb0dd4d8
25 módosított fájl, 468 hozzáadás és 49 törlés
  1. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  2. 2 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  3. 5 4
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  4. 26 5
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java
  5. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java
  6. 19 9
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java
  7. 6 6
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java
  8. 2 0
      ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java
  9. 29 2
      ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java
  10. 4 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java
  11. 23 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java
  12. 18 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryControllerTest.java
  13. 79 0
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java
  14. 9 3
      ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderTabTest.java
  15. 181 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/RiderPositionControllerTest.java
  16. 2 0
      specs/027-offline-transfer-payment/data-model.md
  17. 22 0
      specs/027-offline-transfer-payment/spec.md
  18. 2 0
      specs/027-offline-transfer-payment/tasks.md
  19. 6 4
      specs/030-unified-order-list/contracts/api-contract.md
  20. 6 4
      specs/030-unified-order-list/data-model.md
  21. 2 2
      specs/030-unified-order-list/plan.md
  22. 3 1
      specs/030-unified-order-list/quickstart.md
  23. 3 3
      specs/030-unified-order-list/spec.md
  24. 8 0
      specs/030-unified-order-list/tasks.md
  25. 9 0
      updatesql/sql.md

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

@@ -32,7 +32,7 @@ public class OrderLifecycleService {
 
 
     public static final String PAY_TYPE_OFFLINE = "1";
     public static final String PAY_TYPE_OFFLINE = "1";
     public static final String PAY_TYPE_OMG = "2";
     public static final String PAY_TYPE_OMG = "2";
-    /** 现金支付:仅商家创建订单可用(客户端下单拒绝);创建时未支付,商家App确认收款后置已支付 */
+    /** 现金支付:堂食/自取用户可直接下单;外送仅商家创建订单可用。创建时未支付,商家App确认收款后置已支付 */
     public static final String PAY_TYPE_CASH = "4";
     public static final String PAY_TYPE_CASH = "4";
     /** 線下轉賬支付:用户线下银行转账,商家确认收款(payStatus 从 0 置 1)后解锁骑手接单 */
     /** 線下轉賬支付:用户线下银行转账,商家确认收款(payStatus 从 0 置 1)后解锁骑手接单 */
     public static final String PAY_TYPE_TRANSFER = "6";
     public static final String PAY_TYPE_TRANSFER = "6";

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

@@ -644,9 +644,9 @@ public class PosOrderShOprateController extends BaseController {
         switch (tab == null ? "" : tab) {
         switch (tab == null ? "" : tab) {
             case "pending":
             case "pending":
                 wrapper.eq(PosOrder::getState, 0L)
                 wrapper.eq(PosOrder::getState, 0L)
-                       // 现金自取/堂食可直接待受理;在线支付的自取/堂食必须先完成付款。
+                       // 到付/现金自取/堂食可直接待受理;在线支付的自取/堂食必须先完成付款。
                        .and(w -> w.eq(PosOrder::getPayStatus, 1L)
                        .and(w -> w.eq(PosOrder::getPayStatus, 1L)
-                               .or(cash -> cash.eq(PosOrder::getPayType, "1")
+                               .or(cash -> cash.in(PosOrder::getPayType, "1", "4")
                                        .in(PosOrder::getType, 1L, 2L)));
                                        .in(PosOrder::getType, 1L, 2L)));
                 break;
                 break;
             case "preparing":
             case "preparing":

+ 5 - 4
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -162,8 +162,9 @@ public class UserOrderController extends BaseController {
             }
             }
             shdz= JSON.toJSONString(usadd);
             shdz= JSON.toJSONString(usadd);
         }
         }
-        // 现金支付(4)仅商家创建订单可用,客户端下单直接拒绝
-        if (OrderLifecycleService.PAY_TYPE_CASH.equals(input.getPaymentMethod())) {
+        // 现金支付(4):堂食/自取用户可直接下单线下收款;外送仍仅商家创建订单可用
+        if (OrderLifecycleService.PAY_TYPE_CASH.equals(input.getPaymentMethod())
+                && input.getType() != 1 && input.getType() != 2) {
             throw new ServiceException(MessageUtils.message("no.paytype.cash.merchant.only"));
             throw new ServiceException(MessageUtils.message("no.paytype.cash.merchant.only"));
         }
         }
         // === 发票意图(010 Phase 11)===
         // === 发票意图(010 Phase 11)===
@@ -563,9 +564,9 @@ public class UserOrderController extends BaseController {
             case "active":
             case "active":
                 wrapper.in(PosOrder::getState, 0L, 1L, 2L)
                 wrapper.in(PosOrder::getState, 0L, 1L, 2L)
                        .eq(PosOrder::getAfterSaleStatus, 0L)
                        .eq(PosOrder::getAfterSaleStatus, 0L)
-                       // 现金自取/堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
+                       // 到付/现金自取/堂食可以线下收款;OMG、LINE Pay 等在线订单必须已付款才能履约。
                        .and(w -> w.eq(PosOrder::getPayStatus, 1L)
                        .and(w -> w.eq(PosOrder::getPayStatus, 1L)
-                               .or(cash -> cash.eq(PosOrder::getPayType, "1")
+                               .or(cash -> cash.in(PosOrder::getPayType, "1", "4")
                                        .in(PosOrder::getType, 1L, 2L)));
                                        .in(PosOrder::getType, 1L, 2L)));
                 break;
                 break;
             case "completed":
             case "completed":

+ 26 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/OrderSource.java

@@ -1,16 +1,27 @@
 package com.ruoyi.app.unifiedorder;
 package com.ruoyi.app.unifiedorder;
 
 
-/** 统一订单列表来源枚举;编码字典序(flash 小于 takeaway)是同秒跨源并列时的确定性决胜依据。 */
+/**
+ * 统一订单列表来源枚举;编码字典序(dinein 小于 flash 小于 pickup 小于 takeaway)是同秒跨源并列时的确定性决胜依据。
+ * dinein/pickup 是 pos_order 按 type 拆出的子集筛选,仅在来源单选查询出现,
+ * 混排查询(source=all)的行与游标只会有 takeaway/flash 两种编码。
+ */
 public enum OrderSource {
 public enum OrderSource {
-    /** 外卖订单(pos_order)。 */
-    TAKEAWAY("takeaway"),
+    /** 外卖订单(pos_order,仅外送单 type=0)。 */
+    TAKEAWAY("takeaway", 0L),
     /** 闪送订单(flash_delivery_order)。 */
     /** 闪送订单(flash_delivery_order)。 */
-    FLASH("flash");
+    FLASH("flash", null),
+    /** 堂食订单(pos_order type=2)。 */
+    DINEIN("dinein", 2L),
+    /** 自取订单(pos_order type=1)。 */
+    PICKUP("pickup", 1L);
 
 
     private final String code;
     private final String code;
+    /** pos_order.type 过滤值;null 表示不走 pos_order 路(闪送)。 */
+    private final Long posOrderType;
 
 
-    OrderSource(String code) {
+    OrderSource(String code, Long posOrderType) {
         this.code = code;
         this.code = code;
+        this.posOrderType = posOrderType;
     }
     }
 
 
     /** 前端契约中的来源编码。 */
     /** 前端契约中的来源编码。 */
@@ -18,6 +29,16 @@ public enum OrderSource {
         return code;
         return code;
     }
     }
 
 
+    /** 该来源是否查询 pos_order 外卖路(takeaway/dinein/pickup)。 */
+    public boolean queriesPosOrder() {
+        return posOrderType != null;
+    }
+
+    /** 外卖路的 pos_order.type 过滤值;仅 queriesPosOrder() 为真时调用。 */
+    public Long posOrderType() {
+        return posOrderType;
+    }
+
     /** 按契约编码解析;非法值返回 null,由 Controller 统一转参数错误。 */
     /** 按契约编码解析;非法值返回 null,由 Controller 统一转参数错误。 */
     public static OrderSource fromCode(String code) {
     public static OrderSource fromCode(String code) {
         for (OrderSource source : values()) {
         for (OrderSource source : values()) {

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryController.java

@@ -27,7 +27,7 @@ public class UnifiedOrderQueryController extends BaseController {
      *
      *
      * @param token 登录令牌
      * @param token 登录令牌
      * @param tab 归类 tab:all/unpaid/active/completed/cancelled/refund,默认 all
      * @param tab 归类 tab:all/unpaid/active/completed/cancelled/refund,默认 all
-     * @param source 来源筛选:all/takeaway/flash,默认 all
+     * @param source 来源筛选:all/takeaway/dinein/pickup/flash,默认 all;takeaway 仅外送单,dinein=堂食,pickup=自取
      * @param cursor 上一页返回的游标,首页不传
      * @param cursor 上一页返回的游标,首页不传
      * @param size 每页条数,默认 10,服务端钳制 1-50
      * @param size 每页条数,默认 10,服务端钳制 1-50
      */
      */

+ 19 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryService.java

@@ -25,6 +25,8 @@ import java.util.Set;
 /**
 /**
  * 统一订单列表查询:外卖与闪送两路各取 size+1 条,Java 按游标全序归并,读时聚合零写路径。
  * 统一订单列表查询:外卖与闪送两路各取 size+1 条,Java 按游标全序归并,读时聚合零写路径。
  * 待付款与退款·售后 tab 闪送侧恒空,直接跳过闪送路;来源单选时跳过另一路。
  * 待付款与退款·售后 tab 闪送侧恒空,直接跳过闪送路;来源单选时跳过另一路。
+ * dinein/pickup 是外卖路按 pos_order.type 的子集筛选(堂食=2、自取=1、takeaway=0 纯外送),
+ * 其游标编码相应为 dinein/pickup,同源翻页精确到 id 决胜。
  */
  */
 @Service
 @Service
 public class UnifiedOrderQueryService {
 public class UnifiedOrderQueryService {
@@ -55,11 +57,12 @@ public class UnifiedOrderQueryService {
                                       UnifiedCursor cursor, int size) {
                                       UnifiedCursor cursor, int size) {
         int limit = Math.max(1, Math.min(MAX_PAGE_SIZE, size));
         int limit = Math.max(1, Math.min(MAX_PAGE_SIZE, size));
         List<Row> rows = new ArrayList<>();
         List<Row> rows = new ArrayList<>();
-        if (source == null || source == OrderSource.TAKEAWAY) {
+        if (source == null || source.queriesPosOrder()) {
+            OrderSource takeawayTag = source == null ? OrderSource.TAKEAWAY : source;
             Page<PosOrder> page = posOrderMapper.selectPage(
             Page<PosOrder> page = posOrderMapper.selectPage(
-                    new Page<>(1, limit + 1, false), takeawayWrapper(userId, tab, cursor));
+                    new Page<>(1, limit + 1, false), takeawayWrapper(userId, tab, cursor, source));
             for (PosOrder order : page.getRecords()) {
             for (PosOrder order : page.getRecords()) {
-                rows.add(new Row(UnifiedCursor.of(safeTime(order.getCretim()), OrderSource.TAKEAWAY, order.getId()), order, null));
+                rows.add(new Row(UnifiedCursor.of(safeTime(order.getCretim()), takeawayTag, order.getId()), order, null));
             }
             }
         }
         }
         if (tab.queriesFlash() && (source == null || source == OrderSource.FLASH)) {
         if (tab.queriesFlash() && (source == null || source == OrderSource.FLASH)) {
@@ -89,17 +92,23 @@ public class UnifiedOrderQueryService {
         return view;
         return view;
     }
     }
 
 
-    /** 外卖路条件:用户 + tab 谓词 + 游标推进,排序 cretim DESC, id DESC。 */
-    private LambdaQueryWrapper<PosOrder> takeawayWrapper(Long userId, UnifiedOrderTab tab, UnifiedCursor cursor) {
+    /** 外卖路条件:用户 + 来源 type 过滤 + tab 谓词 + 游标推进,排序 cretim DESC, id DESC;source 为 all 时不限 type。 */
+    private LambdaQueryWrapper<PosOrder> takeawayWrapper(Long userId, UnifiedOrderTab tab, UnifiedCursor cursor,
+                                                         OrderSource source) {
         LambdaQueryWrapper<PosOrder> wrapper = tab.posOrderWrapper()
         LambdaQueryWrapper<PosOrder> wrapper = tab.posOrderWrapper()
-                .eq(PosOrder::getUserId, userId)
-                .orderByDesc(PosOrder::getCretim)
+                .eq(PosOrder::getUserId, userId);
+        if (source != null) {
+            wrapper.eq(PosOrder::getType, source.posOrderType());
+        }
+        wrapper.orderByDesc(PosOrder::getCretim)
                 .orderByDesc(PosOrder::getId);
                 .orderByDesc(PosOrder::getId);
+        // 游标推进按本路查询源判定:混排(source=all)按 takeaway,单选按所选源
+        OrderSource advanceSource = source == null ? OrderSource.TAKEAWAY : source;
         if (cursor != null) {
         if (cursor != null) {
             Date time = new Date(cursor.getTimeMillis());
             Date time = new Date(cursor.getTimeMillis());
-            if (cursor.includesSameTimeAnyId(OrderSource.TAKEAWAY)) {
+            if (cursor.includesSameTimeAnyId(advanceSource)) {
                 wrapper.le(PosOrder::getCretim, time);
                 wrapper.le(PosOrder::getCretim, time);
-            } else if (cursor.includesSameTimeLowerId(OrderSource.TAKEAWAY)) {
+            } else if (cursor.includesSameTimeLowerId(advanceSource)) {
                 wrapper.and(w -> w.lt(PosOrder::getCretim, time)
                 wrapper.and(w -> w.lt(PosOrder::getCretim, time)
                         .or(x -> x.eq(PosOrder::getCretim, time).lt(PosOrder::getId, cursor.getOrderId())));
                         .or(x -> x.eq(PosOrder::getCretim, time).lt(PosOrder::getId, cursor.getOrderId())));
             } else {
             } else {
@@ -207,6 +216,7 @@ public class UnifiedOrderQueryService {
         item.setCurrency(order.getCurrency());
         item.setCurrency(order.getCurrency());
         item.setHasAfterSale(false);
         item.setHasAfterSale(false);
         item.setStatus(order.getStatus());
         item.setStatus(order.getStatus());
+        item.setOrderVersion(order.getVersion());
         item.setServiceType(order.getServiceType());
         item.setServiceType(order.getServiceType());
         item.setDeliveryType(order.getDeliveryType());
         item.setDeliveryType(order.getDeliveryType());
         item.setVehicleType(order.getVehicleType());
         item.setVehicleType(order.getVehicleType());

+ 6 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/UnifiedOrderTab.java

@@ -29,9 +29,9 @@ public enum UnifiedOrderTab {
 
 
     /** 在线支付方式(OMG、LINE Pay),待付款 tab 仅认这两种。 */
     /** 在线支付方式(OMG、LINE Pay),待付款 tab 仅认这两种。 */
     private static final List<String> ONLINE_PAY_TYPES = List.of("2", "3");
     private static final List<String> ONLINE_PAY_TYPES = List.of("2", "3");
-    /** 货到付款支付方式标记。 */
-    private static final String CASH_PAY_TYPE = "1";
-    /** 货到付款可线下收款的订单类型:自取、堂食。 */
+    /** 可线下收款的支付方式:到付(1)、现金(4)。 */
+    private static final List<String> OFFLINE_PAYABLE_PAY_TYPES = List.of("1", "4");
+    /** 可线下收款的订单类型:自取、堂食。 */
     private static final List<Long> CASH_PAYABLE_TYPES = List.of(1L, 2L);
     private static final List<Long> CASH_PAYABLE_TYPES = List.of(1L, 2L);
     /** 进行中的外卖订单状态:待处理、已接单、已出餐。 */
     /** 进行中的外卖订单状态:待处理、已接单、已出餐。 */
     private static final List<Long> ACTIVE_STATES = List.of(0L, 1L, 2L);
     private static final List<Long> ACTIVE_STATES = List.of(0L, 1L, 2L);
@@ -92,9 +92,9 @@ public enum UnifiedOrderTab {
             case ACTIVE:
             case ACTIVE:
                 wrapper.in(PosOrder::getState, ACTIVE_STATES)
                 wrapper.in(PosOrder::getState, ACTIVE_STATES)
                         .eq(PosOrder::getAfterSaleStatus, NO_AFTER_SALE)
                         .eq(PosOrder::getAfterSaleStatus, NO_AFTER_SALE)
-                        // 现金自取/堂食可以线下收款;在线订单必须已付款才能履约。
+                        // 到付/现金自取/堂食可以线下收款;在线订单必须已付款才能履约。
                         .and(w -> w.eq(PosOrder::getPayStatus, PAID)
                         .and(w -> w.eq(PosOrder::getPayStatus, PAID)
-                                .or(cash -> cash.eq(PosOrder::getPayType, CASH_PAY_TYPE)
+                                .or(cash -> cash.in(PosOrder::getPayType, OFFLINE_PAYABLE_PAY_TYPES)
                                         .in(PosOrder::getType, CASH_PAYABLE_TYPES)));
                                         .in(PosOrder::getType, CASH_PAYABLE_TYPES)));
                 break;
                 break;
             case COMPLETED:
             case COMPLETED:
@@ -127,7 +127,7 @@ public enum UnifiedOrderTab {
                 return order.getState() != null && ACTIVE_STATES.contains(order.getState())
                 return order.getState() != null && ACTIVE_STATES.contains(order.getState())
                         && longEq(order.getAfterSaleStatus(), NO_AFTER_SALE)
                         && longEq(order.getAfterSaleStatus(), NO_AFTER_SALE)
                         && (longEq(order.getPayStatus(), PAID)
                         && (longEq(order.getPayStatus(), PAID)
-                                || (CASH_PAY_TYPE.equals(order.getPayType())
+                                || (order.getPayType() != null && OFFLINE_PAYABLE_PAY_TYPES.contains(order.getPayType())
                                         && order.getType() != null && CASH_PAYABLE_TYPES.contains(order.getType())));
                                         && order.getType() != null && CASH_PAYABLE_TYPES.contains(order.getType())));
             case COMPLETED:
             case COMPLETED:
                 return longEq(order.getState(), COMPLETED_STATE)
                 return longEq(order.getState(), COMPLETED_STATE)

+ 2 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/dto/UnifiedOrderListItem.java

@@ -58,6 +58,8 @@ public class UnifiedOrderListItem {
 
 
     /** 闪送状态:WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED。 */
     /** 闪送状态:WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED。 */
     private String status;
     private String status;
+    /** 闪送乐观锁版本(用户编辑待接单订单须回传,同 FlashDeliveryUserOrderListView.orderVersion)。 */
+    private Integer orderVersion;
     /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
     /** 业务场景:HELP_SEND=帮送、HELP_PICKUP=帮取。 */
     private String serviceType;
     private String serviceType;
     /** 配送等级:NORMAL=普通、URGENT=加急。 */
     /** 配送等级:NORMAL=普通、URGENT=加急。 */

+ 29 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/user/RiderPositionController.java

@@ -16,6 +16,7 @@ import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.annotation.Log;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.controller.BaseController;
@@ -59,7 +60,8 @@ public class RiderPositionController extends BaseController
         InfoUser user = infoUserService.getById(id);
         InfoUser user = infoUserService.getById(id);
         QueryWrapper<RiderPosition> queryWrapper= new QueryWrapper<>();
         QueryWrapper<RiderPosition> queryWrapper= new QueryWrapper<>();
         queryWrapper.eq("rider_id",id);
         queryWrapper.eq("rider_id",id);
-        RiderPosition position = riderPositionService.getOne(queryWrapper);
+        // 骑手定位一人一条:历史重复行取第一条继续更新,不再抛 selectOne 多行异常
+        RiderPosition position = riderPositionService.getOne(queryWrapper, false);
         RiderPosition rider = new RiderPosition();
         RiderPosition rider = new RiderPosition();
         if(position==null){
         if(position==null){
             rider.setRiderId(Long.valueOf(id));
             rider.setRiderId(Long.valueOf(id));
@@ -70,7 +72,14 @@ public class RiderPositionController extends BaseController
             }
             }
             System.out.println("setRiderposition更新定位,用户id,新增信息:"+ JSON.toJSONString( rider));
             System.out.println("setRiderposition更新定位,用户id,新增信息:"+ JSON.toJSONString( rider));
             logger.info("setRiderposition更新定位,用户id,新增信息:"+ JSON.toJSONString( rider));
             logger.info("setRiderposition更新定位,用户id,新增信息:"+ JSON.toJSONString( rider));
-            Boolean org = riderPositionService.saveOrUpdate(rider);
+            Boolean org;
+            try {
+                org = riderPositionService.saveOrUpdate(rider);
+            } catch (DuplicateKeyException e) {
+                // 并发上传同时查空双双插入,撞 rider_id 唯一索引时转按骑手更新已有记录
+                logger.info("setRiderposition并发插入撞唯一索引转更新,用户id"+id);
+                org = riderPositionService.update(rider, queryWrapper);
+            }
             if(org){
             if(org){
                 return success();
                 return success();
             }else {
             }else {
@@ -95,6 +104,24 @@ public class RiderPositionController extends BaseController
         }
         }
     }
     }
 
 
+    /**
+     * 通过骑手用户id获取骑手经纬度
+     *
+     * @param token 登录令牌
+     * @param riderId 骑手用户id
+     * @return 骑手定位记录(含 latitude/longitude),骑手尚无定位记录时 data 为 null
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getRiderposition")
+    public AjaxResult getRiderposition(@RequestHeader String token, @RequestParam Long riderId) {
+        QueryWrapper<RiderPosition> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("rider_id", riderId);
+        // 一人一条:历史重复行取第一条,不抛 selectOne 多行异常
+        RiderPosition position = riderPositionService.getOne(queryWrapper, false);
+        return success(position);
+    }
+
     /**
     /**
      * 查询RiderPosition列表
      * 查询RiderPosition列表
      */
      */

+ 4 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderListPaymentFilterTest.java

@@ -81,9 +81,10 @@ class OrderListPaymentFilterTest {
         String sql = query.getSqlSegment();
         String sql = query.getSqlSegment();
         Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
         Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
         assertTrue(sql.contains("(pay_status ="));
         assertTrue(sql.contains("(pay_status ="));
-        assertTrue(sql.contains("OR (pay_type ="));
+        assertTrue(sql.contains("OR (pay_type IN"));
         assertTrue(sql.contains("AND type IN"));
         assertTrue(sql.contains("AND type IN"));
         assertEquals(1, Collections.frequency(parameters, "1"));
         assertEquals(1, Collections.frequency(parameters, "1"));
+        assertEquals(1, Collections.frequency(parameters, "4"));
     }
     }
 
 
     @Test
     @Test
@@ -109,9 +110,10 @@ class OrderListPaymentFilterTest {
         String sql = query.getSqlSegment();
         String sql = query.getSqlSegment();
         Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
         Collection<Object> parameters = new ArrayList<>(query.getParamNameValuePairs().values());
         assertTrue(sql.contains("(pay_status ="));
         assertTrue(sql.contains("(pay_status ="));
-        assertTrue(sql.contains("OR (pay_type ="));
+        assertTrue(sql.contains("OR (pay_type IN"));
         assertTrue(sql.contains("AND type IN"));
         assertTrue(sql.contains("AND type IN"));
         assertEquals(1, Collections.frequency(parameters, "1"));
         assertEquals(1, Collections.frequency(parameters, "1"));
+        assertEquals(1, Collections.frequency(parameters, "4"));
     }
     }
 
 
     @ParameterizedTest
     @ParameterizedTest

+ 23 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedCursorTest.java

@@ -116,4 +116,27 @@ class UnifiedCursorTest {
         assertEquals(false, takeawayCursor.includesSameTimeAnyId(OrderSource.TAKEAWAY));
         assertEquals(false, takeawayCursor.includesSameTimeAnyId(OrderSource.TAKEAWAY));
         assertEquals(true, takeawayCursor.includesSameTimeLowerId(OrderSource.TAKEAWAY));
         assertEquals(true, takeawayCursor.includesSameTimeLowerId(OrderSource.TAKEAWAY));
     }
     }
+
+    @Test
+    void dineinAndPickupCursorsAdvanceLikeOwnSource() {
+        // dinein 游标:本源同秒需 id 更小;更大源(字典序 dinein < flash < pickup < takeaway)同秒整段包含
+        UnifiedCursor cursor = UnifiedCursor.of(1000L, OrderSource.DINEIN, 9L);
+        assertEquals("1000_dinein_9", cursor.encode());
+        assertEquals(cursor, UnifiedCursor.parse(cursor.encode()));
+        assertEquals(false, cursor.includesSameTimeAnyId(OrderSource.DINEIN));
+        assertEquals(true, cursor.includesSameTimeLowerId(OrderSource.DINEIN));
+        assertEquals(true, cursor.includesSameTimeAnyId(OrderSource.FLASH));
+        assertEquals(true, cursor.includesSameTimeAnyId(OrderSource.PICKUP));
+        assertEquals(true, cursor.includesSameTimeAnyId(OrderSource.TAKEAWAY));
+        assertEquals(false, cursor.includesSameTimeLowerId(OrderSource.PICKUP));
+
+        // pickup 游标:更小源(dinein/flash)同秒整段不包含,退化严格小于
+        UnifiedCursor pickupCursor = UnifiedCursor.of(1000L, OrderSource.PICKUP, 7L);
+        assertEquals("1000_pickup_7", pickupCursor.encode());
+        assertEquals(pickupCursor, UnifiedCursor.parse(pickupCursor.encode()));
+        assertEquals(false, pickupCursor.includesSameTimeAnyId(OrderSource.FLASH));
+        assertEquals(false, pickupCursor.includesSameTimeLowerId(OrderSource.FLASH));
+        assertEquals(false, pickupCursor.includesSameTimeAnyId(OrderSource.PICKUP));
+        assertEquals(true, pickupCursor.includesSameTimeLowerId(OrderSource.PICKUP));
+    }
 }
 }

+ 18 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryControllerTest.java

@@ -125,6 +125,24 @@ class UnifiedOrderQueryControllerTest {
         assertSame(view, result.get(AjaxResult.DATA_TAG));
         assertSame(view, result.get(AjaxResult.DATA_TAG));
     }
     }
 
 
+    @Test
+    void dineinAndPickupSourcesAreForwardedToService() {
+        UnifiedOrderPageView view = new UnifiedOrderPageView();
+        when(service.query(eq(9L), eq(UnifiedOrderTab.ALL), eq(OrderSource.DINEIN), isNull(), anyInt()))
+                .thenReturn(view);
+        when(service.query(eq(9L), eq(UnifiedOrderTab.ALL), eq(OrderSource.PICKUP), isNull(), anyInt()))
+                .thenReturn(view);
+
+        UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
+        AjaxResult dinein = controller.list(JwtUtil.token("9", "user"), "all", "dinein", null, 10);
+        AjaxResult pickup = controller.list(JwtUtil.token("9", "user"), "all", "pickup", null, 10);
+
+        assertEquals(200, ((Number) dinein.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(view, dinein.get(AjaxResult.DATA_TAG));
+        assertEquals(200, ((Number) pickup.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(view, pickup.get(AjaxResult.DATA_TAG));
+    }
+
     @Test
     @Test
     void nullUserIdFromBrokenTokenYieldsErrorInsteadOfCrash() {
     void nullUserIdFromBrokenTokenYieldsErrorInsteadOfCrash() {
         UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);
         UnifiedOrderQueryController controller = new UnifiedOrderQueryController(service);

+ 79 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderQueryServiceTest.java

@@ -161,6 +161,74 @@ class UnifiedOrderQueryServiceTest {
         verify(posOrderMapper, times(1)).selectPage(any(), any());
         verify(posOrderMapper, times(1)).selectPage(any(), any());
     }
     }
 
 
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void sourceFilterAddsPosOrderTypePredicate() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+        doReturn(pageOf(List.of())).when(flashOrderMapper).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.TAKEAWAY, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.PICKUP, null, 10);
+        service.query(USER_ID, UnifiedOrderTab.ALL, null, null, 10);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(posOrderMapper, times(4)).selectPage(any(), captor.capture());
+        List<LambdaQueryWrapper<PosOrder>> wrappers = captor.getAllValues().stream()
+                .map(wrapper -> (LambdaQueryWrapper<PosOrder>) wrapper)
+                .toList();
+        // takeaway 仅外送 type=0;dinein=堂食 type=2;pickup=自取 type=1
+        assertTrue(wrappers.get(0).getSqlSegment().contains("type ="), wrappers.get(0).getSqlSegment());
+        assertTrue(wrappers.get(0).getParamNameValuePairs().containsValue(0L),
+                wrappers.get(0).getParamNameValuePairs().toString());
+        assertTrue(wrappers.get(1).getSqlSegment().contains("type ="), wrappers.get(1).getSqlSegment());
+        assertTrue(wrappers.get(1).getParamNameValuePairs().containsValue(2L),
+                wrappers.get(1).getParamNameValuePairs().toString());
+        assertTrue(wrappers.get(2).getSqlSegment().contains("type ="), wrappers.get(2).getSqlSegment());
+        assertTrue(wrappers.get(2).getParamNameValuePairs().containsValue(1L),
+                wrappers.get(2).getParamNameValuePairs().toString());
+        // source=all 不限 type,堂食/自取单照常混排
+        assertFalse(wrappers.get(3).getSqlSegment().contains("type ="), wrappers.get(3).getSqlSegment());
+    }
+
+    @Test
+    void dineinAndPickupQueryOnlyPosOrderPathAndTagCursorWithOwnSource() {
+        doReturn(pageOf(List.of(takeaway(21L, 3000L), takeaway(22L, 2000L))))
+                .when(posOrderMapper).selectPage(any(), any());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN, null, 1);
+
+        assertEquals(1, view.getList().size());
+        // 卡片仍是外卖样式,堂食/自取由 type 字段渲染
+        assertEquals("takeaway", view.getList().get(0).getSourceType());
+        assertTrue(view.isHasMore());
+        assertEquals("3000000_dinein_21", view.getNextCursor());
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.PICKUP, null, 10);
+        verify(posOrderMapper, times(2)).selectPage(any(), any());
+        verify(flashOrderMapper, never()).selectPage(any(), any());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void dineinCursorDrivesSameSecondIdDecisivePredicateOnNextScroll() {
+        doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
+
+        service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.DINEIN,
+                UnifiedCursor.parse("3000000_dinein_21"), 10);
+
+        ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper> captor =
+                ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
+        verify(posOrderMapper).selectPage(any(), captor.capture());
+        LambdaQueryWrapper<PosOrder> wrapper = (LambdaQueryWrapper<PosOrder>) captor.getValue();
+        // 同源游标:同秒 id 决胜分支存在,保证翻页不丢同秒行
+        String sql = wrapper.getSqlSegment();
+        assertTrue(sql.contains(" OR "), sql);
+        assertTrue(sql.contains("type ="), sql);
+    }
+
     @Test
     @Test
     void unpaidAndRefundTabsNeverQueryFlashPath() {
     void unpaidAndRefundTabsNeverQueryFlashPath() {
         doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
         doReturn(pageOf(List.of())).when(posOrderMapper).selectPage(any(), any());
@@ -313,6 +381,17 @@ class UnifiedOrderQueryServiceTest {
         assertEquals("sender", view.getList().get(0).getRole());
         assertEquals("sender", view.getList().get(0).getRole());
     }
     }
 
 
+    @Test
+    void flashItemsCarryOrderVersion() {
+        FlashDeliveryOrder order = flash(31L, 3000L);
+        order.setVersion(7);
+        doReturn(pageOf(List.of(order))).when(flashOrderMapper).selectPage(any(), any());
+
+        UnifiedOrderPageView view = service.query(USER_ID, UnifiedOrderTab.ALL, OrderSource.FLASH, null, 10);
+
+        assertEquals(7, view.getList().get(0).getOrderVersion());
+    }
+
     // ---------- helpers ----------
     // ---------- helpers ----------
 
 
     private static PosOrder takeaway(long id, long timeSec) {
     private static PosOrder takeaway(long id, long timeSec) {

+ 9 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/unifiedorder/UnifiedOrderTabTest.java

@@ -81,10 +81,13 @@ class UnifiedOrderTabTest {
         // 在线已付履约中
         // 在线已付履约中
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 1L, "2", 0L)));
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 1L, "2", 0L)));
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 0L, 1L, "3", 0L)));
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 0L, 1L, "3", 0L)));
-        // 货到付款(payType=1)仅自取/堂食可履约
+        // 货到付款(payType=1)/现金(payType=4)仅自取/堂食可履约
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(1L, 1L, 0L, "1", 0L)));
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(1L, 1L, 0L, "1", 0L)));
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 2L, 0L, "1", 0L)));
         assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 2L, 0L, "1", 0L)));
         assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "1", 0L)));
         assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "1", 0L)));
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(1L, 1L, 0L, "4", 0L)));
+        assertTrue(UnifiedOrderTab.ACTIVE.matches(pos(2L, 2L, 0L, "4", 0L)));
+        assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "4", 0L)));
         // 在线未付不履约
         // 在线未付不履约
         assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "2", 0L)));
         assertFalse(UnifiedOrderTab.ACTIVE.matches(pos(0L, 0L, 0L, "2", 0L)));
         // 终态不进进行中
         // 终态不进进行中
@@ -195,12 +198,15 @@ class UnifiedOrderTabTest {
 
 
     @Test
     @Test
     void activeWrapperCarriesStateAndPaymentConditions() {
     void activeWrapperCarriesStateAndPaymentConditions() {
-        String sql = UnifiedOrderTab.ACTIVE.posOrderWrapper().getSqlSegment();
+        LambdaQueryWrapper<PosOrder> wrapper = UnifiedOrderTab.ACTIVE.posOrderWrapper();
+        String sql = wrapper.getSqlSegment();
         assertTrue(sql.contains("state IN"), sql);
         assertTrue(sql.contains("state IN"), sql);
         assertTrue(sql.contains("after_sale_status ="), sql);
         assertTrue(sql.contains("after_sale_status ="), sql);
         assertTrue(sql.contains("(pay_status ="), sql);
         assertTrue(sql.contains("(pay_status ="), sql);
-        assertTrue(sql.contains("OR (pay_type ="), sql);
+        assertTrue(sql.contains("OR (pay_type IN"), sql);
         assertTrue(sql.contains("type IN"), sql);
         assertTrue(sql.contains("type IN"), sql);
+        assertTrue(wrapper.getParamNameValuePairs().values()
+                .containsAll(java.util.List.of("1", "4")), wrapper.getParamNameValuePairs().toString());
     }
     }
 
 
     @Test
     @Test

+ 181 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/RiderPositionControllerTest.java

@@ -0,0 +1,181 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.RiderPosition;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IRiderPositionService;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.math.BigDecimal;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 骑手定位上传:一人一条约束下的容错——历史重复行不再抛 selectOne 多行异常;
+ * 并发插入撞唯一索引转更新;新增/更新两分支行为不变。
+ */
+class RiderPositionControllerTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final IRiderPositionService riderPositionService = mock(IRiderPositionService.class);
+    private final IInfoUserService infoUserService = mock(IInfoUserService.class);
+    private final RiderPositionController controller = new RiderPositionController();
+
+    @BeforeAll
+    static void initializeBeanFactory() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功");
+        beanFactory.registerSingleton("messageSource", messageSource);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+    }
+
+    @AfterAll
+    static void restoreBeanFactory() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(controller, "riderPositionService", riderPositionService);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        when(infoUserService.getById(any())).thenReturn(new InfoUser());
+    }
+
+    @Test
+    void usesTolerantGetOneSoDuplicateRowsNoLongerCrash() {
+        RiderPosition existing = new RiderPosition();
+        existing.setId(7L);
+        existing.setRiderId(9L);
+        // 多行历史数据下 getOne(qw,false) 返回第一条,不再抛"Expected one result"
+        when(riderPositionService.getOne(any(Wrapper.class), eq(false))).thenReturn(existing);
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        // 单参 getOne 默认 throwEx=true,是本次线上 500 的直接来源,禁止再回到该调用
+        verify(riderPositionService, never()).getOne(any(Wrapper.class));
+        ArgumentCaptor<RiderPosition> captor = ArgumentCaptor.forClass(RiderPosition.class);
+        verify(riderPositionService).saveOrUpdate(captor.capture());
+        assertEquals(7L, captor.getValue().getId());
+    }
+
+    @Test
+    void concurrentInsertOnUniqueKeyFallsBackToUpdateByRider() {
+        when(riderPositionService.getOne(any(Wrapper.class), anyBoolean())).thenReturn(null);
+        // 并发上传双双查空:第二个 insert 撞 rider_id 唯一索引
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class)))
+                .thenThrow(new DuplicateKeyException("uk_rider_id"));
+        when(riderPositionService.update(any(RiderPosition.class), any(Wrapper.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        // 兜底转按 rider_id 更新已有记录,请求不 500
+        verify(riderPositionService).update(any(RiderPosition.class), any(Wrapper.class));
+    }
+
+    @Test
+    void firstUploadInsertsNewRecordWithRiderId() {
+        when(riderPositionService.getOne(any(Wrapper.class), anyBoolean())).thenReturn(null);
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        ArgumentCaptor<RiderPosition> captor = ArgumentCaptor.forClass(RiderPosition.class);
+        verify(riderPositionService).saveOrUpdate(captor.capture());
+        assertEquals(9L, captor.getValue().getRiderId());
+        verify(riderPositionService, never()).update(any(RiderPosition.class), any(Wrapper.class));
+    }
+
+    @Test
+    void secondUploadUpdatesExistingRecordById() {
+        RiderPosition existing = new RiderPosition();
+        existing.setId(7L);
+        when(riderPositionService.getOne(any(Wrapper.class), anyBoolean())).thenReturn(existing);
+        when(riderPositionService.saveOrUpdate(any(RiderPosition.class))).thenReturn(true);
+
+        AjaxResult result = controller.setRiderposition(JwtUtil.token("9", "user"), new RiderPosition());
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        verify(riderPositionService, times(1)).saveOrUpdate(any(RiderPosition.class));
+        verify(riderPositionService, never()).update(any(RiderPosition.class), any(Wrapper.class));
+    }
+
+    @Test
+    void getRiderpositionReturnsPositionByRiderId() {
+        RiderPosition position = new RiderPosition();
+        position.setId(7L);
+        position.setRiderId(9L);
+        position.setLatitude(new BigDecimal("10.770"));
+        position.setLongitude(new BigDecimal("106.700"));
+        when(riderPositionService.getOne(any(Wrapper.class), eq(false))).thenReturn(position);
+
+        AjaxResult result = controller.getRiderposition(JwtUtil.token("9", "user"), 9L);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertSame(position, result.get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void getRiderpositionWithoutRecordReturnsNullData() {
+        when(riderPositionService.getOne(any(Wrapper.class), eq(false))).thenReturn(null);
+
+        AjaxResult result = controller.getRiderposition(JwtUtil.token("9", "user"), 404L);
+
+        assertEquals(200, ((Number) result.get(AjaxResult.CODE_TAG)).intValue());
+        assertNull(result.get(AjaxResult.DATA_TAG));
+    }
+
+    @Test
+    void getRiderpositionDeclaresExplicitAnnotationsWithoutPermissionCheck() throws Exception {
+        Method method = RiderPositionController.class.getMethod("getRiderposition", String.class, Long.class);
+        assertArrayEquals(new String[]{"/getRiderposition"}, method.getAnnotation(GetMapping.class).value());
+        assertNotNull(method.getAnnotation(Anonymous.class));
+        assertNotNull(method.getAnnotation(Auth.class));
+        assertNull(method.getAnnotation(PreAuthorize.class));
+        Parameter[] parameters = method.getParameters();
+        assertNotNull(parameters[0].getAnnotation(RequestHeader.class));
+        RequestParam riderId = parameters[1].getAnnotation(RequestParam.class);
+        assertNotNull(riderId);
+    }
+}

+ 2 - 0
specs/027-offline-transfer-payment/data-model.md

@@ -14,6 +14,8 @@
 | 5 | Apple Pay | 不变 |
 | 5 | Apple Pay | 不变 |
 | **6** | **線下轉賬(新增语义)** | 本功能 |
 | **6** | **線下轉賬(新增语义)** | 本功能 |
 
 
+> **2026-09-21 变更**:现金(4)开放用户端**自取/堂食**下单(原仅商家创建订单可用,外送 type=0 维持仅商家)。pay_status 语义不变:创建时 0,商家 `confirmCashPayment` 确认后置 1;自取/堂食单商家 `completeOrder`(state 2→3)时自动置 1。详见 spec.md 变更记录。
+
 ### PosOrder.pay_status(支付状态)
 ### PosOrder.pay_status(支付状态)
 - 0=未支付,1=已支付。转账单:创建时 0,商家确认收款时 0→1(confirmTransferPayment)。
 - 0=未支付,1=已支付。转账单:创建时 0,商家确认收款时 0→1(confirmTransferPayment)。
 - 自取/堂食转账单:商家完成订单时现有逻辑自动置 1(completeOrder state 2→3)。
 - 自取/堂食转账单:商家完成订单时现有逻辑自动置 1(completeOrder state 2→3)。

+ 22 - 0
specs/027-offline-transfer-payment/spec.md

@@ -122,3 +122,25 @@
 - 骑手运费由骑手与商家线下交接,系统不记录、不结算。
 - 骑手运费由骑手与商家线下交接,系统不记录、不结算。
 - 支付方式值 6 为全新值,无历史数据冲突(历史值域 1-5)。
 - 支付方式值 6 为全新值,无历史数据冲突(历史值域 1-5)。
 - 商家确认收款入口同时面向商家 App 与商家 Web 预留(本期实现接口,Web 端按钮可后续按需补)。
 - 商家确认收款入口同时面向商家 App 与商家 Web 预留(本期实现接口,Web 端按钮可后续按需补)。
+
+---
+
+## 2026-09-21 变更记录:现金支付开放用户端堂食/自取下单
+
+现金(payType=4)原规则「仅商家创建订单可用,客户端下单直接拒绝」调整为:**用户端自取(type=1)/堂食(type=2)可直接以现金下单**;外送(type=0)维持仅商家创建(客户端仍报 `no.paytype.cash.merchant.only`)。
+
+**改动点**(commit `31bb384`,branch test):
+
+1. `UserOrderController.createOrder`:现金限制改为「type 非 1/2 才拒绝」
+2. `UserOrderController.orderList` active tab:线下收款分支 `payType='1'` 扩为 `IN ('1','4')`——现金自取/堂食单在商家确认收款前即可见
+3. `PosOrderShOprateController.orderList` pending tab:同上扩展——否则用户下的现金自取单(state=0)不进商家待受理列表
+4. `UnifiedOrderTab` ACTIVE(SQL 条件与 matches 内存判定同源扩展,常量 `OFFLINE_PAYABLE_PAY_TYPES=['1','4']`)
+5. 测试同步:`OrderListPaymentFilterTest`、`UnifiedOrderTabTest` 补 payType=4 断言(62/62 绿)
+
+**不变的语义**:
+
+- `confirmCashPayment`(商家确认现金收款,payStatus 0→1)流程不变;商家创建现金单路径不变
+- 待付款 tab 仍仅在线支付未付(现金/到付不进待付款)
+- `completeOrder`(自取/堂食完成)自动 payStatus 0→1 不变
+- 用户取消仅限 state=0;现金单未支付、无退款路径
+- LinePayOrderGuard 只拦 LINE Pay 未付单,现金单受理/出餐/完成不受影响

+ 2 - 0
specs/027-offline-transfer-payment/tasks.md

@@ -59,3 +59,5 @@ T007,T010 ──> T011 ──> T012
 ## MVP
 ## MVP
 
 
 US2(T001+T004+T005+T006):确认收款 + 骑手门禁是资金闸门核心;US1 银行信息接口紧随其后(App 展示依赖),US3 标签纯展示可最后。
 US2(T001+T004+T005+T006):确认收款 + 骑手门禁是资金闸门核心;US1 银行信息接口紧随其后(App 展示依赖),US3 标签纯展示可最后。
+
+- [x] T013(2026-09-21 追加)现金支付开放用户端堂食/自取下单:createOrder 放开 type=1/2、用户/商家/统一列表三处 tab 条件扩到 payType 1/4、测试补断言(commit 31bb384,62/62 绿)

+ 6 - 4
specs/030-unified-order-list/contracts/api-contract.md

@@ -11,7 +11,8 @@ GET /system/unifiedOrder/list
 Header: token
 Header: token
 Query:
 Query:
   tab     可选,枚举 all/unpaid/active/completed/cancelled/refund,默认 all
   tab     可选,枚举 all/unpaid/active/completed/cancelled/refund,默认 all
-  source  可选,枚举 all/takeaway/flash,默认 all
+  source  可选,枚举 all/takeaway/dinein/pickup/flash,默认 all
+          takeaway=仅外送单(type=0);dinein=堂食(type=2);pickup=自取(type=1);all=全部混排
   cursor  可选,首页不传;后续传上一页返回的 nextCursor 原样透传
   cursor  可选,首页不传;后续传上一页返回的 nextCursor 原样透传
   size    可选,默认 10,服务端钳制 1-50
   size    可选,默认 10,服务端钳制 1-50
 ```
 ```
@@ -53,7 +54,7 @@ Query:
 
 
 ### flash 渲染块(对齐现有闪送列表卡片)
 ### flash 渲染块(对齐现有闪送列表卡片)
 
 
-`status`(WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED)、`serviceType`、`deliveryType`、`vehicleType`、`deliveryMode`(NOW/SCHEDULED)、`scheduledPickupStartAt/scheduledPickupEndAt`、`pickupAddress/pickupDetailAddress`、`deliveryAddress/deliveryDetailAddress`、`packageType`、`quantity`、`tipAmount`
+`status`(WAITING_ACCEPTANCE/ACCEPTED/PICKED_UP/DELIVERED/COMPLETED/CANCELLED)、`orderVersion`(闪送乐观锁版本,用户编辑待接单订单须回传)、`serviceType`、`deliveryType`、`vehicleType`、`deliveryMode`(NOW/SCHEDULED)、`scheduledPickupStartAt/scheduledPickupEndAt`、`pickupAddress/pickupDetailAddress`、`deliveryAddress/deliveryDetailAddress`、`packageType`、`quantity`、`tipAmount`
 
 
 ## 4. tab 归类语义(前端文案对照)
 ## 4. tab 归类语义(前端文案对照)
 
 
@@ -73,13 +74,14 @@ Query:
 3. **角色标签**:`sourceType=flash && role=receiver` 的卡片显示"我收的"标识。
 3. **角色标签**:`sourceType=flash && role=receiver` 的卡片显示"我收的"标识。
 4. **角标**:`hasAfterSale=true` 的外卖卡片显示售后角标。
 4. **角标**:`hasAfterSale=true` 的外卖卡片显示售后角标。
 5. **空 tab**:待付款/退款售后里没有闪送单是正常现象,空态文案正常展示即可。
 5. **空 tab**:待付款/退款售后里没有闪送单是正常现象,空态文案正常展示即可。
-6. 旧的 `/system/userOrder/orderList` 与 `/system/flashDelivery/orders` 不下线,其它页面继续用。
+6. **堂食/自取筛选**:`source=dinein/pickup` 下卡片仍是 `sourceType=takeaway` 外卖样式,堂食/自取身份按卡片的 `type` 字段(2/1)渲染文案。
+7. 旧的 `/system/userOrder/orderList` 与 `/system/flashDelivery/orders` 不下线,其它页面继续用。
 
 
 ## 6. 联调自测清单
 ## 6. 联调自测清单
 
 
 1. 同一账号混有外卖+闪送单 → 全部 tab 按时间倒序混排,两种卡片正确渲染。
 1. 同一账号混有外卖+闪送单 → 全部 tab 按时间倒序混排,两种卡片正确渲染。
 2. 连续滚动到底 → 与该账号实际订单总数一致,无重复无遗漏。
 2. 连续滚动到底 → 与该账号实际订单总数一致,无重复无遗漏。
-3. 6 个 tab × 3 个来源筛选逐一核对归类(重点:待付款不含货到付款;售后单只在退款/售后 tab)。
+3. 6 个 tab × 5 个来源筛选逐一核对归类(重点:待付款不含货到付款;售后单只在退款/售后 tab;takeaway/dinein/pickup 三筛选取舍:外送单不出现在堂食/自取筛选,反之亦然)。
 4. A 寄给 B 的闪送单:A、B 各自列表都出现,角色分别为寄件/收件;自己寄给自己只出现一条。
 4. A 寄给 B 的闪送单:A、B 各自列表都出现,角色分别为寄件/收件;自己寄给自己只出现一条。
 5. 非法 cursor(手改字符串)→ 报错提示,重置回首页可用。
 5. 非法 cursor(手改字符串)→ 报错提示,重置回首页可用。
 6. 旧外卖列表页/闪送列表页(若有保留入口)行为不变。
 6. 旧外卖列表页/闪送列表页(若有保留入口)行为不变。

+ 6 - 4
specs/030-unified-order-list/data-model.md

@@ -26,15 +26,16 @@
 
 
 ### 闪送渲染块(flash,对齐 FlashDeliveryUserOrderListView 字段子集)
 ### 闪送渲染块(flash,对齐 FlashDeliveryUserOrderListView 字段子集)
 
 
-`status`(六态枚举原值)、`serviceType`、`deliveryType`、`vehicleType`、`deliveryMode`+`scheduledPickupStartAt/scheduledPickupEndAt`、`pickupAddress/pickupDetailAddress`、`deliveryAddress/deliveryDetailAddress`、`packageType`、`quantity`、`tipAmount`、`amount`。
+`status`(六态枚举原值)、`orderVersion`(乐观锁版本,编辑待接单订单须回传)、`serviceType`、`deliveryType`、`vehicleType`、`deliveryMode`+`scheduledPickupStartAt/scheduledPickupEndAt`、`pickupAddress/pickupDetailAddress`、`deliveryAddress/deliveryDetailAddress`、`packageType`、`quantity`、`tipAmount`、`amount`。
 
 
 ## 2. 游标 UnifiedCursor
 ## 2. 游标 UnifiedCursor
 
 
 - **格式**:`{createTimeMillis}_{sourceType}_{orderId}`,如 `1726812345000_flash_1024`。服务端生成,前端视为不透明字符串透传。
 - **格式**:`{createTimeMillis}_{sourceType}_{orderId}`,如 `1726812345000_flash_1024`。服务端生成,前端视为不透明字符串透传。
 - **排序全序**(归并与游标推进的同一规则):
 - **排序全序**(归并与游标推进的同一规则):
   1. `createTime DESC`
   1. `createTime DESC`
-  2. 同刻:`sourceType` 字典序 ASC(`flash` < `takeaway`,即同秒闪送排前面)
+  2. 同刻:`sourceType` 字典序 ASC(`dinein` < `flash` < `pickup` < `takeaway`)
   3. 同刻同源:`orderId DESC`
   3. 同刻同源:`orderId DESC`
+- **来源编码**:混排查询(source=all)的行与游标只有 `takeaway`/`flash`;`dinein`/`pickup` 仅在来源单选查询的游标中出现(堂食/自取是 pos_order.type 子集,单选时同源翻页同秒 id 决胜照常生效)。
 - **推进条件**(每源 SQL):本源取 `(t < T) OR (t = T AND 本源序 > cursor源序) OR (t = T AND 本源 = cursor源 AND id < cursorId)`,其中"本源序 > cursor 源序"在单源 SQL 中退化为常量布尔(实现封装在 UnifiedCursor,禁止散落拼接)。
 - **推进条件**(每源 SQL):本源取 `(t < T) OR (t = T AND 本源序 > cursor源序) OR (t = T AND 本源 = cursor源 AND id < cursorId)`,其中"本源序 > cursor 源序"在单源 SQL 中退化为常量布尔(实现封装在 UnifiedCursor,禁止散落拼接)。
 - **校验**:格式非法/数值越界 → 参数错误(消息 key),不静默当首页。
 - **校验**:格式非法/数值越界 → 参数错误(消息 key),不静默当首页。
 - **有效期**:游标仅是位置标记,无过期概念;源数据变化导致的位置偏移属正常现象(新下单会出现在首页,不影响旧游标正确性)。
 - **有效期**:游标仅是位置标记,无过期概念;源数据变化导致的位置偏移属正常现象(新下单会出现在首页,不影响旧游标正确性)。
@@ -46,7 +47,7 @@
 | tab | 条件 |
 | tab | 条件 |
 |---|---|
 |---|---|
 | unpaid | `payStatus=0 AND payType IN (2,3) AND state≠4 AND afterSaleStatus=0`(仅在线支付未付) |
 | unpaid | `payStatus=0 AND payType IN (2,3) AND state≠4 AND afterSaleStatus=0`(仅在线支付未付) |
-| active | `state IN (0,1,2) AND afterSaleStatus=0 AND (payStatus=1 OR (payType='1' AND type IN (1,2)))` |
+| active | `state IN (0,1,2) AND afterSaleStatus=0 AND (payStatus=1 OR (payType IN ('1','4') AND type IN (1,2)))`(2026-09-21:payType 扩入现金 4,随堂食/自取现金下单开放) |
 | completed | `state=3 AND afterSaleStatus=0` |
 | completed | `state=3 AND afterSaleStatus=0` |
 | cancelled | `state=4 AND afterSaleStatus=0` |
 | cancelled | `state=4 AND afterSaleStatus=0` |
 | refund | `afterSaleStatus>0` |
 | refund | `afterSaleStatus>0` |
@@ -76,7 +77,7 @@
 -- 外卖路(role 恒 sender)
 -- 外卖路(role 恒 sender)
 -- 注:PosOrder 实体无 del_flag 字段,且现有 orderList 亦不过滤,为保持行为零漂移不加该条件
 -- 注:PosOrder 实体无 del_flag 字段,且现有 orderList 亦不过滤,为保持行为零漂移不加该条件
 SELECT ... FROM pos_order
 SELECT ... FROM pos_order
-WHERE user_id = ? AND <tab条件> AND <游标推进>
+WHERE user_id = ? AND <来源type条件> AND <tab条件> AND <游标推进>
 ORDER BY cretim DESC, id DESC
 ORDER BY cretim DESC, id DESC
 LIMIT size+1
 LIMIT size+1
 
 
@@ -87,6 +88,7 @@ ORDER BY create_time DESC, id DESC
 LIMIT size+1
 LIMIT size+1
 ```
 ```
 
 
+- 来源 type 条件(2026-09-21 堂食/自取筛选):`source=takeaway → type=0`(仅外送)、`dinein → type=2`、`pickup → type=1`;`source=all` 不加 type 条件(堂食/自取照常混排)。dinein/pickup 是外卖路子集,跳过闪送路。
 - 游标推进中的"同刻同源 id < cursorId"与源内 `ORDER BY ... id DESC` 对应;跨源同刻并列由 Java 归并的 source 字典序处理。
 - 游标推进中的"同刻同源 id < cursorId"与源内 `ORDER BY ... id DESC` 对应;跨源同刻并列由 Java 归并的 source 字典序处理。
 - `tab=unpaid/refund`:跳过闪送路;`source` 单选:跳过另一路。
 - `tab=unpaid/refund`:跳过闪送路;`source` 单选:跳过另一路。
 - Java 归并:按全序(§2)取前 size 条,第 size+1 条存在 → `hasMore=true`;`nextCursor` = 末条(t, source, id)。
 - Java 归并:按全序(§2)取前 size 条,第 size+1 条存在 → `hasMore=true`;`nextCursor` = 末条(t, source, id)。

+ 2 - 2
specs/030-unified-order-list/plan.md

@@ -6,7 +6,7 @@
 
 
 ## Summary
 ## Summary
 
 
-用户端外卖(pos_order)与闪送(flash_delivery_order)订单合并为一个统一列表:Java 读时聚合(否决中间表/视图),新包 `com.ruoyi.app.unifiedorder` 提供独立接口 `GET /system/unifiedOrder/list`,支持 6 tab(沿用外卖现有枚举 all/unpaid/active/completed/cancelled/refund)+ 来源筛选(all/takeaway/flash)+ 游标滚动分页(严格 keyset,同秒跨源决胜:source 字典序 flash<takeaway + id DESC)。两路各查 `size+1` 条内存归并;闪送路含收件人视角(`user_id OR receiver_user_id`,role 字段区分);unpaid/refund tab 跳过闪送路。tab 归类做成表驱动谓词(同一份规则生成 SQL 条件与内存判定)。**零数据库结构变更**(唯一条件项:核实/补两表查询索引)。
+用户端外卖(pos_order)与闪送(flash_delivery_order)订单合并为一个统一列表:Java 读时聚合(否决中间表/视图),新包 `com.ruoyi.app.unifiedorder` 提供独立接口 `GET /system/unifiedOrder/list`,支持 6 tab(沿用外卖现有枚举 all/unpaid/active/completed/cancelled/refund)+ 来源筛选(all/takeaway/dinein/pickup/flash,2026-09-21 扩堂食/自取:takeaway=type0 仅外送、dinein=type2、pickup=type1)+ 游标滚动分页(严格 keyset,同秒跨源决胜:source 字典序 + id DESC)。两路各查 `size+1` 条内存归并;闪送路含收件人视角(`user_id OR receiver_user_id`,role 字段区分);unpaid/refund tab 跳过闪送路。tab 归类做成表驱动谓词(同一份规则生成 SQL 条件与内存判定)。**零数据库结构变更**(唯一条件项:核实/补两表查询索引)。
 
 
 ## Technical Context
 ## Technical Context
 
 
@@ -67,7 +67,7 @@ ruoyi-admin/src/main/java/com/ruoyi/app/unifiedorder/
 ├── UnifiedOrderQueryController.java   # GET /system/unifiedOrder/list
 ├── UnifiedOrderQueryController.java   # GET /system/unifiedOrder/list
 ├── UnifiedOrderQueryService.java      # 两路查询 + 归并 + 游标推进 + 店铺名批量回填
 ├── UnifiedOrderQueryService.java      # 两路查询 + 归并 + 游标推进 + 店铺名批量回填
 ├── UnifiedOrderTab.java               # tab 枚举 + 表驱动谓词(wrapper 条件构造 & 实体内存判定同源)
 ├── UnifiedOrderTab.java               # tab 枚举 + 表驱动谓词(wrapper 条件构造 & 实体内存判定同源)
-├── OrderSource.java                   # takeaway/flash 枚举(字典序决胜依据)
+├── OrderSource.java                   # takeaway/dinein/pickup/flash 枚举(字典序决胜依据;dinein/pickup 为 pos_order type 子集)
 ├── UnifiedCursor.java                 # 游标:生成/解析/校验(时间_source_id)
 ├── UnifiedCursor.java                 # 游标:生成/解析/校验(时间_source_id)
 └── dto/
 └── dto/
     ├── UnifiedOrderListItem.java      # 公共字段 + takeaway 渲染块 + flash 渲染块
     ├── UnifiedOrderListItem.java      # 公共字段 + takeaway 渲染块 + flash 渲染块

+ 3 - 1
specs/030-unified-order-list/quickstart.md

@@ -38,7 +38,9 @@ curl '.../list?tab=active'  # 外卖履约中 + 闪送非终态
 
 
 # 4. 来源筛选 + 收件视角
 # 4. 来源筛选 + 收件视角
 curl '.../list?source=flash' -H 'token: <B的token>'   # 含 A 寄 B 的单,role=receiver
 curl '.../list?source=flash' -H 'token: <B的token>'   # 含 A 寄 B 的单,role=receiver
-curl '.../list?source=takeaway'                        # 仅外卖
+curl '.../list?source=takeaway'                        # 仅外送单(type=0,不含堂食/自取)
+curl '.../list?source=dinein'                          # 仅堂食(type=2,卡片 sourceType 仍为 takeaway)
+curl '.../list?source=pickup'                          # 仅自取(type=1)
 
 
 # 5. 非法游标
 # 5. 非法游标
 curl '.../list?cursor=garbage'                          # 参数错误提示
 curl '.../list?cursor=garbage'                          # 参数错误提示

+ 3 - 3
specs/030-unified-order-list/spec.md

@@ -12,7 +12,7 @@
 
 
 ### User Story 1 - 统一列表浏览与混排 (Priority: P1)
 ### User Story 1 - 统一列表浏览与混排 (Priority: P1)
 
 
-用户打开订单页,看到的不再需要区分"外卖订单""闪送订单"两个入口,而是一个按下单时间倒序混排的统一列表:外卖单和闪送单交替出现,各自用现有的卡片样式渲染(外卖卡显示店铺/商品/配送信息,闪送卡显示取收地址/物品/小费)。列表支持无限滚动加载;顶部支持来源筛选(全部/外卖/闪送)。点击任意一单跳转到该单现有的详情页。
+用户打开订单页,看到的不再需要区分"外卖订单""闪送订单"两个入口,而是一个按下单时间倒序混排的统一列表:外卖单和闪送单交替出现,各自用现有的卡片样式渲染(外卖卡显示店铺/商品/配送信息,闪送卡显示取收地址/物品/小费)。列表支持无限滚动加载;顶部支持来源筛选(全部/外卖/堂食/自取/闪送,2026-09-21 扩堂食自取:外卖=仅外送单)。点击任意一单跳转到该单现有的详情页。
 
 
 **Why this priority**: 这是功能的核心价值——"像淘宝京东一样所有订单一个列表";没有它其余无意义。
 **Why this priority**: 这是功能的核心价值——"像淘宝京东一样所有订单一个列表";没有它其余无意义。
 
 
@@ -22,7 +22,7 @@
 
 
 1. **Given** 用户名下同时存在外卖单与闪送单,**When** 打开统一列表(tab=全部,来源=全部),**Then** 两类订单按下单时间倒序混排,卡片按来源用各自样式渲染
 1. **Given** 用户名下同时存在外卖单与闪送单,**When** 打开统一列表(tab=全部,来源=全部),**Then** 两类订单按下单时间倒序混排,卡片按来源用各自样式渲染
 2. **Given** 滚动到列表底部,**When** 继续上拉,**When** 服务端按游标返回下一页,**Then** 所有订单在全部翻页中恰好出现一次(不漏不重)
 2. **Given** 滚动到列表底部,**When** 继续上拉,**When** 服务端按游标返回下一页,**Then** 所有订单在全部翻页中恰好出现一次(不漏不重)
-3. **Given** 来源筛选选择"外卖"(或"闪送"),**When** 查询,**Then** 仅返回该来源订单,滚动分页同样完整
+3. **Given** 来源筛选选择"外卖"(或"堂食"/"自取"/"闪送"),**When** 查询,**Then** 仅返回该来源订单(外卖=外送单不含堂食自取、堂食/自取反之),滚动分页同样完整
 4. **Given** 点击列表项,**When** 前端跳转,**Then** 凭列表提供的订单键直达该来源现有详情页
 4. **Given** 点击列表项,**When** 前端跳转,**Then** 凭列表提供的订单键直达该来源现有详情页
 
 
 ---
 ---
@@ -77,7 +77,7 @@
 
 
 - **FR-001**: 系统 MUST 提供统一订单列表查询接口:登录用户按下单时间倒序查看名下全部外卖与闪送订单的混排结果,支持无限滚动(游标分页:请求携带上一页返回的 cursor,响应含 list/hasMore/nextCursor)。
 - **FR-001**: 系统 MUST 提供统一订单列表查询接口:登录用户按下单时间倒序查看名下全部外卖与闪送订单的混排结果,支持无限滚动(游标分页:请求携带上一页返回的 cursor,响应含 list/hasMore/nextCursor)。
 - **FR-002**: 列表 MUST 提供 6 个 tab 筛选(all/unpaid/active/completed/cancelled/refund,枚举名与现有外卖列表一致)且归类满足:外卖沿用现有判定(在线支付未付且无售后→待付款;履约中且无售后→进行中;已完成且无售后→已完成;已取消且无售后→已取消;售后标记>0→退款·售后);闪送按状态映射(非终态→进行中;已完成→已完成;已取消→已取消;待付款/退款·售后 tab 闪送侧为空)。
 - **FR-002**: 列表 MUST 提供 6 个 tab 筛选(all/unpaid/active/completed/cancelled/refund,枚举名与现有外卖列表一致)且归类满足:外卖沿用现有判定(在线支付未付且无售后→待付款;履约中且无售后→进行中;已完成且无售后→已完成;已取消且无售后→已取消;售后标记>0→退款·售后);闪送按状态映射(非终态→进行中;已完成→已完成;已取消→已取消;待付款/退款·售后 tab 闪送侧为空)。
-- **FR-003**: 列表 MUST 支持来源筛选(all/takeaway/flash 三值),与 tab 可自由组合。
+- **FR-003**: 列表 MUST 支持来源筛选(all/takeaway/dinein/pickup/flash 五值;2026-09-21 扩堂食/自取:takeaway=仅外送单 type=0、dinein=堂食 type=2、pickup=自取 type=1、all 不限 type 混排),与 tab 可自由组合。
 - **FR-004**: 游标分页 MUST 保证翻页完整性:全部翻页遍历中每个订单恰好出现一次,含同秒跨源并列场景(确定性决胜规则)。
 - **FR-004**: 游标分页 MUST 保证翻页完整性:全部翻页遍历中每个订单恰好出现一次,含同秒跨源并列场景(确定性决胜规则)。
 - **FR-005**: 统一列表项 MUST 携带:来源类型、角色标记(仅闪送:寄件/收件)、订单标识与业务单号(详情跳转键)、统一状态(tab 同款枚举)、下单时间、金额与币种、售后标记,以及两个来源各自的卡片渲染字段块(外卖块含订单类型/状态族原值/店铺名等;闪送块对齐现有闪送列表卡片字段子集)。
 - **FR-005**: 统一列表项 MUST 携带:来源类型、角色标记(仅闪送:寄件/收件)、订单标识与业务单号(详情跳转键)、统一状态(tab 同款枚举)、下单时间、金额与币种、售后标记,以及两个来源各自的卡片渲染字段块(外卖块含订单类型/状态族原值/店铺名等;闪送块对齐现有闪送列表卡片字段子集)。
 - **FR-006**: 闪送订单 MUST 同时按寄件人与收件人视角纳入列表并以角色字段区分;寄件人=收件人的单仅出现一条(判寄件人)。
 - **FR-006**: 闪送订单 MUST 同时按寄件人与收件人视角纳入列表并以角色字段区分;寄件人=收件人的单仅出现一条(判寄件人)。

+ 8 - 0
specs/030-unified-order-list/tasks.md

@@ -106,6 +106,14 @@ mvn -pl ruoyi-admin -am test -Dtest='XxxTest' -Dsurefire.failIfNoSpecifiedTests=
 ## Phase 7: 追加需求(2026-09-21)——外卖卡片店铺信息修正
 ## Phase 7: 追加需求(2026-09-21)——外卖卡片店铺信息修正
 
 
 - [x] T021 外卖 `storeName` 修正为店铺名称:`UnifiedOrderQueryService` 批量回填由「shId 查 InfoUser 昵称」改为「mdId 查 PosStore.posName」,店铺已删/缺失时回退订单快照 posName/logo;新增 `storeAvatar`(PosStore.logo,同回退)、`goodsCount`(food 快照数组 number 求和,空/非法为 null);DTO/测试/api-contract/data-model 同步
 - [x] T021 外卖 `storeName` 修正为店铺名称:`UnifiedOrderQueryService` 批量回填由「shId 查 InfoUser 昵称」改为「mdId 查 PosStore.posName」,店铺已删/缺失时回退订单快照 posName/logo;新增 `storeAvatar`(PosStore.logo,同回退)、`goodsCount`(food 快照数组 number 求和,空/非法为 null);DTO/测试/api-contract/data-model 同步
+- [x] T022 ACTIVE 谓词线下收款分支扩入现金:`UnifiedOrderTab` `payType='1'` → `IN ('1','4')`(SQL 与 matches 同源,常量改 `OFFLINE_PAYABLE_PAY_TYPES`),配合堂食/自取开放用户现金下单(027 spec 2026-09-21 变更记录,commit 31bb384);测试补 payType=4 判定与 wrapper 断言
+
+---
+
+## Phase 8: 追加需求(2026-09-21)——来源筛选扩堂食/自取
+
+- [x] T023 `source` 扩枚举 `dinein`(堂食 type=2)/`pickup`(自取 type=1),`takeaway` 收窄为仅外送单(type=0),`all` 不限 type 照常混排:`OrderSource` 增枚举值+`posOrderType`;`UnifiedOrderQueryService` 外卖路按查询源加 type 过滤、游标推进源随查询源(dinein/pickup 单选翻页同秒 id 决胜)、单选跳过闪送路;游标编码单选时为 dinein/pickup,混排仍只产生 takeaway/flash;测试补 type 谓词/路由/游标推进;api-contract/data-model/quickstart 同步
+- [x] T024 闪送条目返回 `orderVersion`(乐观锁版本,取 `flash_delivery_order.version`,语义同 FlashDeliveryUserOrderListView):DTO flash 块加字段、`flashItem` 赋值;测试补 orderVersion 断言;api-contract/data-model 同步
 
 
 ---
 ---
 
 

+ 9 - 0
updatesql/sql.md

@@ -1729,3 +1729,12 @@ ALTER TABLE pos_order ADD INDEX idx_user_cretim (user_id, cretim);
 ALTER TABLE flash_delivery_order ADD INDEX idx_user_ctime (user_id, create_time);
 ALTER TABLE flash_delivery_order ADD INDEX idx_user_ctime (user_id, create_time);
 ALTER TABLE flash_delivery_order ADD INDEX idx_receiver_ctime (receiver_user_id, create_time);
 ALTER TABLE flash_delivery_order ADD INDEX idx_receiver_ctime (receiver_user_id, create_time);
 ```
 ```
+
+## 2026-09-21 骑手定位一人一条(rider_position 唯一索引)
+
+```sql
+-- 2026-09-21 骑手定位唯一索引:每个骑手只允许一条定位记录
+-- 前置:先清理历史重复数据(每个 rider_id 只保留一条,建议保留最新一条),否则建索引会失败
+-- 清理参考:DELETE p1 FROM rider_position p1 INNER JOIN rider_position p2 ON p1.rider_id = p2.rider_id AND p1.id < p2.id;
+ALTER TABLE rider_position ADD UNIQUE INDEX uk_rider_id (rider_id);
+```