4 次代碼提交 ad734c906b ... 3c4ce2c3c7

作者 SHA1 備註 提交日期
  刘云鑫 3c4ce2c3c7 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand 2 周之前
  刘云鑫 d32f5c628f Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand 2 周之前
  刘云鑫 1acb9a1920 feat:增加模糊查询 2 周之前
  刘云鑫 e905940606 feat:按商户查询桌号并按分类分组(商户ID 同门店ID) 2 周之前

+ 26 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingTableGroupVo.java

@@ -0,0 +1,26 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 预订桌号按分类分组
+ *
+ * @author system
+ */
+@Data
+@ApiModel(value = "StoreBookingTableGroupVo", description = "预订服务桌号按分类分组")
+public class StoreBookingTableGroupVo {
+
+    @ApiModelProperty(value = "分类ID(无分类时为 null)")
+    private Integer categoryId;
+
+    @ApiModelProperty(value = "分类名称")
+    private String categoryName;
+
+    @ApiModelProperty(value = "该分类下桌号列表(顺序与列表接口一致)")
+    private List<StoreBookingTableVo> tables;
+}

+ 23 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingTableReservationRowVo.java

@@ -0,0 +1,23 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 门店桌号分组场景:预约 + 桌关联一行(用于 XML 多表查询结果映射)
+ *
+ * @author system
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel(value = "StoreBookingTableReservationRowVo", description = "桌号分组-预约关联行")
+public class StoreBookingTableReservationRowVo extends UserReservationVo {
+
+    @ApiModelProperty(value = "桌号主键 store_table.id")
+    private Integer tableId;
+
+    @ApiModelProperty(value = "预约桌关联主键 user_reservation_table.id")
+    private Integer userReservationTableId;
+}

+ 10 - 2
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingTableVo.java

@@ -1,7 +1,6 @@
 package shop.alien.entity.store.vo;
 
 import com.fasterxml.jackson.annotation.JsonFormat;
-import com.fasterxml.jackson.annotation.JsonProperty;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
@@ -35,11 +34,17 @@ public class StoreBookingTableVo {
 
     @ApiModelProperty(value = "座位数")
     private Integer seatingCapacity;
-    
+
     @JsonProperty("seating_capacity")
     @ApiModelProperty(value = "座位数(下划线字段)")
     private Integer seatingCapacitySnake;
 
+    @ApiModelProperty(value = "桌状态(0:空闲 1:就餐中/已开台 2:其他 3:加餐);存在已到店(status=2)的预约时置为 1")
+    private Integer status;
+
+    @ApiModelProperty(value = "当前已到店预约ID(无已到店预约时为 null)")
+    private Integer currentReservationId;
+
     @ApiModelProperty(value = "类型(1:美食,2:通用)")
     private Integer type;
 
@@ -63,6 +68,9 @@ public class StoreBookingTableVo {
     @ApiModelProperty(value = "二维码URL")
     private String qrcodeUrl;
 
+    @ApiModelProperty(value = "该桌关联的预约信息列表(同一桌多条预约时聚合;无预约为空列表)")
+    private List<UserReservationVo> reservations;
+
     @ApiModelProperty(value = "APP桌码二维码URL")
     private String appQrcodeUrl;
 }

+ 12 - 0
alien-entity/src/main/java/shop/alien/mapper/UserReservationMapper.java

@@ -3,6 +3,7 @@ package shop.alien.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import org.apache.ibatis.annotations.Param;
 import shop.alien.entity.store.UserReservation;
+import shop.alien.entity.store.vo.StoreBookingTableReservationRowVo;
 import shop.alien.entity.store.vo.StoreReservationListVo;
 
 import java.util.Date;
@@ -78,4 +79,15 @@ public interface UserReservationMapper extends BaseMapper<UserReservation> {
             @Param("tableId") Integer tableId,
             @Param("storeId") Integer storeId
     );
+
+    /**
+     * 门店下所有未删除预约,左关联预约桌与桌号;一行对应 (预约, 桌) 一条关联。
+     * 用于按 store_table.id 聚合每桌的预约列表。
+     *
+     * @param storeId 门店ID
+     * @return 扁平结果行(含 tableId、userReservationTableId)
+     */
+    List<StoreBookingTableReservationRowVo> listReservationRowsWithTableByStoreId(
+            @Param("storeId") Integer storeId
+    );
 }

