فهرست منبع

feat: 统一订单列表来源筛选扩堂食/自取(030 T023)

source 新增 dinein(堂食 type=2)/pickup(自取 type=1);takeaway 收窄为仅外送单(type=0),all 不限 type 照常混排。
外卖路按查询源过滤 type 并以查询源做游标推进(dinein/pickup 单选翻页同秒 id 决胜),单选时跳过闪送路;
单选查询的游标编码为 dinein/pickup,混排游标仍只产生 takeaway/flash。契约与 030 文档同步。
qmj 1 روز پیش
والد
کامیت
3568789cf5

+ 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
      */
      */

+ 18 - 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 {

+ 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);

+ 68 - 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());

+ 5 - 3
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
 ```
 ```
@@ -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. 旧外卖列表页/闪送列表页(若有保留入口)行为不变。

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

@@ -33,8 +33,9 @@
 - **格式**:`{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),不静默当首页。
 - **有效期**:游标仅是位置标记,无过期概念;源数据变化导致的位置偏移属正常现象(新下单会出现在首页,不影响旧游标正确性)。
 - **有效期**:游标仅是位置标记,无过期概念;源数据变化导致的位置偏移属正常现象(新下单会出现在首页,不影响旧游标正确性)。
@@ -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 同时按寄件人与收件人视角纳入列表并以角色字段区分;寄件人=收件人的单仅出现一条(判寄件人)。

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

@@ -110,6 +110,12 @@ mvn -pl ruoyi-admin -am test -Dtest='XxxTest' -Dsurefire.failIfNoSpecifiedTests=
 
 
 ---
 ---
 
 
+## 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 同步
+
+---
+
 ## Dependencies & Execution Order
 ## Dependencies & Execution Order
 
 
 ### Phase Dependencies
 ### Phase Dependencies