3 Komitmen d6bb6f2109 ... 9e3bae3046

Pembuat SHA1 Pesan Tanggal
  刘云鑫 9e3bae3046 Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand 2 minggu lalu
  刘云鑫 c1be6a9b04 feat:换桌 2 minggu lalu
  刘云鑫 bca122501c feat:下单 2 minggu lalu

+ 8 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java

@@ -62,6 +62,14 @@ public class StoreOrder {
     @TableField("contact_phone")
     private String contactPhone;
 
+    @ApiModelProperty(value = "下单联系人姓名")
+    @TableField("dining_contact_name")
+    private String diningContactName;
+
+    @ApiModelProperty(value = "联系人性别(1男 2女)")
+    @TableField("dining_gender")
+    private Integer diningGender;
+
     @ApiModelProperty(value = "餐具费")
     @TableField("tableware_fee")
     private BigDecimal tablewareFee;

+ 68 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingChangeTableDTO.java

@@ -0,0 +1,68 @@
+package shop.alien.entity.store.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 预订场景「换桌」请求(与 {@code POST /store/booking/order/changeTable} 同一入参):
+ * <ul>
+ *   <li><b>仅查空闲桌</b>:只传 {@link #storeId},{@link #sourceTableIds} 不传或传空列表</li>
+ *   <li><b>执行换桌</b>:传 {@link #sourceTableIds}、{@link #targetTableIds}、{@link #carryReservationTime} 等,规则见下</li>
+ * </ul>
+ * 将源桌台上待支付的门店订单迁到目标空闲桌,并同步预约与桌台状态。
+ * <p>
+ * 场景说明(源桌数 × 目标桌数):
+ * <ul>
+ *   <li>1×1:单桌换单桌,预约时间沿用 {@link #carryReservationTime}=true 时不改约;否则填写新时间。</li>
+ *   <li>1×多:一桌订单对应多桌(拼桌/加台),订单主表落第一目标桌,预约关联表写入全部目标桌;时间规则同上。</li>
+ *   <li>多×1:多桌合并一桌,多笔待支付订单合并为一笔(明细与金额汇总),仅保留主订单号,其余订单置取消;时间相同则沿用,时间不一致须 {@link #carryReservationTime}=false 并填写统一新时段。</li>
+ *   <li>多×多(N=M):按源桌 ID、目标桌 ID 升序一一配对,分别迁移订单;预约关联表更新为全部新桌。</li>
+ * </ul>
+ * 多人加餐共一单:合并后仅一笔待支付,结账一次即全员状态一致(与加餐模型一致)。
+ */
+@Data
+@ApiModel(value = "StoreBookingChangeTableDTO", description = "预订换桌请求")
+public class StoreBookingChangeTableDTO {
+
+    @ApiModelProperty(value = "门店ID", required = true)
+    @NotNull(message = "门店ID不能为空")
+    private Integer storeId;
+
+    /**
+     * 当前占用中的源桌(须存在待支付 store_order)。不传或空表示「只查空闲桌」,不执行换桌。
+     */
+    @ApiModelProperty(value = "源桌台ID列表;空=仅返回空闲桌列表")
+    private List<Integer> sourceTableIds;
+
+    /**
+     * 目标空闲桌(须同店、空闲);与源桌组合需满足 1×1 / 1×多 / 多×1 / N×N(N&gt;1)。执行换桌时必填。
+     */
+    @ApiModelProperty(value = "目标空闲桌台ID列表(可多选);仅查询空闲桌时不传")
+    private List<Integer> targetTableIds;
+
+    /**
+     * true:沿用原预约日期与时段(多桌合并时要求各订单关联同一预约且时段一致,否则请先选 false)。
+     * false:必须填写 {@link #newReservationDate}、{@link #newStartTime}、{@link #newEndTime},用于「多桌时段不一致需统一改约」等场景。
+     * <p>
+     * 若所有订单均无预约(user_reservation_id 为空),本字段仅作占位,服务端不更新预约时间。
+     * 仅查询空闲桌时无需传。
+     */
+    @ApiModelProperty(value = "是否沿用原预约时间(执行换桌时必填)")
+    private Boolean carryReservationTime;
+
+    @ApiModelProperty(value = "新预约日期(carryReservationTime=false 且存在预约时必填)")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private Date newReservationDate;
+
+    @ApiModelProperty(value = "新预约开始时间 HH:mm(carryReservationTime=false 且存在预约时必填)")
+    private String newStartTime;
+
+    @ApiModelProperty(value = "新预约结束时间 HH:mm(carryReservationTime=false 且存在预约时必填)")
+    private String newEndTime;
+}

+ 62 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingPlaceOrderDTO.java

@@ -0,0 +1,62 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+/**
+ * 预订场景「下单」页提交:主表写入 store_order,明细写入 store_order_detail。
+ * <p>
+ * 同一桌已存在待支付订单时,接口行为为「加餐」:只追加本次提交的明细(is_add_dish=1),并累加订单金额,不生成新订单号。
+ * 同一笔待支付订单可多次调用本接口追加(多次加餐),不限次数。
+ */
+@Data
+@ApiModel(value = "StoreBookingPlaceOrderDTO", description = "预订下单请求")
+public class StoreBookingPlaceOrderDTO {
+
+    @ApiModelProperty(value = "门店ID", required = true)
+    @NotNull(message = "门店ID不能为空")
+    private Integer storeId;
+
+    @ApiModelProperty(value = "桌号ID", required = true)
+    @NotNull(message = "桌号ID不能为空")
+    private Integer tableId;
+
+    @ApiModelProperty(value = "加餐目标订单ID(可选)。若桌台 current_order_id 未维护或需显式指定,可传上次返回的 orderId,避免误开新单")
+    private Integer targetOrderId;
+
+    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id,无则不传)")
+    private Integer userReservationId;
+
+    @ApiModelProperty(value = "使用人数(就餐人数)", required = true)
+    @NotNull(message = "使用人数不能为空")
+    @Min(value = 1, message = "使用人数至少为1")
+    private Integer dinerCount;
+
+    @ApiModelProperty(value = "下单联系人姓名,对应 store_order.dining_contact_name")
+    @Size(max = 100, message = "联系人姓名过长")
+    private String diningContactName;
+
+    @ApiModelProperty(value = "手机号")
+    @Size(max = 20, message = "手机号过长")
+    private String contactPhone;
+
+    @ApiModelProperty(value = "联系人性别(1男 2女),对应 store_order.dining_gender")
+    private Integer diningGender;
+
+    @ApiModelProperty(value = "备注,最多30字")
+    @Size(max = 30, message = "备注最多30字")
+    private String remark;
+
+    @ApiModelProperty(value = "菜品明细", required = true)
+    @NotEmpty(message = "请至少选择一道菜品")
+    @Valid
+    private List<StoreBookingPlaceOrderItemDTO> items;
+}

+ 41 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingPlaceOrderItemDTO.java

@@ -0,0 +1,41 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+/**
+ * 预订场景下单:订单明细行(写入 store_order_detail)
+ */
+@Data
+@ApiModel(value = "StoreBookingPlaceOrderItemDTO", description = "预订下单-菜品行")
+public class StoreBookingPlaceOrderItemDTO {
+
+    @ApiModelProperty(value = "菜品ID", required = true)
+    @NotNull(message = "菜品ID不能为空")
+    private Integer cuisineId;
+
+    @ApiModelProperty(value = "菜品名称", required = true)
+    @NotBlank(message = "菜品名称不能为空")
+    private String cuisineName;
+
+    @ApiModelProperty(value = "菜品类型(1:单品, 2:套餐),不传默认1")
+    private Integer cuisineType;
+
+    @ApiModelProperty(value = "菜品图片")
+    private String cuisineImage;
+
+    @ApiModelProperty(value = "单价", required = true)
+    @NotNull(message = "单价不能为空")
+    private BigDecimal unitPrice;
+
+    @ApiModelProperty(value = "数量", required = true)
+    @NotNull(message = "数量不能为空")
+    @Min(value = 1, message = "数量至少为1")
+    private Integer quantity;
+}

+ 33 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingChangeTableResultVo.java

@@ -0,0 +1,33 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 预订换桌执行结果:便于前端刷新订单详情、桌态与预约信息。
+ */
+@Data
+@ApiModel(value = "StoreBookingChangeTableResultVo", description = "预订换桌结果")
+public class StoreBookingChangeTableResultVo {
+
+    @ApiModelProperty(value = "主订单ID(合并场景下为保留的待支付订单)")
+    private Integer primaryOrderId;
+
+    @ApiModelProperty(value = "主订单号")
+    private String primaryOrderNo;
+
+    @ApiModelProperty(value = "本次涉及的全部订单ID(含合并后被取消的从单)")
+    private List<Integer> affectedOrderIds;
+
+    @ApiModelProperty(value = "合并进主单后已取消的订单ID列表(非合并场景为空)")
+    private List<Integer> mergedIntoPrimaryOrderIds;
+
+    @ApiModelProperty(value = "换桌后的目标桌台ID列表(与预约 user_reservation_table 一致)")
+    private List<Integer> newTableIds;
+
+    @ApiModelProperty(value = "提示说明")
+    private String message;
+}

+ 25 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingChangeTableUnifiedResultVo.java

@@ -0,0 +1,25 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 换桌统一响应:同一接口先查空闲桌再提交换桌时,二选一有值。
+ * <ul>
+ *   <li>仅查询:{@link #idleTables} 有值,{@link #changeResult} 为 null</li>
+ *   <li>执行换桌:{@link #changeResult} 有值,{@link #idleTables} 为 null</li>
+ * </ul>
+ */
+@Data
+@ApiModel(value = "StoreBookingChangeTableUnifiedResultVo", description = "换桌统一响应(查空闲桌 / 执行换桌)")
+public class StoreBookingChangeTableUnifiedResultVo {
+
+    @ApiModelProperty(value = "空闲桌列表(仅查询模式)")
+    private List<StoreBookingTableVo> idleTables;
+
+    @ApiModelProperty(value = "换桌执行结果(仅提交换桌模式)")
+    private StoreBookingChangeTableResultVo changeResult;
+}

+ 27 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreBookingPlaceOrderResultVo.java

@@ -0,0 +1,27 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 预订下单成功返回
+ */
+@Data
+@ApiModel(value = "StoreBookingPlaceOrderResultVo", description = "预订下单结果")
+public class StoreBookingPlaceOrderResultVo {
+
+    @ApiModelProperty(value = "订单ID")
+    private Integer orderId;
+
+    @ApiModelProperty(value = "订单号")
+    private String orderNo;
+
+    @ApiModelProperty(value = "订单总金额(后端按明细汇总)")
+    private BigDecimal totalAmount;
+
+    @ApiModelProperty(value = "本次是否为加餐追加(true:追加明细;false:新订单首单)")
+    private Boolean addDish;
+}

+ 67 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreBookingOrderController.java

@@ -0,0 +1,67 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.dto.StoreBookingChangeTableDTO;
+import shop.alien.entity.store.dto.StoreBookingPlaceOrderDTO;
+import shop.alien.entity.store.vo.StoreBookingChangeTableUnifiedResultVo;
+import shop.alien.entity.store.vo.StoreBookingPlaceOrderResultVo;
+import shop.alien.store.service.StoreBookingOrderService;
+
+import javax.validation.Valid;
+
+/**
+ * 预订服务-门店订单(store_order / store_order_detail,不经过 alien-dining)
+ */
+@Slf4j
+@Api(tags = {"预订服务-门店下单"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/booking/order")
+@RequiredArgsConstructor
+public class StoreBookingOrderController {
+
+    private final StoreBookingOrderService storeBookingOrderService;
+
+    @ApiOperationSupport(order = 1)
+    @ApiOperation("提交订单(主表 store_order,明细 store_order_detail;联系人 dining_contact_name / dining_gender 1男2女。若该桌已有待支付订单则为加餐,可多次加餐;可选 targetOrderId 指定订单)")
+    @PostMapping("/place")
+    public R<StoreBookingPlaceOrderResultVo> placeOrder(@Valid @RequestBody StoreBookingPlaceOrderDTO dto) {
+        log.info("StoreBookingOrderController.placeOrder?storeId={}, tableId={}, dinerCount={}, itemSize={}",
+                dto.getStoreId(), dto.getTableId(), dto.getDinerCount(), dto.getItems() != null ? dto.getItems().size() : 0);
+        try {
+            return R.data(storeBookingOrderService.placeOrder(dto));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("placeOrder biz: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        } catch (Exception e) {
+            log.error("placeOrder error", e);
+            return R.fail("下单失败:" + e.getMessage());
+        }
+    }
+
+    @ApiOperationSupport(order = 2)
+    @ApiOperation("换桌统一接口:sourceTableIds 为空时仅返回 idleTables(空闲桌可多选);否则执行换桌,结果在 changeResult,详见 StoreBookingChangeTableDTO")
+    @PostMapping("/changeTable")
+    public R<StoreBookingChangeTableUnifiedResultVo> changeTable(@Valid @RequestBody StoreBookingChangeTableDTO dto) {
+        log.info("StoreBookingOrderController.changeTable?storeId={}, sourceSize={}, targetSize={}, carryTime={}",
+                dto.getStoreId(),
+                dto.getSourceTableIds() != null ? dto.getSourceTableIds().size() : 0,
+                dto.getTargetTableIds() != null ? dto.getTargetTableIds().size() : 0,
+                dto.getCarryReservationTime());
+        try {
+            return R.data(storeBookingOrderService.changeTable(dto));
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            log.warn("changeTable biz: {}", e.getMessage());
+            return R.fail(e.getMessage());
+        } catch (Exception e) {
+            log.error("changeTable error", e);
+            return R.fail("换桌失败:" + e.getMessage());
+        }
+    }
+}

+ 22 - 0
alien-store/src/main/java/shop/alien/store/service/StoreBookingOrderService.java

@@ -0,0 +1,22 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.store.dto.StoreBookingChangeTableDTO;
+import shop.alien.entity.store.dto.StoreBookingPlaceOrderDTO;
+import shop.alien.entity.store.vo.StoreBookingChangeTableUnifiedResultVo;
+import shop.alien.entity.store.vo.StoreBookingPlaceOrderResultVo;
+
+/**
+ * 预订场景门店订单(仅 alien-store:落库 store_order / store_order_detail)
+ */
+public interface StoreBookingOrderService {
+
+    /**
+     * 提交下单:写主表、明细,并更新桌台当前订单(与堂食订单表结构一致)
+     */
+    StoreBookingPlaceOrderResultVo placeOrder(StoreBookingPlaceOrderDTO dto);
+
+    /**
+     * 换桌统一入口:{@link StoreBookingChangeTableDTO#getSourceTableIds()} 为空则只返回空闲桌列表;否则执行换桌。
+     */
+    StoreBookingChangeTableUnifiedResultVo changeTable(StoreBookingChangeTableDTO dto);
+}

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

@@ -100,4 +100,12 @@ public interface StoreBookingTableService extends IService<StoreTable> {
      * @return true-有预订信息, false-没有预订信息
      */
     boolean hasReservationInTable(Integer tableId, Integer storeId);
+
+    /**
+     * 换桌场景:列出当前可选中的空闲桌(store_table.status=0),含分类名等展示字段,供前端多选目标桌。
+     *
+     * @param storeId 门店ID
+     * @return 空闲桌列表
+     */
+    List<StoreBookingTableVo> listIdleTablesForChange(Integer storeId);
 }

+ 667 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingOrderServiceImpl.java

@@ -0,0 +1,667 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import shop.alien.entity.store.StoreCuisine;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.StoreOrderDetail;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.UserReservation;
+import shop.alien.entity.store.UserReservationTable;
+import shop.alien.entity.store.dto.StoreBookingChangeTableDTO;
+import shop.alien.entity.store.dto.StoreBookingPlaceOrderDTO;
+import shop.alien.entity.store.dto.StoreBookingPlaceOrderItemDTO;
+import shop.alien.entity.store.vo.StoreBookingChangeTableResultVo;
+import shop.alien.entity.store.vo.StoreBookingChangeTableUnifiedResultVo;
+import shop.alien.entity.store.vo.StoreBookingPlaceOrderResultVo;
+import shop.alien.mapper.StoreCuisineMapper;
+import shop.alien.mapper.StoreOrderDetailMapper;
+import shop.alien.mapper.StoreOrderMapper;
+import shop.alien.mapper.StoreTableMapper;
+import shop.alien.mapper.UserReservationMapper;
+import shop.alien.mapper.UserReservationTableMapper;
+import shop.alien.store.service.StoreBookingOrderService;
+import shop.alien.store.service.StoreBookingTableService;
+import shop.alien.util.common.JwtUtil;
+import com.alibaba.fastjson.JSONObject;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 预订下单:不依赖 alien-dining,直接写入订单主表与明细表。
+ * <p>
+ * 同一桌已存在待支付订单时,视为「加餐」:仅追加明细(is_add_dish=1),累加总金额,不新建订单号。
+ * 同一待支付订单可多次加餐(多次调用本接口),不限次数。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(rollbackFor = Exception.class)
+public class StoreBookingOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOrder> implements StoreBookingOrderService {
+
+    private final StoreOrderDetailMapper storeOrderDetailMapper;
+    private final StoreTableMapper storeTableMapper;
+    private final StoreCuisineMapper storeCuisineMapper;
+    private final UserReservationMapper userReservationMapper;
+    private final UserReservationTableMapper userReservationTableMapper;
+    private final StoreBookingTableService storeBookingTableService;
+
+    @Override
+    public StoreBookingPlaceOrderResultVo placeOrder(StoreBookingPlaceOrderDTO dto) {
+        if (dto.getDiningGender() != null && dto.getDiningGender() != 1 && dto.getDiningGender() != 2) {
+            throw new IllegalArgumentException("联系人性别仅支持:1男 2女");
+        }
+
+        StoreTable table = storeTableMapper.selectById(dto.getTableId());
+        if (table == null || table.getStoreId() == null || !table.getStoreId().equals(dto.getStoreId())) {
+            throw new IllegalArgumentException("桌号不存在或与门店不匹配");
+        }
+
+        StoreOrder pendingOrder = resolvePendingOrder(dto, table);
+
+        Integer userId = null;
+        String userPhone = null;
+        try {
+            JSONObject jwt = JwtUtil.getCurrentUserInfo();
+            if (jwt != null) {
+                userId = jwt.getInteger("userId");
+                userPhone = jwt.getString("phone");
+            }
+        } catch (Exception e) {
+            log.debug("placeOrder: 未解析到登录用户,按匿名下单处理");
+        }
+
+        Date now = new Date();
+        BigDecimal linesSubtotal = validateAndSumLines(dto);
+
+        if (pendingOrder != null) {
+            return addDishesToExistingOrder(dto, table, pendingOrder, linesSubtotal, userId, userPhone, now);
+        }
+
+        String orderNo = generateOrderNo();
+
+        StoreOrder order = new StoreOrder();
+        order.setOrderNo(orderNo);
+        order.setStoreId(dto.getStoreId());
+        order.setTableId(dto.getTableId());
+        order.setTableNumber(table.getTableNumber());
+        order.setUserReservationId(dto.getUserReservationId());
+        order.setDinerCount(dto.getDinerCount());
+        order.setContactPhone(StringUtils.hasText(dto.getContactPhone()) ? dto.getContactPhone().trim() : null);
+        order.setDiningContactName(StringUtils.hasText(dto.getDiningContactName()) ? dto.getDiningContactName().trim() : null);
+        order.setDiningGender(dto.getDiningGender());
+        order.setPayUserId(userId);
+        order.setPayUserPhone(userPhone);
+        order.setOrderStatus(0);
+        order.setTotalAmount(linesSubtotal);
+        order.setDiscountAmount(BigDecimal.ZERO);
+        order.setTablewareFee(BigDecimal.ZERO);
+        order.setPayAmount(linesSubtotal);
+        order.setPayStatus(0);
+        order.setRemark(StringUtils.hasText(dto.getRemark()) ? dto.getRemark().trim() : null);
+        order.setCreatedUserId(userId);
+        order.setUpdatedUserId(userId);
+        order.setCreatedTime(now);
+        order.setUpdatedTime(now);
+        this.save(order);
+
+        insertDetailRows(order.getId(), orderNo, dto.getItems(), userId, userPhone, now, false);
+
+        StoreTable tablePatch = new StoreTable();
+        tablePatch.setId(table.getId());
+        tablePatch.setCurrentOrderId(order.getId());
+        tablePatch.setStatus(1);
+        tablePatch.setDinerCount(dto.getDinerCount());
+        storeTableMapper.updateById(tablePatch);
+
+        StoreBookingPlaceOrderResultVo vo = new StoreBookingPlaceOrderResultVo();
+        vo.setOrderId(order.getId());
+        vo.setOrderNo(orderNo);
+        vo.setTotalAmount(linesSubtotal);
+        vo.setAddDish(false);
+        log.info("预订首单成功 orderId={} orderNo={} tableId={} storeId={}", order.getId(), orderNo, dto.getTableId(), dto.getStoreId());
+        return vo;
+    }
+
+    /**
+     * 换桌统一入口:无源桌则只查空闲桌;有源桌则执行迁桌(1×1 / 1×多 / 多×1 / N×N)。
+     */
+    @Override
+    public StoreBookingChangeTableUnifiedResultVo changeTable(StoreBookingChangeTableDTO dto) {
+        Integer storeId = dto.getStoreId();
+        if (storeId == null) {
+            throw new IllegalArgumentException("门店ID不能为空");
+        }
+        List<Integer> sourceTableIds = dto.getSourceTableIds();
+        if (sourceTableIds == null || sourceTableIds.isEmpty()) {
+            // 仅查询空闲桌(原独立 GET,现合并到本接口)
+            StoreBookingChangeTableUnifiedResultVo out = new StoreBookingChangeTableUnifiedResultVo();
+            out.setIdleTables(storeBookingTableService.listIdleTablesForChange(storeId));
+            return out;
+        }
+        StoreBookingChangeTableUnifiedResultVo out = new StoreBookingChangeTableUnifiedResultVo();
+        out.setChangeResult(executeChangeTable(dto));
+        return out;
+    }
+
+    /**
+     * 执行换桌:按源/目标桌数量关系处理 1×1、1×多、多×1(合并)、N×N 配对,并同步预约时段与桌台占用。
+     * <p>
+     * 约定:多桌合并为一桌时,多笔待支付合并为一笔,保证「多人加餐、一人结账」仅一笔待支付;明细与实付金额累加至主单。
+     */
+    private StoreBookingChangeTableResultVo executeChangeTable(StoreBookingChangeTableDTO dto) {
+        Integer storeId = dto.getStoreId();
+        List<Integer> sourceTableIds = dto.getSourceTableIds();
+        List<Integer> targetTableIds = dto.getTargetTableIds();
+        if (storeId == null) {
+            throw new IllegalArgumentException("门店ID不能为空");
+        }
+        if (dto.getCarryReservationTime() == null) {
+            throw new IllegalArgumentException("执行换桌时请指定是否沿用原预约时间 carryReservationTime");
+        }
+        if (targetTableIds == null || targetTableIds.isEmpty()) {
+            throw new IllegalArgumentException("执行换桌时目标桌台不能为空");
+        }
+        if (new HashSet<>(sourceTableIds).size() != sourceTableIds.size()) {
+            throw new IllegalArgumentException("源桌列表存在重复");
+        }
+        if (new HashSet<>(targetTableIds).size() != targetTableIds.size()) {
+            throw new IllegalArgumentException("目标桌列表存在重复");
+        }
+        for (Integer a : sourceTableIds) {
+            if (targetTableIds.contains(a)) {
+                throw new IllegalArgumentException("源桌与目标桌不能包含相同桌ID");
+            }
+        }
+
+        List<Integer> src = sourceTableIds.stream().sorted().collect(Collectors.toList());
+        List<Integer> tgt = targetTableIds.stream().sorted().collect(Collectors.toList());
+        int n = src.size();
+        int m = tgt.size();
+
+        Integer userId = null;
+        try {
+            JSONObject jwt = JwtUtil.getCurrentUserInfo();
+            if (jwt != null) {
+                userId = jwt.getInteger("userId");
+            }
+        } catch (Exception e) {
+            log.debug("changeTable: 未解析到登录用户");
+        }
+        Date now = new Date();
+
+        // 源桌须属于本店且存在待支付订单;目标桌须空闲且可绑定订单
+        Map<Integer, StoreTable> sourceTableMap = loadAndAssertStoreTables(storeId, src, "源桌");
+        Map<Integer, StoreTable> targetTableMap = loadAndAssertStoreTables(storeId, tgt, "目标桌");
+        for (Integer tid : tgt) {
+            assertTargetTableIdle(targetTableMap.get(tid));
+        }
+
+        LinkedHashMap<Integer, StoreOrder> orderBySourceTable = new LinkedHashMap<>();
+        for (Integer sid : src) {
+            StoreOrder o = findSinglePendingOrderForTable(storeId, sid);
+            if (o == null) {
+                throw new IllegalArgumentException("源桌无待支付订单,tableId=" + sid);
+            }
+            orderBySourceTable.put(sid, o);
+        }
+
+        List<StoreOrder> orders = src.stream().map(orderBySourceTable::get).collect(Collectors.toList());
+        validateReservationConsistency(orders, dto);
+
+        StoreBookingChangeTableResultVo resultVo = new StoreBookingChangeTableResultVo();
+        resultVo.setAffectedOrderIds(orders.stream().map(StoreOrder::getId).collect(Collectors.toList()));
+        resultVo.setMergedIntoPrimaryOrderIds(new ArrayList<>());
+
+        if (n == m) {
+            // N×N(含 1×1):按源桌、目标桌升序一一配对,分别改订单桌号
+            for (int i = 0; i < n; i++) {
+                Integer sId = src.get(i);
+                Integer tId = tgt.get(i);
+                StoreOrder o = orderBySourceTable.get(sId);
+                StoreTable newT = targetTableMap.get(tId);
+                releaseTableIfCurrentOrder(storeTableMapper.selectById(sId), o.getId());
+                patchOrderTable(o, newT, userId, now);
+                assignTableToOrder(newT, o, o.getDinerCount());
+            }
+            Integer reservationId = unifiedReservationId(orders);
+            if (reservationId != null) {
+                replaceReservationTables(reservationId, tgt);
+            }
+            StoreOrder primary = orderBySourceTable.get(src.get(0));
+            resultVo.setPrimaryOrderId(primary.getId());
+            resultVo.setPrimaryOrderNo(primary.getOrderNo());
+            resultVo.setNewTableIds(tgt);
+            resultVo.setMessage("换桌成功(一一配对)");
+            return resultVo;
+        }
+
+        if (n == 1 && m > 1) {
+            // 1×多:一单多桌,订单主表落在第一目标桌,多桌共用 current_order_id 便于各端同步同一笔待支付
+            StoreOrder o = orderBySourceTable.get(src.get(0));
+            StoreTable firstTarget = targetTableMap.get(tgt.get(0));
+            releaseTableIfCurrentOrder(sourceTableMap.get(src.get(0)), o.getId());
+            patchOrderTable(o, firstTarget, userId, now);
+            for (Integer tId : tgt) {
+                assignTableToOrder(targetTableMap.get(tId), o, o.getDinerCount());
+            }
+            Integer reservationId = o.getUserReservationId();
+            if (reservationId != null) {
+                replaceReservationTables(reservationId, tgt);
+            }
+            resultVo.setPrimaryOrderId(o.getId());
+            resultVo.setPrimaryOrderNo(o.getOrderNo());
+            resultVo.setNewTableIds(tgt);
+            resultVo.setMessage("换桌成功(一桌多单台)");
+            return resultVo;
+        }
+
+        if (n > 1 && m == 1) {
+            // 多×1:合并订单至主单(ID 最小),其余订单取消,仅一笔待支付
+            orders.sort(Comparator.comparing(StoreOrder::getId));
+            StoreOrder primary = orders.get(0);
+            List<StoreOrder> secondaries = orders.subList(1, orders.size());
+            StoreTable newT = targetTableMap.get(tgt.get(0));
+
+            for (StoreOrder o : orders) {
+                releaseTableIfCurrentOrder(storeTableMapper.selectById(o.getTableId()), o.getId());
+            }
+            mergePendingOrdersIntoPrimary(primary, secondaries, userId, now);
+            patchOrderTable(primary, newT, userId, now);
+            assignTableToOrder(newT, primary, maxDinerCount(orders));
+
+            Integer reservationId = unifiedReservationId(orders);
+            if (reservationId != null) {
+                replaceReservationTables(reservationId, tgt);
+            }
+            resultVo.setPrimaryOrderId(primary.getId());
+            resultVo.setPrimaryOrderNo(primary.getOrderNo());
+            resultVo.setMergedIntoPrimaryOrderIds(secondaries.stream().map(StoreOrder::getId).collect(Collectors.toList()));
+            resultVo.setNewTableIds(tgt);
+            resultVo.setMessage("换桌成功(多桌合并,已合并为单笔待支付订单)");
+            return resultVo;
+        }
+
+        throw new IllegalArgumentException("不支持的换桌组合:源桌数=" + n + ",目标桌数=" + m + ",仅支持 N×N、1×多、多×1");
+    }
+
+    /** 多桌合并时取最大就餐人数,避免汇总后人数偏小 */
+    private static int maxDinerCount(List<StoreOrder> orders) {
+        int max = 1;
+        for (StoreOrder o : orders) {
+            if (o.getDinerCount() != null) {
+                max = Math.max(max, o.getDinerCount());
+            }
+        }
+        return max;
+    }
+
+    /**
+     * 将多笔待支付合并为一笔:明细归主单、金额累加、从单取消(状态=2),保证结账只处理主单。
+     */
+    private void mergePendingOrdersIntoPrimary(StoreOrder primary, List<StoreOrder> secondaries, Integer userId, Date now) {
+        BigDecimal total = primary.getTotalAmount() != null ? primary.getTotalAmount() : BigDecimal.ZERO;
+        BigDecimal pay = primary.getPayAmount() != null ? primary.getPayAmount() : BigDecimal.ZERO;
+        for (StoreOrder sec : secondaries) {
+            total = total.add(sec.getTotalAmount() != null ? sec.getTotalAmount() : BigDecimal.ZERO);
+            pay = pay.add(sec.getPayAmount() != null ? sec.getPayAmount() : BigDecimal.ZERO);
+
+            LambdaQueryWrapper<StoreOrderDetail> dq = new LambdaQueryWrapper<>();
+            dq.eq(StoreOrderDetail::getOrderId, sec.getId());
+            List<StoreOrderDetail> lines = storeOrderDetailMapper.selectList(dq);
+            for (StoreOrderDetail d : lines) {
+                d.setOrderId(primary.getId());
+                d.setOrderNo(primary.getOrderNo());
+                d.setUpdatedUserId(userId);
+                d.setUpdatedTime(now);
+                storeOrderDetailMapper.updateById(d);
+            }
+            String remark = sec.getRemark() != null ? sec.getRemark() : "";
+            sec.setOrderStatus(2);
+            sec.setRemark((remark + " [换桌合并至" + primary.getOrderNo() + "]").trim());
+            sec.setUpdatedUserId(userId);
+            sec.setUpdatedTime(now);
+            this.updateById(sec);
+        }
+        primary.setTotalAmount(total.setScale(2, RoundingMode.HALF_UP));
+        primary.setPayAmount(pay.setScale(2, RoundingMode.HALF_UP));
+        if (primary.getUserReservationId() == null) {
+            for (StoreOrder sec : secondaries) {
+                if (sec.getUserReservationId() != null) {
+                    primary.setUserReservationId(sec.getUserReservationId());
+                    break;
+                }
+            }
+        }
+        primary.setUpdatedUserId(userId);
+        primary.setUpdatedTime(now);
+        this.updateById(primary);
+    }
+
+    private void patchOrderTable(StoreOrder o, StoreTable newTable, Integer userId, Date now) {
+        o.setTableId(newTable.getId());
+        o.setTableNumber(newTable.getTableNumber());
+        o.setUpdatedUserId(userId);
+        o.setUpdatedTime(now);
+        this.updateById(o);
+    }
+
+    private void assignTableToOrder(StoreTable table, StoreOrder order, Integer dinerCount) {
+        StoreTable patch = new StoreTable();
+        patch.setId(table.getId());
+        patch.setCurrentOrderId(order.getId());
+        patch.setStatus(1);
+        if (dinerCount != null) {
+            patch.setDinerCount(dinerCount);
+        }
+        storeTableMapper.updateById(patch);
+    }
+
+    /**
+     * 释放源桌:仅当 current_order_id 仍指向本订单时清空,避免误清其它会话。
+     */
+    private void releaseTableIfCurrentOrder(StoreTable table, Integer orderId) {
+        if (table == null || orderId == null) {
+            return;
+        }
+        if (table.getCurrentOrderId() != null && table.getCurrentOrderId().equals(orderId)) {
+            StoreTable patch = new StoreTable();
+            patch.setId(table.getId());
+            patch.setCurrentOrderId(null);
+            patch.setStatus(0);
+            patch.setDinerCount(null);
+            storeTableMapper.updateById(patch);
+        }
+    }
+
+    private Map<Integer, StoreTable> loadAndAssertStoreTables(Integer storeId, List<Integer> ids, String label) {
+        Map<Integer, StoreTable> map = new LinkedHashMap<>();
+        for (Integer id : ids) {
+            StoreTable t = storeTableMapper.selectById(id);
+            if (t == null || t.getStoreId() == null || !t.getStoreId().equals(storeId)) {
+                throw new IllegalArgumentException(label + "不存在或不属于该门店,tableId=" + id);
+            }
+            map.put(id, t);
+        }
+        return map;
+    }
+
+    private static void assertTargetTableIdle(StoreTable t) {
+        if (t.getStatus() != null && t.getStatus() != 0) {
+            throw new IllegalArgumentException("目标桌非空闲,无法换入,tableId=" + t.getId());
+        }
+        if (t.getCurrentOrderId() != null) {
+            throw new IllegalArgumentException("目标桌已绑定订单,无法换入,tableId=" + t.getId());
+        }
+    }
+
+    private StoreOrder findSinglePendingOrderForTable(Integer storeId, Integer tableId) {
+        LambdaQueryWrapper<StoreOrder> q = new LambdaQueryWrapper<>();
+        q.eq(StoreOrder::getStoreId, storeId)
+                .eq(StoreOrder::getTableId, tableId)
+                .eq(StoreOrder::getDeleteFlag, 0)
+                .eq(StoreOrder::getOrderStatus, 0)
+                .eq(StoreOrder::getPayStatus, 0)
+                .orderByDesc(StoreOrder::getId);
+        List<StoreOrder> list = this.list(q);
+        if (list.size() > 1) {
+            throw new IllegalStateException("该桌存在多笔待支付订单,数据异常,tableId=" + tableId);
+        }
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    /**
+     * 校验预约一致性:要么均无预约,要么同一 user_reservation_id;并按 carryReservationTime 决定是否改约。
+     */
+    private void validateReservationConsistency(List<StoreOrder> orders, StoreBookingChangeTableDTO dto) {
+        boolean hasNull = orders.stream().anyMatch(o -> o.getUserReservationId() == null);
+        boolean hasNonNull = orders.stream().anyMatch(o -> o.getUserReservationId() != null);
+        if (hasNull && hasNonNull) {
+            throw new IllegalArgumentException("所选订单部分含预约、部分为散客,无法一并换桌");
+        }
+        Set<Integer> resIds = orders.stream()
+                .map(StoreOrder::getUserReservationId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (resIds.size() > 1) {
+            throw new IllegalArgumentException("所选订单关联多笔不同预约,请分次换桌或联系管理员");
+        }
+        if (resIds.isEmpty()) {
+            return;
+        }
+        Integer reservationId = resIds.iterator().next();
+        UserReservation ur = userReservationMapper.selectById(reservationId);
+        if (ur == null || (ur.getDeleteFlag() != null && ur.getDeleteFlag() != 0)) {
+            throw new IllegalArgumentException("预约不存在或已删除");
+        }
+
+        if (Boolean.TRUE.equals(dto.getCarryReservationTime())) {
+            // 多桌合并:若各订单曾对应不同预约已排除;此处仅校验「沿用」时不再写新字段
+            return;
+        }
+        if (dto.getNewReservationDate() == null
+                || !StringUtils.hasText(dto.getNewStartTime())
+                || !StringUtils.hasText(dto.getNewEndTime())) {
+            throw new IllegalArgumentException("不沿用原预约时间时,请填写新预约日期与开始/结束时间");
+        }
+        UserReservation patch = new UserReservation();
+        patch.setId(reservationId);
+        patch.setReservationDate(dto.getNewReservationDate());
+        patch.setStartTime(dto.getNewStartTime().trim());
+        patch.setEndTime(dto.getNewEndTime().trim());
+        userReservationMapper.updateById(patch);
+    }
+
+    private static Integer unifiedReservationId(List<StoreOrder> orders) {
+        for (StoreOrder o : orders) {
+            if (o.getUserReservationId() != null) {
+                return o.getUserReservationId();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 与 {@link shop.alien.store.service.impl.UserReservationServiceImpl#saveReservationTables} 一致:先物理删再插,保证预约与桌台一致。
+     */
+    private void replaceReservationTables(Integer reservationId, List<Integer> newTableIds) {
+        userReservationTableMapper.physicalDeleteByReservationId(reservationId);
+        if (newTableIds == null || newTableIds.isEmpty()) {
+            return;
+        }
+        int sort = 0;
+        for (Integer tableId : newTableIds) {
+            UserReservationTable row = new UserReservationTable();
+            row.setReservationId(reservationId);
+            row.setTableId(tableId);
+            row.setSort(sort++);
+            userReservationTableMapper.insert(row);
+        }
+    }
+
+    /**
+     * 解析本桌待支付订单,用于首单/多次加餐。
+     * 优先级:显式 targetOrderId → 桌台 current_order_id → 按桌+店查询待支付(唯一一笔)。
+     */
+    private StoreOrder resolvePendingOrder(StoreBookingPlaceOrderDTO dto, StoreTable table) {
+        if (dto.getTargetOrderId() != null) {
+            StoreOrder o = this.getById(dto.getTargetOrderId());
+            if (!isPendingOrderForTable(o, dto.getStoreId(), dto.getTableId())) {
+                throw new IllegalArgumentException("目标订单不存在、非待支付或与门店/桌号不符,无法加餐");
+            }
+            return o;
+        }
+        Integer curId = table.getCurrentOrderId();
+        if (curId != null) {
+            StoreOrder byPointer = this.getById(curId);
+            if (isPendingOrderForTable(byPointer, dto.getStoreId(), dto.getTableId())) {
+                return byPointer;
+            }
+        }
+        LambdaQueryWrapper<StoreOrder> q = new LambdaQueryWrapper<>();
+        q.eq(StoreOrder::getStoreId, dto.getStoreId())
+                .eq(StoreOrder::getTableId, dto.getTableId())
+                .eq(StoreOrder::getDeleteFlag, 0)
+                .eq(StoreOrder::getOrderStatus, 0)
+                .eq(StoreOrder::getPayStatus, 0)
+                .orderByDesc(StoreOrder::getId);
+        List<StoreOrder> list = this.list(q);
+        if (list.size() > 1) {
+            throw new IllegalStateException("该桌存在多笔待支付订单,数据异常,请联系管理员");
+        }
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    private static boolean isPendingOrderForTable(StoreOrder o, Integer storeId, Integer tableId) {
+        if (o == null) {
+            return false;
+        }
+        if (o.getDeleteFlag() != null && o.getDeleteFlag() != 0) {
+            return false;
+        }
+        if (o.getOrderStatus() == null || o.getOrderStatus() != 0) {
+            return false;
+        }
+        if (o.getPayStatus() == null || o.getPayStatus() != 0) {
+            return false;
+        }
+        return storeId.equals(o.getStoreId()) && tableId.equals(o.getTableId());
+    }
+
+    /**
+     * 加餐:在已有待支付订单上追加明细,累加金额;桌台置为加餐(3),与堂食约定一致。
+     */
+    private StoreBookingPlaceOrderResultVo addDishesToExistingOrder(
+            StoreBookingPlaceOrderDTO dto,
+            StoreTable table,
+            StoreOrder existing,
+            BigDecimal deltaAmount,
+            Integer userId,
+            String userPhone,
+            Date now) {
+        if (!dto.getStoreId().equals(existing.getStoreId()) || !dto.getTableId().equals(existing.getTableId())) {
+            throw new IllegalArgumentException("待支付订单与当前门店/桌号不一致,无法加餐");
+        }
+        BigDecimal oldTotal = existing.getTotalAmount() != null ? existing.getTotalAmount() : BigDecimal.ZERO;
+        BigDecimal oldPay = existing.getPayAmount() != null ? existing.getPayAmount() : BigDecimal.ZERO;
+        BigDecimal newTotal = oldTotal.add(deltaAmount).setScale(2, RoundingMode.HALF_UP);
+        BigDecimal newPay = oldPay.add(deltaAmount).setScale(2, RoundingMode.HALF_UP);
+
+        existing.setTotalAmount(newTotal);
+        existing.setPayAmount(newPay);
+        existing.setDinerCount(dto.getDinerCount());
+        if (StringUtils.hasText(dto.getContactPhone())) {
+            existing.setContactPhone(dto.getContactPhone().trim());
+        }
+        if (StringUtils.hasText(dto.getDiningContactName())) {
+            existing.setDiningContactName(dto.getDiningContactName().trim());
+        }
+        if (dto.getDiningGender() != null) {
+            existing.setDiningGender(dto.getDiningGender());
+        }
+        if (StringUtils.hasText(dto.getRemark())) {
+            existing.setRemark(dto.getRemark().trim());
+        }
+        if (existing.getUserReservationId() == null && dto.getUserReservationId() != null) {
+            existing.setUserReservationId(dto.getUserReservationId());
+        }
+        existing.setUpdatedUserId(userId);
+        existing.setUpdatedTime(now);
+        this.updateById(existing);
+
+        insertDetailRows(existing.getId(), existing.getOrderNo(), dto.getItems(), userId, userPhone, now, true);
+
+        StoreTable tablePatch = new StoreTable();
+        tablePatch.setId(table.getId());
+        tablePatch.setCurrentOrderId(existing.getId());
+        tablePatch.setStatus(3);
+        tablePatch.setDinerCount(dto.getDinerCount());
+        storeTableMapper.updateById(tablePatch);
+
+        StoreBookingPlaceOrderResultVo vo = new StoreBookingPlaceOrderResultVo();
+        vo.setOrderId(existing.getId());
+        vo.setOrderNo(existing.getOrderNo());
+        vo.setTotalAmount(newTotal);
+        vo.setAddDish(true);
+        log.info("预订加餐成功 orderId={} orderNo={} delta={} tableId={}", existing.getId(), existing.getOrderNo(), deltaAmount, dto.getTableId());
+        return vo;
+    }
+
+    /** 校验菜品归属并汇总本请求明细金额 */
+    private BigDecimal validateAndSumLines(StoreBookingPlaceOrderDTO dto) {
+        BigDecimal sum = BigDecimal.ZERO;
+        for (StoreBookingPlaceOrderItemDTO line : dto.getItems()) {
+            StoreCuisine cuisine = storeCuisineMapper.selectById(line.getCuisineId());
+            if (cuisine == null || cuisine.getStoreId() == null || !cuisine.getStoreId().equals(dto.getStoreId())) {
+                throw new IllegalArgumentException("菜品不存在或不属于该门店,cuisineId=" + line.getCuisineId());
+            }
+            BigDecimal unit = line.getUnitPrice() != null ? line.getUnitPrice() : BigDecimal.ZERO;
+            BigDecimal sub = unit.multiply(BigDecimal.valueOf(line.getQuantity())).setScale(2, RoundingMode.HALF_UP);
+            sum = sum.add(sub);
+        }
+        return sum;
+    }
+
+    private void insertDetailRows(
+            Integer orderId,
+            String orderNo,
+            List<StoreBookingPlaceOrderItemDTO> lines,
+            Integer userId,
+            String userPhone,
+            Date now,
+            boolean addDish) {
+        for (StoreBookingPlaceOrderItemDTO line : lines) {
+            BigDecimal unit = line.getUnitPrice() != null ? line.getUnitPrice() : BigDecimal.ZERO;
+            int qty = line.getQuantity() != null ? line.getQuantity() : 1;
+            BigDecimal sub = unit.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+            Integer cuisineType = line.getCuisineType() != null ? line.getCuisineType() : 1;
+
+            StoreOrderDetail detail = new StoreOrderDetail();
+            detail.setOrderId(orderId);
+            detail.setOrderNo(orderNo);
+            detail.setCuisineId(line.getCuisineId());
+            detail.setCuisineName(line.getCuisineName());
+            detail.setCuisineType(cuisineType);
+            detail.setCuisineImage(line.getCuisineImage());
+            detail.setUnitPrice(unit);
+            detail.setQuantity(qty);
+            detail.setSubtotalAmount(sub);
+            detail.setAddUserId(userId);
+            detail.setAddUserPhone(userPhone);
+            detail.setIsAddDish(addDish ? 1 : 0);
+            if (addDish) {
+                detail.setAddDishTime(now);
+            }
+            detail.setCreatedUserId(userId);
+            detail.setUpdatedUserId(userId);
+            detail.setCreatedTime(now);
+            detail.setUpdatedTime(now);
+            storeOrderDetailMapper.insert(detail);
+        }
+    }
+
+    /** 与 alien-dining 订单号规则保持一致 */
+    private static String generateOrderNo() {
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
+        String timestamp = sdf.format(new Date());
+        String random = String.valueOf((int) (Math.random() * 10000));
+        return "ORD" + timestamp + String.format("%04d", Integer.parseInt(random));
+    }
+}

+ 22 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java

@@ -739,6 +739,28 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
     }
 
     @Override
+    public List<StoreBookingTableVo> listIdleTablesForChange(Integer storeId) {
+        if (storeId == null) {
+            throw new IllegalArgumentException("门店ID不能为空");
+        }
+        // 换桌目标桌:须为空闲(0)、无进行中订单指针,避免与就餐中桌态冲突
+        List<StoreTable> idleRows = this.list(
+                new LambdaQueryWrapper<StoreTable>()
+                        .eq(StoreTable::getStoreId, storeId)
+                        .eq(StoreTable::getStatus, 0)
+                        .eq(StoreTable::getDeleteFlag, 0)
+                        .isNull(StoreTable::getCurrentOrderId));
+        Set<Integer> idleIds = idleRows.stream().map(StoreTable::getId).collect(Collectors.toSet());
+        if (idleIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        // 排序与列表接口一致,便于前端展示
+        return getTableListWithCategoryName(storeId, null, null).stream()
+                .filter(vo -> vo.getId() != null && idleIds.contains(vo.getId()))
+                .collect(Collectors.toList());
+    }
+
+    @Override
     public boolean hasReservationInTable(Integer tableId, Integer storeId) {
         log.info("StoreBookingTableServiceImpl.hasReservationInTable?tableId={}, storeId={}", tableId, storeId);