+ 59 - 0
alien-entity/src/main/resources/mapper/UserReservationMapper.xml

@@ -53,6 +53,65 @@
         <result column="created_time" property="createdTime" />
     </resultMap>
 
+    <!-- 桌号分组:预约 + 预约桌关联 + 桌号(左连接桌表,保留 urt.table_id) -->
+    <resultMap id="StoreBookingTableReservationRowVoMap" type="shop.alien.entity.store.vo.StoreBookingTableReservationRowVo">
+        <result column="table_id" property="tableId" />
+        <result column="user_reservation_table_id" property="userReservationTableId" />
+        <result column="id" property="id" />
+        <result column="reservation_no" property="reservationNo" />
+        <result column="user_id" property="userId" />
+        <result column="store_id" property="storeId" />
+        <result column="reservation_date" property="reservationDate" />
+        <result column="start_time" property="startTime" />
+        <result column="end_time" property="endTime" />
+        <result column="guest_count" property="guestCount" />
+        <result column="category_id" property="categoryId" />
+        <result column="status" property="status" />
+        <result column="actual_arrival_time" property="actualArrivalTime" />
+        <result column="remark" property="remark" />
+        <result column="reservation_user_name" property="reservationUserName" />
+        <result column="reservation_user_gender" property="reservationUserGender" />
+        <result column="reservation_user_phone" property="reservationUserPhone" />
+        <result column="created_time" property="createdTime" />
+        <result column="updated_time" property="updatedTime" />
+    </resultMap>
+
+    <select id="listReservationRowsWithTableByStoreId" resultMap="StoreBookingTableReservationRowVoMap">
+        SELECT
+            urt.id AS user_reservation_table_id,
+            urt.table_id AS table_id,
+            ur.id,
+            ur.reservation_no,
+            ur.user_id,
+            ur.store_id,
+            ur.reservation_date,
+            ur.start_time,
+            ur.end_time,
+            ur.guest_count,
+            ur.category_id,
+            ur.status,
+            ur.actual_arrival_time,
+            ur.remark,
+            ur.reservation_user_name,
+            ur.reservation_user_gender,
+            ur.reservation_user_phone,
+            ur.created_time,
+            ur.updated_time
+        FROM
+            user_reservation ur
+        INNER JOIN user_reservation_table urt ON ur.id = urt.reservation_id AND urt.delete_flag = 0
+        LEFT JOIN store_table st ON urt.table_id = st.id AND st.delete_flag = 0
+        WHERE
+            ur.delete_flag = 0
+            AND ur.store_id = #{storeId}
+        ORDER BY
+            ur.reservation_date DESC,
+            ur.start_time ASC,
+            ur.id ASC,
+            urt.sort ASC,
+            urt.id ASC
+    </select>
+
     <!-- 查询商家端预约信息列表 -->
     <select id="getStoreReservationList" resultMap="StoreReservationListVoMap">
         SELECT

+ 22 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreBookingTableController.java

@@ -10,6 +10,7 @@ import shop.alien.entity.result.R;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.StoreBookingTableBatchDTO;
 import shop.alien.entity.store.dto.StoreBookingTableDTO;
+import shop.alien.entity.store.vo.StoreBookingTableGroupVo;
 import shop.alien.entity.store.vo.StoreBookingTableVo;
 import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.store.service.StoreTableService;
@@ -252,4 +253,25 @@ public class StoreBookingTableController {
         }
     }
 
+    @ApiOperationSupport(order = 7)
+    @ApiOperation("按商户查询桌号并按分类分组(商户ID 同门店ID)")
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "merchantId", value = "商户ID(门店ID)", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "tableName", value = "桌子名称(桌号)模糊查询,可选", dataType = "String", paramType = "query", required = false)
+    })
+    @GetMapping("/listGroupedByMerchant")
+    public R<List<StoreBookingTableGroupVo>> listGroupedByMerchant(@RequestParam Integer merchantId,
+                                                                  @RequestParam(required = false) String tableName) {
+        log.info("StoreBookingTableController.listGroupedByMerchant?merchantId={}&tableName={}", merchantId, tableName);
+        if (merchantId == null) {
+            return R.fail("商户ID不能为空");
+        }
+        try {
+            return R.data(storeBookingTableService.listTablesGroupedByMerchant(merchantId, tableName));
+        } catch (Exception e) {
+            log.error("按商户查询桌号分组失败", e);
+            return R.fail("查询失败:" + e.getMessage());
+        }
+    }
+
 }

+ 10 - 0
alien-store/src/main/java/shop/alien/store/service/StoreBookingTableService.java

@@ -3,6 +3,7 @@ package shop.alien.store.service;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.vo.StoreBookingTableGroupVo;
 import shop.alien.entity.store.vo.StoreBookingTableVo;
 
 import java.util.List;
@@ -45,6 +46,15 @@ public interface StoreBookingTableService extends IService<StoreTable> {
     IPage<StoreBookingTableVo> getTableListPage(Integer pageNum, Integer pageSize, Integer storeId, Integer categoryId);
 
     /**
+     * 按商户查询全部桌号,并按预订分类分组(商户ID 即门店 store_id)
+     *
+     * @param merchantId 商户ID(门店ID)
+     * @param tableName  桌子名称(桌号)模糊条件,传空或空白则不过滤
+     * @return 按分类分组的桌号列表
+     */
+    List<StoreBookingTableGroupVo> listTablesGroupedByMerchant(Integer merchantId, String tableName);
+
+    /**
      * 新增预订服务桌号
      *
      * @param table 桌号对象

+ 235 - 2
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java

@@ -14,16 +14,25 @@ import org.springframework.util.StringUtils;
 import org.springframework.beans.BeanUtils;
 import shop.alien.entity.store.StoreBookingCategory;
 import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.vo.StoreBookingTableGroupVo;
+import shop.alien.entity.store.vo.StoreBookingTableReservationRowVo;
 import shop.alien.entity.store.vo.StoreBookingTableVo;
+import shop.alien.entity.store.vo.UserReservationVo;
 import shop.alien.mapper.StoreBookingTableMapper;
 import shop.alien.mapper.UserReservationMapper;
 import shop.alien.store.service.StoreBookingCategoryService;
 import shop.alien.store.service.StoreBookingTableService;
 import shop.alien.util.common.JwtUtil;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -66,6 +75,7 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
                 .comparing(StoreTable::getCategoryId, Comparator.nullsLast(Integer::compareTo)) // 先按类别排序
                 .thenComparing(table -> parseTableNumberForSort(table.getTableNumber()))); // 再按桌号排序
         
+        log.info("getTableList 完成 storeId={} categoryId={} tableCount={}", storeId, categoryId, list.size());
         return list;
     }
 
@@ -89,7 +99,7 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
                 ));
         
         // 转换为VO并设置分类名称
-        return tableList.stream()
+        List<StoreBookingTableVo> vos = tableList.stream()
                 .filter(table -> table != null)
                 .map(table -> {
                     StoreBookingTableVo vo = new StoreBookingTableVo();
@@ -102,6 +112,9 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
                     return vo;
                 })
                 .collect(Collectors.toList());
+        log.info("getTableListWithCategoryName 完成 storeId={} categoryId={} tableCount={} storeCategoryDefCount={}",
+                storeId, categoryId, vos.size(), categoryMap.size());
+        return vos;
     }
 
     @Override
@@ -131,7 +144,227 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
         
         return page;
     }
-    
+
+    /**
+     * 无分类桌号在分组 Map 中使用的占位 key;对外返回时分组 {@link StoreBookingTableGroupVo#getCategoryId()} 会转为 null。
+     */
+    private static final int UNCATEGORIZED_CATEGORY_GROUP_KEY = Integer.MIN_VALUE;
+
+    /**
+     * 按桌号(桌子名称)子串模糊过滤;{@code tableName} 为空或仅空白时返回原列表。
+     */
+    private static List<StoreBookingTableVo> filterBookingTablesByNameLike(List<StoreBookingTableVo> vos, String tableName) {
+        if (!StringUtils.hasText(tableName)) {
+            return vos;
+        }
+        String needle = tableName.trim();
+        return vos.stream()
+                .filter(vo -> vo != null && vo.getTableNumber() != null && vo.getTableNumber().contains(needle))
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public List<StoreBookingTableGroupVo> listTablesGroupedByMerchant(Integer merchantId, String tableName) {
+        if (merchantId == null) {
+            log.warn("listTablesGroupedByMerchant: merchantId 为空(应对应门店 storeId)");
+        }
+        log.info("listTablesGroupedByMerchant 开始 merchantId={} tableName={}", merchantId, tableName);
+        try {
+            // merchantId 在本项目中与门店 storeId 语义一致
+            List<StoreBookingTableVo> all = getTableListWithCategoryName(merchantId, null);
+            all = filterBookingTablesByNameLike(all, tableName);
+            Map<Integer, List<StoreBookingTableVo>> byCategory = groupTableVosByCategoryPreserveOrder(all);
+            Map<Integer, String> categoryNameMap = loadCategoryNamesForStoreAndGroupKeys(merchantId, byCategory.keySet());
+            Map<Integer, List<UserReservationVo>> reservationListByTableId = buildSortedReservationsByTableId(merchantId);
+            attachReservationsToTablesInCategoryMap(byCategory, reservationListByTableId);
+            List<StoreBookingTableGroupVo> result = buildStoreBookingTableGroupList(byCategory, categoryNameMap);
+            int reservationRowTotal = reservationListByTableId.values().stream().mapToInt(List::size).sum();
+            log.info(
+                    "listTablesGroupedByMerchant 成功 merchantId={} tableCount={} categoryGroupCount={} responseGroupCount={} "
+                            + "tablesWithReservations={} reservationRowTotal={}",
+                    merchantId, all.size(), byCategory.size(), result.size(),
+                    reservationListByTableId.size(), reservationRowTotal);
+            return result;
+        } catch (Exception e) {
+            log.error("listTablesGroupedByMerchant 失败 merchantId={} tableName={}", merchantId, tableName, e);
+            throw e;
+        }
+    }
+
+    /**
+     * 按分类 ID 分组桌号 VO:顺序与 {@link #getTableListWithCategoryName} 返回列表一致(LinkedHashMap 保序)。
+     * 无 {@code categoryId} 的桌统一归入占位 key {@link #UNCATEGORIZED_CATEGORY_GROUP_KEY},便于与真实分类 ID 区分。
+     */
+    private static Map<Integer, List<StoreBookingTableVo>> groupTableVosByCategoryPreserveOrder(List<StoreBookingTableVo> all) {
+        Map<Integer, List<StoreBookingTableVo>> byCategory = new LinkedHashMap<>();
+        for (StoreBookingTableVo vo : all) {
+            Integer key = vo.getCategoryId() != null ? vo.getCategoryId() : UNCATEGORIZED_CATEGORY_GROUP_KEY;
+            byCategory.computeIfAbsent(key, k -> new ArrayList<>()).add(vo);
+        }
+        log.info("groupTableVosByCategoryPreserveOrder 完成 tableCount={} categoryGroupCount={}", all.size(), byCategory.size());
+        return byCategory;
+    }
+
+    /**
+     * 按分组用到的分类 ID 批量查询分类名称(不依赖桌 VO 上已带的 categoryName,以分类表为准)。
+     *
+     * @param storeId     门店 ID(与入参 merchantId 一致)
+     * @param categoryKeys {@link #groupTableVosByCategoryPreserveOrder} 的 key 集合,含占位 key 时会被过滤
+     */
+    private Map<Integer, String> loadCategoryNamesForStoreAndGroupKeys(Integer storeId, Set<Integer> categoryKeys) {
+        List<Integer> categoryIds = categoryKeys.stream()
+                .filter(id -> id != null && !Integer.valueOf(UNCATEGORIZED_CATEGORY_GROUP_KEY).equals(id))
+                .collect(Collectors.toList());
+        if (categoryIds.isEmpty()) {
+            log.info("loadCategoryNamesForStoreAndGroupKeys 跳过查询 storeId={} categoryKeysSize={} reason=无有效分类ID",
+                    storeId, categoryKeys != null ? categoryKeys.size() : 0);
+            return new LinkedHashMap<>();
+        }
+        log.info("loadCategoryNamesForStoreAndGroupKeys 查询 storeId={} needCategoryIdCount={}", storeId, categoryIds.size());
+        log.debug("loadCategoryNamesForStoreAndGroupKeys categoryIds={}", categoryIds);
+        List<StoreBookingCategory> categories = storeBookingCategoryService.list(
+                new LambdaQueryWrapper<StoreBookingCategory>()
+                        .eq(StoreBookingCategory::getStoreId, storeId)
+                        .in(StoreBookingCategory::getId, categoryIds)
+        );
+        Map<Integer, String> map = categories.stream()
+                .collect(Collectors.toMap(
+                        StoreBookingCategory::getId,
+                        StoreBookingCategory::getCategoryName,
+                        (v1, v2) -> v1
+                ));
+        if (map.size() != categoryIds.size()) {
+            log.warn("loadCategoryNamesForStoreAndGroupKeys 分类数量不一致 storeId={} 请求id数={} 实际查到={} 可能存在脏分类或未归属当前门店的分类",
+                    storeId, categoryIds.size(), map.size());
+        } else {
+            log.info("loadCategoryNamesForStoreAndGroupKeys 完成 storeId={} loadedNameCount={}", storeId, map.size());
+        }
+        return map;
+    }
+
+    /**
+     * 查询门店下预约与桌的关联行,聚合成「桌 ID → 该桌预约列表」。
+     * <p>
+     * 数据来源:{@link UserReservationMapper#listReservationRowsWithTableByStoreId},一行对应一条 (预约, 桌);
+     * {@code tableId} / 预约主键为空时跳过(无桌或脏数据)。同一桌同一预约按预约 ID 去重,避免联表重复行,
+     * 最后按 {@link #sortReservationsForTableView} 规则排序。
+     */
+    private Map<Integer, List<UserReservationVo>> buildSortedReservationsByTableId(Integer merchantId) {
+        log.info("buildSortedReservationsByTableId 开始 merchantId={}", merchantId);
+        List<StoreBookingTableReservationRowVo> reservationRows =
+                userReservationMapper.listReservationRowsWithTableByStoreId(merchantId);
+        Map<Integer, Map<Integer, UserReservationVo>> reservationsByTableId = new HashMap<>();
+        int skippedInvalid = 0;
+        for (StoreBookingTableReservationRowVo row : reservationRows) {
+            if (row.getTableId() == null || row.getId() == null) {
+                skippedInvalid++;
+                continue;
+            }
+            reservationsByTableId
+                    .computeIfAbsent(row.getTableId(), k -> new LinkedHashMap<>())
+                    .putIfAbsent(row.getId(), toReservationVo(row));
+        }
+        Map<Integer, List<UserReservationVo>> reservationListByTableId = new HashMap<>();
+        for (Map.Entry<Integer, Map<Integer, UserReservationVo>> e : reservationsByTableId.entrySet()) {
+            List<UserReservationVo> reservationList = new ArrayList<>(e.getValue().values());
+            sortReservationsForTableView(reservationList);
+            reservationListByTableId.put(e.getKey(), reservationList);
+        }
+        int reservationRowTotal = reservationListByTableId.values().stream().mapToInt(List::size).sum();
+        if (skippedInvalid > 0) {
+            log.warn("buildSortedReservationsByTableId 丢弃无效行 merchantId={} skippedInvalid={}(tableId或预约id为空)",
+                    merchantId, skippedInvalid);
+        }
+        log.info(
+                "buildSortedReservationsByTableId 完成 merchantId={} sqlRowCount={} distinctTables={} distinctReservationLinks={}",
+                merchantId, reservationRows.size(), reservationListByTableId.size(), reservationRowTotal);
+        return reservationListByTableId;
+    }
+
+    /**
+     * 将每桌的预约列表写回 {@link StoreBookingTableVo#setReservations};无预约时置为空列表,避免前端收到 null。
+     * <p>
+     * 若该桌存在 {@link UserReservationVo#getStatus()} == 2(已到店)的预约,则将桌 {@link StoreBookingTableVo#setStatus}
+     * 置为 1(就餐中/已开台),并设置 {@link StoreBookingTableVo#setCurrentReservationId} 为其中最新到店的一条。
+     */
+    private static void attachReservationsToTablesInCategoryMap(
+            Map<Integer, List<StoreBookingTableVo>> byCategory,
+            Map<Integer, List<UserReservationVo>> reservationListByTableId) {
+        int tableTotal = 0;
+        int withArrivedReservation = 0;
+        for (List<StoreBookingTableVo> tables : byCategory.values()) {
+            for (StoreBookingTableVo tableVo : tables) {
+                tableTotal++;
+                List<UserReservationVo> reservations = reservationListByTableId.get(tableVo.getId());
+                if (reservations == null) {
+                    reservations = Collections.emptyList();
+                }
+                tableVo.setReservations(reservations);
+                UserReservationVo arrived = pickLatestArrivedReservation(reservations);
+                if (arrived != null) {
+                    withArrivedReservation++;
+                    tableVo.setStatus(TABLE_STATUS_DINING);
+                    tableVo.setCurrentReservationId(arrived.getId());
+                } else {
+                    tableVo.setCurrentReservationId(null);
+                }
+            }
+        }
+        log.info("attachReservationsToTablesInCategoryMap 完成 tableTotal={} withArrivedReservation={}",
+                tableTotal, withArrivedReservation);
+    }
+
+    private static final int RESERVATION_STATUS_ARRIVED = 2;
+    private static final int TABLE_STATUS_DINING = 1;
+
+    /**
+     * 多条已到店时取 {@code actual_arrival_time} 最新的一条,时间为空时按预约 id 较大者优先。
+     */
+    private static UserReservationVo pickLatestArrivedReservation(List<UserReservationVo> reservations) {
+        return reservations.stream()
+                .filter(r -> r.getStatus() != null && r.getStatus() == RESERVATION_STATUS_ARRIVED)
+                .max(Comparator
+                        .comparing(UserReservationVo::getActualArrivalTime, Comparator.nullsFirst(Date::compareTo))
+                        .thenComparing(UserReservationVo::getId, Comparator.nullsFirst(Integer::compareTo)))
+                .orElse(null);
+    }
+
+    /**
+     * 由「分类 key → 桌列表」与分类名称映射组装 {@link StoreBookingTableGroupVo} 列表;占位 key 在 VO 中展示为 categoryId/categoryName 的 null。
+     */
+    private static List<StoreBookingTableGroupVo> buildStoreBookingTableGroupList(
+            Map<Integer, List<StoreBookingTableVo>> byCategory,
+            Map<Integer, String> categoryNameMap) {
+        List<StoreBookingTableGroupVo> result = new ArrayList<>(byCategory.size());
+        for (Map.Entry<Integer, List<StoreBookingTableVo>> e : byCategory.entrySet()) {
+            StoreBookingTableGroupVo group = new StoreBookingTableGroupVo();
+            Integer cid = e.getKey();
+            group.setCategoryId(cid == UNCATEGORIZED_CATEGORY_GROUP_KEY ? null : cid);
+            group.setTables(e.getValue());
+            group.setCategoryName(cid == UNCATEGORIZED_CATEGORY_GROUP_KEY ? null : categoryNameMap.get(cid));
+            result.add(group);
+        }
+        log.info("buildStoreBookingTableGroupList 完成 responseGroupCount={}", result.size());
+        return result;
+    }
+
+    private static UserReservationVo toReservationVo(StoreBookingTableReservationRowVo row) {
+        UserReservationVo vo = new UserReservationVo();
+        BeanUtils.copyProperties(row, vo);
+        vo.setTableIds(Collections.singletonList(row.getTableId()));
+        return vo;
+    }
+
+    /**
+     * 同一桌多条预约:按预订日期倒序,再按开始时间正序
+     */
+    private static void sortReservationsForTableView(List<UserReservationVo> list) {
+        list.sort(Comparator
+                .comparing(UserReservationVo::getReservationDate, Comparator.nullsLast(Comparator.naturalOrder()))
+                .reversed()
+                .thenComparing(UserReservationVo::getStartTime, Comparator.nullsLast(String::compareTo)));
+    }
+
     /**
      * 解析桌号用于排序
      * 规则:字母(A-Z)优先,然后数字(由小到大)