Bladeren bron

Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand

刘云鑫 2 weken geleden
bovenliggende
commit
9e3bae3046
33 gewijzigde bestanden met toevoegingen van 809 en 70 verwijderingen
  1. 25 1
      alien-dining/src/main/java/shop/alien/dining/controller/DiningController.java
  2. 1 1
      alien-dining/src/main/java/shop/alien/dining/controller/StoreInfoController.java
  3. 1 1
      alien-dining/src/main/java/shop/alien/dining/controller/StoreOrderController.java
  4. 13 0
      alien-dining/src/main/java/shop/alien/dining/feign/AlienStoreFeign.java
  5. 1 1
      alien-dining/src/main/java/shop/alien/dining/service/DiningWalkInReservationService.java
  6. 68 0
      alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java
  7. 6 1
      alien-dining/src/main/java/shop/alien/dining/service/impl/DiningServiceImpl.java
  8. 86 13
      alien-dining/src/main/java/shop/alien/dining/service/impl/DiningWalkInReservationServiceImpl.java
  9. 73 1
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreInfoServiceImpl.java
  10. 131 15
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java
  11. 80 0
      alien-dining/src/main/java/shop/alien/dining/support/DiningMenuPricing.java
  12. 155 0
      alien-dining/src/main/java/shop/alien/dining/support/ProductDiscountPricingSupport.java
  13. 5 1
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java
  14. 6 2
      alien-entity/src/main/java/shop/alien/entity/store/UserReservation.java
  15. 10 1
      alien-entity/src/main/java/shop/alien/entity/store/dto/CartItemDTO.java
  16. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/dto/CreateOrderDTO.java
  17. 2 2
      alien-entity/src/main/java/shop/alien/entity/store/dto/DiningWalkInReservationDTO.java
  18. 5 2
      alien-entity/src/main/java/shop/alien/entity/store/dto/UserReservationDTO.java
  19. 4 5
      alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryWithCuisinesVO.java
  20. 7 1
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderCuisineItemVO.java
  21. 27 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/StoreCuisineWithPricesVO.java
  22. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/StoreReservationListVo.java
  23. 5 2
      alien-entity/src/main/java/shop/alien/entity/store/vo/UserReservationVo.java
  24. 3 0
      alien-entity/src/main/resources/db/migration/store_order_detail_list_unit_price.sql
  25. 3 0
      alien-entity/src/main/resources/db/migration/store_order_user_reservation_id.sql
  26. 3 0
      alien-entity/src/main/resources/db/migration/store_table_app_qrcode_url.sql
  27. 5 0
      alien-entity/src/main/resources/mapper/UserReservationMapper.xml
  28. 2 1
      alien-store/src/main/java/shop/alien/store/controller/StoreServiceFeeRuleController.java
  29. 1 1
      alien-store/src/main/java/shop/alien/store/controller/UserReservationController.java
  30. 16 0
      alien-store/src/main/java/shop/alien/store/controller/dining/StoreDiningPathProxyController.java
  31. 11 1
      alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java
  32. 35 14
      alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java
  33. 15 2
      alien-store/src/main/java/shop/alien/store/service/impl/UserReservationServiceImpl.java

+ 25 - 1
alien-dining/src/main/java/shop/alien/dining/controller/DiningController.java

@@ -9,6 +9,7 @@ import shop.alien.entity.result.R;
 import shop.alien.entity.store.dto.DiningWalkInReservationDTO;
 import shop.alien.entity.store.vo.TableDiningStatusVO;
 import shop.alien.entity.store.vo.*;
+import shop.alien.dining.feign.AlienStoreFeign;
 import shop.alien.dining.service.DiningService;
 import shop.alien.dining.service.DiningWalkInReservationService;
 import shop.alien.dining.util.TokenUtil;
@@ -32,8 +33,9 @@ public class DiningController {
 
     private final DiningService diningService;
     private final DiningWalkInReservationService diningWalkInReservationService;
+    private final AlienStoreFeign alienStoreFeign;
 
-    @ApiOperation(value = "提交到店就餐信息", notes = "选完人数并填写姓名/电话/时段后调用:写入 user_reservation,状态为已到店(2),并关联当前桌;返回预约ID,下单时 CreateOrderDTO.userReservationId 传入该值")
+    @ApiOperation(value = "提交到店就餐信息", notes = "选完人数并填写姓名/电话/时段后调用:写入 user_reservation,状态为已到店(2),并关联当前桌;同时将 store_table 置为就餐中(1)、写入 diner_count(与 page-info 首客传人数一致),便于 /table-dining-status。返回预约ID。下单时可不传 userReservationId,后端将按 tableId 解析本桌当日有效预约。")
     @PostMapping("/walk-in/reservation")
     public R<Integer> createWalkInReservation(@Valid @RequestBody DiningWalkInReservationDTO dto) {
         log.info("DiningController.createWalkInReservation?tableId={}", dto != null ? dto.getTableId() : null);
@@ -50,6 +52,28 @@ public class DiningController {
         }
     }
 
+    @ApiOperation(value = "【小程序】按门店与餐桌查询预约详情列表", notes = "内部 Feign 调用门店 GET /user/reservation/detailByStoreAndTableRecord。userReservationTableId 为 store_table.id。reservationDate 为 yyyy-MM-dd 可选。免登录,与门店接口一致。")
+    @GetMapping("/reservation/detail-by-store-table-record")
+    public R<List<UserReservationVo>> reservationDetailByStoreAndTableRecord(
+            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
+            @ApiParam(value = "餐桌ID(store_table.id)", required = true) @RequestParam Integer userReservationTableId,
+            @ApiParam(value = "预订日期 yyyy-MM-dd,不传则不过滤") @RequestParam(required = false) String reservationDate) {
+        log.info("DiningController.reservationDetailByStoreAndTableRecord?storeId={}, userReservationTableId={}, reservationDate={}",
+                storeId, userReservationTableId, reservationDate);
+        try {
+            if (storeId == null) {
+                return R.fail("门店ID不能为空");
+            }
+            if (userReservationTableId == null) {
+                return R.fail("餐桌ID不能为空");
+            }
+            return alienStoreFeign.detailByStoreAndTableRecord(storeId, userReservationTableId, reservationDate);
+        } catch (Exception e) {
+            log.error("查询预约详情列表失败: {}", e.getMessage(), e);
+            return R.fail("查询预约详情列表失败: " + e.getMessage());
+        }
+    }
+
     @ApiOperation(value = "查询餐桌是否处于就餐中", notes = "免登录可调用,用于前端判断是否跳过选择用餐人数。返回 tableNumber 为桌号名称。就餐中(status=1)、加餐(status=3) 均视为就餐状态,且 diner_count 有值 时 inDining=true")
     @GetMapping("/table-dining-status")
     public R<TableDiningStatusVO> getTableDiningStatus(

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/controller/StoreInfoController.java

@@ -64,7 +64,7 @@ public class StoreInfoController {
         }
     }
 
-    @ApiOperation(value = "根据门店ID查询菜品种类及各类别下菜品", notes = "一次返回所有菜品种类及每个分类下的菜品列表;可选 keyword 按菜品名称模糊查询。返回中 category 与 /store/info/categories 单条结构一致,cuisines 与 /store/info/cuisines 返回结构一致,保证字段完整一致。")
+    @ApiOperation(value = "根据门店ID查询菜品种类及各类别下菜品", notes = "一次返回所有菜品种类及每个分类下的菜品列表;可选 keyword 按菜品名称模糊查询。category 与 /store/info/categories 单条一致;cuisines 每项为 StoreCuisine 全字段,并含 originalPrice、currentPrice、hasActiveDiscount(按 store_product_discount_rule 当前时刻命中,与门店优惠配置一致)。")
     @GetMapping("/categories-with-cuisines")
     public R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisinesByStoreId(
             @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/controller/StoreOrderController.java

@@ -229,7 +229,7 @@ public class StoreOrderController {
         }
     }
 
-    @ApiOperation(value = "创建订单(下单)", notes = "从购物车创建订单,不立即支付。备注创建/更新传入,更新订单(加餐)覆盖")
+    @ApiOperation(value = "创建订单(下单)", notes = "从购物车创建订单,不立即支付。绑定的预约须为「已到店(2)」且当前桌在其占用桌位中;userReservationId 可选,不传则按 tableId 解析本桌当日已到店预约(不必与预约人为同一登录账号,便于同伴下单)。备注创建/更新传入,加餐更新时覆盖")
     @PostMapping("/create")
     public R<shop.alien.entity.store.vo.OrderSuccessVO> createOrder(@Valid @RequestBody CreateOrderDTO dto) {
         log.info("StoreOrderController.createOrder?dto={}", dto);

+ 13 - 0
alien-dining/src/main/java/shop/alien/dining/feign/AlienStoreFeign.java

@@ -13,6 +13,7 @@ import org.springframework.web.multipart.MultipartFile;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.LifeCollect;
 import shop.alien.entity.store.vo.LifeDiscountCouponVo;
+import shop.alien.entity.store.vo.UserReservationVo;
 
 import java.math.BigDecimal;
 import java.util.List;
@@ -159,4 +160,16 @@ public interface AlienStoreFeign {
      */
     @PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
     R<String> uploadFile(@RequestPart("file") MultipartFile file);
+
+    // ==================== 用户预约(门店实现,小程序经点餐 Feign 转发) ====================
+
+    /**
+     * 与 {@code UserReservationController#detailByStoreAndTableRecord} 一致。
+     * {@code userReservationTableId} 对应 {@code store_table.id}(餐桌主键)。
+     */
+    @GetMapping("user/reservation/detailByStoreAndTableRecord")
+    R<List<UserReservationVo>> detailByStoreAndTableRecord(
+            @RequestParam("storeId") Integer storeId,
+            @RequestParam("userReservationTableId") Integer userReservationTableId,
+            @RequestParam(value = "reservationDate", required = false) String reservationDate);
 }

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/service/DiningWalkInReservationService.java

@@ -8,7 +8,7 @@ import shop.alien.entity.store.dto.DiningWalkInReservationDTO;
 public interface DiningWalkInReservationService {
 
     /**
-     * 创建预约并关联当前桌位,状态为已到店(2),返回 user_reservation.id 供下单绑定
+     * 创建预约并关联当前桌位,状态为已到店(2);同步将餐桌置为就餐中并写入就餐人数。返回 user_reservation.id 供下单绑定。
      */
     Integer createWalkInReservation(DiningWalkInReservationDTO dto, Integer userId);
 }

+ 68 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java

@@ -21,13 +21,19 @@ import shop.alien.mapper.StoreCartMapper;
 import shop.alien.mapper.StoreCouponUsageMapper;
 import shop.alien.mapper.StoreCuisineMapper;
 import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreProductDiscountRuleMapper;
 import shop.alien.mapper.StoreTableMapper;
+import shop.alien.dining.support.DiningMenuPricing;
 import shop.alien.dining.util.TokenUtil;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -61,6 +67,7 @@ public class CartServiceImpl implements CartService {
     private final StoreCartMapper storeCartMapper;
     private final StoreCouponUsageMapper storeCouponUsageMapper;
     private final StoreInfoMapper storeInfoMapper;
+    private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
 
     @Override
     public CartDTO getCart(Integer tableId) {
@@ -109,10 +116,71 @@ public class CartServiceImpl implements CartService {
             cart = loadCartFromDatabase(tableId);
         }
 
+        applyRealtimeMenuPricing(cart);
         return cart;
     }
 
     /**
+     * 按门店标价 + {@code store_product_discount_rule} 刷新行单价/小计/总价,并写回 Redis+DB(与下单明细口径一致)。
+     */
+    private void applyRealtimeMenuPricing(CartDTO cart) {
+        if (cart == null || cart.getStoreId() == null || cart.getItems() == null || cart.getItems().isEmpty()) {
+            return;
+        }
+        List<CartItemDTO> items = cart.getItems();
+        Set<Integer> cuisineIds = items.stream()
+                .map(CartItemDTO::getCuisineId)
+                .filter(Objects::nonNull)
+                .filter(id -> id > 0)
+                .collect(Collectors.toSet());
+        Map<Integer, BigDecimal> listByIdReal = cuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveListUnitPriceByCuisineId(cuisineIds, storeCuisineMapper);
+        Map<Integer, BigDecimal> saleById = cuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveSaleUnitPrice(cart.getStoreId(), listByIdReal, storeProductDiscountRuleMapper);
+
+        boolean changed = false;
+        for (CartItemDTO it : items) {
+            Integer cid = it.getCuisineId();
+            int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+            if (cid == null || cid <= 0) {
+                BigDecimal p = it.getUnitPrice() != null ? it.getUnitPrice() : BigDecimal.ZERO;
+                it.setOriginalUnitPrice(p);
+                it.setCurrentUnitPrice(p);
+                it.setHasActiveDiscount(Boolean.FALSE);
+                BigDecimal newSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                if (!Objects.equals(it.getUnitPrice(), p) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
+                    changed = true;
+                }
+                it.setUnitPrice(p);
+                it.setSubtotalAmount(newSub);
+                continue;
+            }
+            BigDecimal list = listByIdReal.getOrDefault(cid, BigDecimal.ZERO);
+            BigDecimal sale = saleById.getOrDefault(cid, list);
+            it.setOriginalUnitPrice(list);
+            it.setCurrentUnitPrice(sale);
+            it.setHasActiveDiscount(list.compareTo(sale) != 0);
+            BigDecimal newSub = sale.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
+                changed = true;
+            }
+            it.setUnitPrice(sale);
+            it.setSubtotalAmount(newSub);
+        }
+        BigDecimal total = items.stream()
+                .map(CartItemDTO::getSubtotalAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        cart.setTotalAmount(total);
+        cart.setTotalQuantity(items.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+        if (changed) {
+            saveCart(cart);
+        }
+    }
+
+    /**
      * 从数据库加载购物车
      */
     private CartDTO loadCartFromDatabase(Integer tableId) {

+ 6 - 1
alien-dining/src/main/java/shop/alien/dining/service/impl/DiningServiceImpl.java

@@ -570,7 +570,12 @@ public class DiningServiceImpl implements DiningService {
             item.setCuisineName(detail.getCuisineName());
             item.setCuisineType(detail.getCuisineType());
             item.setCuisineImage(detail.getCuisineImage());
-            item.setUnitPrice(detail.getUnitPrice());
+            java.math.BigDecimal cur = detail.getUnitPrice();
+            java.math.BigDecimal orig = detail.getListUnitPrice() != null ? detail.getListUnitPrice() : cur;
+            item.setUnitPrice(cur);
+            item.setOriginalUnitPrice(orig);
+            item.setCurrentUnitPrice(cur);
+            item.setHasActiveDiscount(orig != null && cur != null && orig.compareTo(cur) != 0);
             item.setQuantity(detail.getQuantity());
             item.setSubtotalAmount(detail.getSubtotalAmount());
             item.setAddUserId(detail.getAddUserId());

+ 86 - 13
alien-dining/src/main/java/shop/alien/dining/service/impl/DiningWalkInReservationServiceImpl.java

@@ -32,7 +32,7 @@ import java.util.concurrent.ThreadLocalRandom;
 import java.util.stream.Collectors;
 
 /**
- * 点餐流程:填写就餐信息 → 仅写 user_reservation + user_reservation_table,状态已到店,不产生 user_reservation_order
+ * 点餐流程:填写就餐信息 → 写 user_reservation + user_reservation_table(已到店),并更新 store_table 为就餐中;不产生 user_reservation_order
  */
 @Slf4j
 @Service
@@ -49,6 +49,8 @@ public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservat
     /** 未填结束时间时,按 N 小时作为占用窗口做冲突判断(与订金预订「结束时间」空值场景对齐) */
     private static final int DEFAULT_BLOCK_HOURS_WHEN_NO_END = 4;
     private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("HH:mm");
+    /** 与订桌/库内既有数据一致:varchar 存 yyyy-MM-dd HH:mm */
+    private static final DateTimeFormatter STORE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
 
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -67,21 +69,24 @@ public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservat
         }
 
         LocalDate day = reservationDate.toInstant().atZone(SHANGHAI).toLocalDate();
-        LocalTime newStartLt = parseHm(dto.getStartTime());
+        LocalTime newStartLt = parseTimeOfDayOnly(dto.getStartTime());
         if (newStartLt == null) {
             throw new RuntimeException("开始时间格式不正确,请使用 HH:mm");
         }
-        LocalTime newEndLt = parseHm(dto.getEndTime());
+        LocalTime newEndLt = parseTimeOfDayOnly(dto.getEndTime());
         assertNoTableBookingConflict(table.getId(), table.getStoreId(), day, newStartLt, newEndLt);
 
+        LocalDateTime startLdt = LocalDateTime.of(day, newStartLt);
+        LocalDateTime endLdt = windowEnd(day, newStartLt, newEndLt);
+
         Date now = new Date();
         UserReservation entity = new UserReservation();
         entity.setReservationNo(generateReservationNo());
         entity.setUserId(userId);
         entity.setStoreId(table.getStoreId());
         entity.setReservationDate(reservationDate);
-        entity.setStartTime(trimOrNull(dto.getStartTime()));
-        entity.setEndTime(trimOrNull(dto.getEndTime()));
+        entity.setStartTime(startLdt.format(STORE_DATE_TIME));
+        entity.setEndTime(endLdt.format(STORE_DATE_TIME));
         entity.setGuestCount(dto.getGuestCount());
         entity.setCategoryId(table.getCategoryId());
         entity.setStatus(STATUS_ARRIVED);
@@ -103,8 +108,19 @@ public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservat
         link.setUpdatedUserId(userId);
         userReservationTableMapper.insert(link);
 
-        log.info("点餐到店预约已创建 reservationId={} tableId={} userId={} status=已到店",
-                entity.getId(), table.getId(), userId);
+        // 与 getDiningPageInfo 首客传人数一致:提交就餐信息成功后,餐桌置为就餐中并写入人数,
+        // 便于 /table-dining-status 与其它用户跳过「选人数」前置。
+        Integer st = table.getStatus();
+        if (st == null || st == 0 || st == 2) {
+            table.setStatus(1);
+        }
+        table.setDinerCount(dto.getGuestCount());
+        table.setUpdatedUserId(userId);
+        table.setUpdatedTime(now);
+        storeTableMapper.updateById(table);
+
+        log.info("点餐到店预约已创建 reservationId={} tableId={} userId={} status=已到店,餐桌已更新就餐中 dinerCount={} tableStatus={}",
+                entity.getId(), table.getId(), userId, dto.getGuestCount(), table.getStatus());
         return entity.getId();
     }
 
@@ -157,13 +173,11 @@ public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservat
             if (otherDay == null || !day.equals(otherDay)) {
                 continue;
             }
-            LocalTime oStart = parseHm(other.getStartTime());
-            if (oStart == null) {
+            LocalDateTime oStartLdt = parseStoredStartLdt(otherDay, other.getStartTime());
+            if (oStartLdt == null) {
                 continue;
             }
-            LocalTime oEnd = parseHm(other.getEndTime());
-            LocalDateTime oStartLdt = LocalDateTime.of(otherDay, oStart);
-            LocalDateTime oEndLdt = windowEnd(otherDay, oStart, oEnd);
+            LocalDateTime oEndLdt = parseStoredEndLdt(oStartLdt, other.getEndTime());
             if (newStartLdt.isBefore(oEndLdt) && oStartLdt.isBefore(newEndLdt)) {
                 log.warn("到店就餐信息与既有预订冲突 tableId={} day={} new=[{} - {}] otherResId={} other=[{} - {}]",
                         tableId, day, newStartLdt, newEndLdt, other.getId(), oStartLdt, oEndLdt);
@@ -184,11 +198,15 @@ public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservat
         return d.toInstant().atZone(SHANGHAI).toLocalDate();
     }
 
-    private static LocalTime parseHm(String raw) {
+    /** 仅时分:HH:mm / H:mm */
+    private static LocalTime parseTimeOfDayOnly(String raw) {
         if (!StringUtils.hasText(raw)) {
             return null;
         }
         String s = raw.trim();
+        if (s.contains(" ")) {
+            s = s.substring(s.lastIndexOf(' ') + 1).trim();
+        }
         try {
             return LocalTime.parse(s, HM);
         } catch (DateTimeParseException e) {
@@ -200,6 +218,61 @@ public class DiningWalkInReservationServiceImpl extends ServiceImpl<UserReservat
         }
     }
 
+    private static LocalDateTime tryParseFullDateTime(String raw) {
+        if (!StringUtils.hasText(raw) || !raw.trim().contains(" ")) {
+            return null;
+        }
+        String s = raw.trim();
+        DateTimeFormatter[] fmps = new DateTimeFormatter[]{
+                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
+                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
+                DateTimeFormatter.ofPattern("yyyy-MM-dd H:mm:ss"),
+                DateTimeFormatter.ofPattern("yyyy-MM-dd H:mm")
+        };
+        for (DateTimeFormatter f : fmps) {
+            try {
+                return LocalDateTime.parse(s, f);
+            } catch (DateTimeParseException ignored) {
+            }
+        }
+        return null;
+    }
+
+    private static LocalDateTime parseStoredStartLdt(LocalDate reservationDay, String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return null;
+        }
+        String s = raw.trim();
+        LocalDateTime full = tryParseFullDateTime(s);
+        if (full != null) {
+            return full;
+        }
+        LocalTime t = parseTimeOfDayOnly(s);
+        if (t == null) {
+            return null;
+        }
+        return LocalDateTime.of(reservationDay, t);
+    }
+
+    private static LocalDateTime parseStoredEndLdt(LocalDateTime startLdt, String endRaw) {
+        if (startLdt == null) {
+            return null;
+        }
+        if (!StringUtils.hasText(endRaw)) {
+            return windowEnd(startLdt.toLocalDate(), startLdt.toLocalTime(), null);
+        }
+        String s = endRaw.trim();
+        LocalDateTime full = tryParseFullDateTime(s);
+        if (full != null) {
+            return full;
+        }
+        LocalTime endT = parseTimeOfDayOnly(s);
+        if (endT == null) {
+            return windowEnd(startLdt.toLocalDate(), startLdt.toLocalTime(), null);
+        }
+        return windowEnd(startLdt.toLocalDate(), startLdt.toLocalTime(), endT);
+    }
+
     /**
      * 预约结束时刻:结束早于或等于开始(同一张日历日上的钟点)则视为跨日至次日;结束时间为空则用默认时长。
      */

+ 73 - 1
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreInfoServiceImpl.java

@@ -4,22 +4,34 @@ import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
 import shop.alien.entity.store.StoreCuisine;
 import shop.alien.entity.store.StoreCuisineCategory;
 import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreProductDiscountRule;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.StoreInfoWithHomepageCuisinesDTO;
 import shop.alien.entity.store.vo.CategoryWithCuisinesVO;
+import shop.alien.entity.store.vo.StoreCuisineWithPricesVO;
 import shop.alien.mapper.StoreCuisineCategoryMapper;
 import shop.alien.mapper.StoreCuisineMapper;
 import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreProductDiscountRuleMapper;
 import shop.alien.mapper.StoreTableMapper;
 import shop.alien.dining.service.StoreInfoService;
+import shop.alien.dining.support.ProductDiscountPricingSupport;
 import org.apache.commons.lang3.StringUtils;
 
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -37,6 +49,7 @@ public class StoreInfoServiceImpl implements StoreInfoService {
     private final StoreCuisineCategoryMapper storeCuisineCategoryMapper;
     private final StoreCuisineMapper storeCuisineMapper;
     private final StoreInfoMapper storeInfoMapper;
+    private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
 
     @Override
     public List<StoreTable> getTablesByStoreId(Integer storeId) {
@@ -124,18 +137,77 @@ public class StoreInfoServiceImpl implements StoreInfoService {
         }
         boolean filterByName = StringUtils.isNotBlank(keyword);
         String keywordLower = filterByName ? keyword.trim().toLowerCase() : null;
+
+        List<StoreCuisine> flatForPricing = new ArrayList<>();
+        for (StoreCuisineCategory category : categories) {
+            Integer categoryId = category.getId();
+            allCuisines.stream()
+                    .filter(c -> belongsToCategory(c, categoryId))
+                    .filter(c -> !filterByName || (c.getName() != null && c.getName().toLowerCase().contains(keywordLower)))
+                    .forEach(flatForPricing::add);
+        }
+        Map<Integer, BigDecimal> discountedByProductId = buildDiscountedPriceMap(storeId, flatForPricing);
+
         List<CategoryWithCuisinesVO> result = new ArrayList<>();
         for (StoreCuisineCategory category : categories) {
             Integer categoryId = category.getId();
-            List<StoreCuisine> cuisines = allCuisines.stream()
+            List<StoreCuisineWithPricesVO> cuisines = allCuisines.stream()
                     .filter(c -> belongsToCategory(c, categoryId))
                     .filter(c -> !filterByName || (c.getName() != null && c.getName().toLowerCase().contains(keywordLower)))
+                    .map(c -> toCuisineWithPricesVo(c, discountedByProductId))
                     .collect(Collectors.toList());
             result.add(new CategoryWithCuisinesVO(category, cuisines));
         }
         return result;
     }
 
+    /**
+     * 与门店 {@code StoreProductDiscountService} 同一套规则,按当前时刻算折后价(仅包含有命中的菜品 id)
+     */
+    private Map<Integer, BigDecimal> buildDiscountedPriceMap(Integer storeId, List<StoreCuisine> visibleCuisines) {
+        if (visibleCuisines == null || visibleCuisines.isEmpty()) {
+            return new HashMap<>();
+        }
+        Set<Integer> idSet = new HashSet<>();
+        Map<Integer, BigDecimal> basePriceByProduct = new HashMap<>();
+        for (StoreCuisine c : visibleCuisines) {
+            if (c.getId() == null) {
+                continue;
+            }
+            if (idSet.add(c.getId())) {
+                BigDecimal p = c.getTotalPrice() != null ? c.getTotalPrice() : BigDecimal.ZERO;
+                basePriceByProduct.put(c.getId(), p);
+            }
+        }
+        List<Integer> productIds = new ArrayList<>(idSet);
+        if (productIds.isEmpty()) {
+            return new HashMap<>();
+        }
+        List<StoreProductDiscountRule> rules = storeProductDiscountRuleMapper.selectList(
+                new LambdaQueryWrapper<StoreProductDiscountRule>()
+                        .eq(StoreProductDiscountRule::getStoreId, storeId)
+                        .eq(StoreProductDiscountRule::getStatus, 1)
+                        .in(StoreProductDiscountRule::getProductId, productIds));
+        return ProductDiscountPricingSupport.resolveDiscountedPrices(
+                productIds,
+                basePriceByProduct,
+                LocalDateTime.now(),
+                rules);
+    }
+
+    private static StoreCuisineWithPricesVO toCuisineWithPricesVo(StoreCuisine cuisine, Map<Integer, BigDecimal> discountedByProductId) {
+        Map<Integer, BigDecimal> disc = discountedByProductId == null ? Collections.emptyMap() : discountedByProductId;
+        StoreCuisineWithPricesVO vo = new StoreCuisineWithPricesVO();
+        BeanUtils.copyProperties(cuisine, vo);
+        BigDecimal original = cuisine.getTotalPrice() != null ? cuisine.getTotalPrice() : BigDecimal.ZERO;
+        vo.setOriginalPrice(original);
+        boolean hasRule = cuisine.getId() != null && disc.containsKey(cuisine.getId());
+        BigDecimal current = hasRule ? disc.get(cuisine.getId()) : original;
+        vo.setCurrentPrice(current);
+        vo.setHasActiveDiscount(hasRule);
+        return vo;
+    }
+
     private boolean belongsToCategory(StoreCuisine cuisine, Integer categoryId) {
         String categoryIdsStr = cuisine.getCategoryIds();
         if (StringUtils.isBlank(categoryIdsStr)) {

+ 131 - 15
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java

@@ -13,6 +13,7 @@ import org.springframework.util.StringUtils;
 import shop.alien.dining.config.BaseRedisService;
 import shop.alien.dining.service.CartService;
 import shop.alien.dining.service.StoreOrderService;
+import shop.alien.dining.support.DiningMenuPricing;
 import shop.alien.dining.util.TokenUtil;
 import shop.alien.entity.store.*;
 import shop.alien.entity.store.dto.CartDTO;
@@ -29,7 +30,10 @@ import shop.alien.entity.store.vo.OrderDetailWithChangeLogVO;
 import shop.alien.mapper.*;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -45,6 +49,9 @@ import java.util.stream.Collectors;
 @Transactional(rollbackFor = Exception.class)
 public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOrder> implements StoreOrderService {
 
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
+    /** user_reservation.status:已到店,仅该状态允许下单绑预约 */
+    private static final int RESERVATION_STATUS_ARRIVED = 2;
 
     private final StoreOrderDetailMapper orderDetailMapper;
     private final StoreTableMapper storeTableMapper;
@@ -62,6 +69,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     private final shop.alien.dining.service.OrderLockService orderLockService;
     private final UserReservationMapper userReservationMapper;
     private final UserReservationTableMapper userReservationTableMapper;
+    private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
 
     @Override
     public StoreOrder createOrder(CreateOrderDTO dto) {
@@ -212,10 +220,18 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 order.setDiscountAmount(discountAmount);
                 order.setPayAmount(payAmount);
                 order.setRemark(dto.getRemark());
-                if (dto.getUserReservationId() != null) {
-                    validateUserReservationForOrder(dto.getUserReservationId(), table);
-                    order.setUserReservationId(dto.getUserReservationId());
+                Integer bindReservationId = dto.getUserReservationId();
+                if (bindReservationId == null) {
+                    bindReservationId = order.getUserReservationId();
                 }
+                if (bindReservationId == null) {
+                    bindReservationId = resolveUserReservationIdForTable(table);
+                }
+                if (bindReservationId == null) {
+                    throw new RuntimeException("未找到与本桌关联的有效已到店预约,无法更新订单");
+                }
+                validateUserReservationForOrder(bindReservationId, table);
+                order.setUserReservationId(bindReservationId);
                 order.setUpdatedUserId(userId);
                 order.setUpdatedTime(now);
                 this.updateById(order);
@@ -239,10 +255,14 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         
         // 如果没有订单需要更新,创建新订单
         if (!isUpdate) {
-            if (dto.getUserReservationId() == null) {
-                throw new RuntimeException("请先完成就餐信息登记后再下单(需传入预约ID)");
+            Integer userReservationId = dto.getUserReservationId();
+            if (userReservationId == null) {
+                userReservationId = resolveUserReservationIdForTable(table);
+            }
+            if (userReservationId == null) {
+                throw new RuntimeException("请先完成就餐信息登记后再下单(未找到与本桌关联的有效预约)");
             }
-            validateUserReservationForOrder(dto.getUserReservationId(), table);
+            validateUserReservationForOrder(userReservationId, table);
             // 生成订单号
             orderNo = generateOrderNo();
             
@@ -269,7 +289,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             // payType在支付时设置
             order.setPayStatus(0); // 未支付
             order.setRemark(dto.getRemark());
-            order.setUserReservationId(dto.getUserReservationId());
+            order.setUserReservationId(userReservationId);
             order.setCreatedUserId(userId);
             order.setUpdatedUserId(userId);
             // 手动设置创建时间和更新时间(临时方案,避免自动填充未生效导致 created_time 为 null)
@@ -324,11 +344,23 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             }
         }
 
-        // 创建订单明细(单价、小计来自购物车
+        // 创建订单明细:标价/成交单价按当前时刻 store_cuisine + 菜品优惠规则计算(与购物车刷新口径一致
         // 如果是更新订单,需要判断哪些商品是新增的或数量增加的,标记为加餐
         // 餐具的特殊ID(用于标识餐具项)
         final Integer TABLEWARE_CUISINE_ID = -1;
-        
+
+        java.util.Set<Integer> cartCuisineIds = cart.getItems().stream()
+                .map(CartItemDTO::getCuisineId)
+                .filter(Objects::nonNull)
+                .filter(id -> id > 0)
+                .collect(Collectors.toSet());
+        java.util.Map<Integer, java.math.BigDecimal> listUnitByCuisine = cartCuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveListUnitPriceByCuisineId(cartCuisineIds, storeCuisineMapper);
+        java.util.Map<Integer, java.math.BigDecimal> saleUnitByCuisine = cartCuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveSaleUnitPrice(table.getStoreId(), listUnitByCuisine, storeProductDiscountRuleMapper);
+
         List<StoreOrderDetail> orderDetails = cart.getItems().stream()
                 .map(item -> {
                     StoreOrderDetail detail = new StoreOrderDetail();
@@ -361,9 +393,25 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                     detail.setCuisineType(cuisineType);
                     
                     detail.setCuisineImage(item.getCuisineImage());
-                    detail.setUnitPrice(item.getUnitPrice());
+                    int lineQty = item.getQuantity() != null ? item.getQuantity() : 0;
+                    if (TABLEWARE_CUISINE_ID.equals(item.getCuisineId())) {
+                        java.math.BigDecimal p = item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO;
+                        detail.setListUnitPrice(p);
+                        detail.setUnitPrice(p);
+                        detail.setSubtotalAmount(p.multiply(BigDecimal.valueOf(lineQty)).setScale(2, RoundingMode.HALF_UP));
+                    } else {
+                        Integer cid = item.getCuisineId();
+                        java.math.BigDecimal listU = listUnitByCuisine.getOrDefault(cid,
+                                item.getOriginalUnitPrice() != null ? item.getOriginalUnitPrice()
+                                        : (item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO));
+                        java.math.BigDecimal saleU = saleUnitByCuisine.getOrDefault(cid,
+                                item.getCurrentUnitPrice() != null ? item.getCurrentUnitPrice()
+                                        : (item.getUnitPrice() != null ? item.getUnitPrice() : listU));
+                        detail.setListUnitPrice(listU);
+                        detail.setUnitPrice(saleU);
+                        detail.setSubtotalAmount(saleU.multiply(BigDecimal.valueOf(lineQty)).setScale(2, RoundingMode.HALF_UP));
+                    }
                     detail.setQuantity(item.getQuantity());
-                    detail.setSubtotalAmount(item.getSubtotalAmount());
                     detail.setAddUserId(item.getAddUserId());
                     detail.setAddUserPhone(item.getAddUserPhone());
                     detail.setRemark(item.getRemark());
@@ -809,7 +857,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         item.setCuisineName(detail.getCuisineName());
                         item.setCuisineImage(detail.getCuisineImage());
                         item.setQuantity(detail.getQuantity());
-                        item.setUnitPrice(detail.getUnitPrice());
+                        BigDecimal cur = detail.getUnitPrice();
+                        BigDecimal orig = detail.getListUnitPrice() != null ? detail.getListUnitPrice() : cur;
+                        item.setUnitPrice(cur);
+                        item.setOriginalUnitPrice(orig);
+                        item.setCurrentUnitPrice(cur);
                         item.setTags(detail.getCuisineId() != null ? finalCuisineIdToTags.get(detail.getCuisineId()) : null);
                         return item;
                     })
@@ -1369,7 +1421,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         item.setCuisineName(detail.getCuisineName());
                         item.setCuisineImage(detail.getCuisineImage());
                         item.setQuantity(detail.getQuantity());
-                        item.setUnitPrice(detail.getUnitPrice());
+                        BigDecimal cur = detail.getUnitPrice();
+                        BigDecimal orig = detail.getListUnitPrice() != null ? detail.getListUnitPrice() : cur;
+                        item.setUnitPrice(cur);
+                        item.setOriginalUnitPrice(orig);
+                        item.setCurrentUnitPrice(cur);
                         item.setTags(detail.getCuisineId() != null ? finalCuisineIdToTagsMy.get(detail.getCuisineId()) : null);
                         return item;
                     })
@@ -1428,7 +1484,12 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             item.setCuisineName(detail.getCuisineName());
             item.setCuisineType(detail.getCuisineType());
             item.setCuisineImage(detail.getCuisineImage());
-            item.setUnitPrice(detail.getUnitPrice());
+            BigDecimal cur = detail.getUnitPrice();
+            BigDecimal orig = detail.getListUnitPrice() != null ? detail.getListUnitPrice() : cur;
+            item.setUnitPrice(cur);
+            item.setOriginalUnitPrice(orig);
+            item.setCurrentUnitPrice(cur);
+            item.setHasActiveDiscount(orig != null && cur != null && orig.compareTo(cur) != 0);
             item.setQuantity(detail.getQuantity());
             item.setSubtotalAmount(detail.getSubtotalAmount());
             item.setAddUserId(detail.getAddUserId());
@@ -1782,7 +1843,59 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     }
 
     /**
-     * 校验预约存在、归属门店一致,且当前桌在该预约占用桌位中;不限制预约状态(延迟任务仅在已到店时更新)。
+     * 根据桌号解析可绑定的预约:user_reservation_table 关联本桌、门店一致、
+     * 状态为已到店(2)、预约日为今日(上海时区)。
+     * 不要求下单用户与预约登记用户一致,便于同伴在同一桌扫码下单。
+     * 若当日该桌有多条已到店预约,取预约 id 最大的一条。
+     */
+    private Integer resolveUserReservationIdForTable(StoreTable table) {
+        if (table == null || table.getId() == null) {
+            return null;
+        }
+        List<UserReservationTable> links = userReservationTableMapper.selectList(
+                new LambdaQueryWrapper<UserReservationTable>()
+                        .eq(UserReservationTable::getTableId, table.getId())
+                        .eq(UserReservationTable::getDeleteFlag, 0));
+        if (links == null || links.isEmpty()) {
+            return null;
+        }
+        Set<Integer> resIds = links.stream()
+                .map(UserReservationTable::getReservationId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (resIds.isEmpty()) {
+            return null;
+        }
+        List<UserReservation> candidates = userReservationMapper.selectList(
+                new LambdaQueryWrapper<UserReservation>()
+                        .in(UserReservation::getId, resIds)
+                        .eq(UserReservation::getStoreId, table.getStoreId()));
+        if (candidates == null || candidates.isEmpty()) {
+            return null;
+        }
+        LocalDate today = LocalDate.now(SHANGHAI);
+        List<UserReservation> active = candidates.stream()
+                .filter(r -> r.getStatus() != null && r.getStatus() == RESERVATION_STATUS_ARRIVED)
+                .filter(r -> reservationDateMatchesToday(r, today))
+                .collect(Collectors.toList());
+        if (active.isEmpty()) {
+            return null;
+        }
+        active.sort(Comparator.comparing(UserReservation::getId, Comparator.nullsFirst(Comparator.naturalOrder())).reversed());
+        return active.get(0).getId();
+    }
+
+    private static boolean reservationDateMatchesToday(UserReservation r, LocalDate today) {
+        if (r.getReservationDate() == null) {
+            return false;
+        }
+        LocalDate d = r.getReservationDate().toInstant().atZone(SHANGHAI).toLocalDate();
+        return today.equals(d);
+    }
+
+    /**
+     * 校验预约存在、归属门店一致、当前桌在该预约占用桌位中、已到店(2)。
+     * 下单账号可与预约登记用户不同(同伴同桌下单)。
      */
     private void validateUserReservationForOrder(Integer reservationId, StoreTable table) {
         if (reservationId == null || table == null) {
@@ -1792,6 +1905,9 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         if (r == null) {
             throw new RuntimeException("预约不存在");
         }
+        if (r.getStatus() == null || r.getStatus() != RESERVATION_STATUS_ARRIVED) {
+            throw new RuntimeException("仅预约状态为已到店时可下单,请先完成到店登记");
+        }
         if (r.getStoreId() == null || !r.getStoreId().equals(table.getStoreId())) {
             throw new RuntimeException("预约与门店不匹配");
         }

+ 80 - 0
alien-dining/src/main/java/shop/alien/dining/support/DiningMenuPricing.java

@@ -0,0 +1,80 @@
+package shop.alien.dining.support;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import shop.alien.entity.store.StoreCuisine;
+import shop.alien.entity.store.StoreProductDiscountRule;
+import shop.alien.mapper.StoreCuisineMapper;
+import shop.alien.mapper.StoreProductDiscountRuleMapper;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 点餐侧:门店标价 + {@link ProductDiscountPricingSupport},得到出品价(¥/份)。
+ */
+public final class DiningMenuPricing {
+
+    public static final int TABLEWARE_CUISINE_ID = -1;
+
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
+
+    private DiningMenuPricing() {
+    }
+
+    /**
+     * 当前门店价目标价(store_cuisine.total_price),缺失的 id 为 0。
+     */
+    public static Map<Integer, BigDecimal> resolveListUnitPriceByCuisineId(Set<Integer> cuisineIds, StoreCuisineMapper cuisineMapper) {
+        if (cuisineIds == null || cuisineIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<StoreCuisine> list = cuisineMapper.selectList(
+                new LambdaQueryWrapper<StoreCuisine>().in(StoreCuisine::getId, cuisineIds));
+        Map<Integer, BigDecimal> m = new HashMap<>();
+        if (list != null) {
+            for (StoreCuisine c : list) {
+                m.put(c.getId(), c.getTotalPrice() != null ? c.getTotalPrice() : BigDecimal.ZERO);
+            }
+        }
+        for (Integer id : cuisineIds) {
+            m.putIfAbsent(id, BigDecimal.ZERO);
+        }
+        return m;
+    }
+
+    /**
+     * 成交单价:有规则命中则用规则价,否则等于标价。
+     */
+    public static Map<Integer, BigDecimal> resolveSaleUnitPrice(
+            Integer storeId,
+            Map<Integer, BigDecimal> listUnitByCuisineId,
+            StoreProductDiscountRuleMapper ruleMapper) {
+        if (storeId == null || listUnitByCuisineId == null || listUnitByCuisineId.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Integer> ids = new ArrayList<>(listUnitByCuisineId.keySet());
+        List<StoreProductDiscountRule> rules = ruleMapper.selectList(
+                new LambdaQueryWrapper<StoreProductDiscountRule>()
+                        .eq(StoreProductDiscountRule::getStoreId, storeId)
+                        .eq(StoreProductDiscountRule::getStatus, 1)
+                        .in(StoreProductDiscountRule::getProductId, ids));
+        Map<Integer, BigDecimal> disc = ProductDiscountPricingSupport.resolveDiscountedPrices(
+                ids,
+                listUnitByCuisineId,
+                LocalDateTime.now(SHANGHAI),
+                rules != null ? rules : Collections.emptyList());
+        Map<Integer, BigDecimal> sale = new HashMap<>();
+        for (Integer id : ids) {
+            BigDecimal list = listUnitByCuisineId.getOrDefault(id, BigDecimal.ZERO);
+            sale.put(id, disc.containsKey(id) ? disc.get(id) : list);
+        }
+        return sale;
+    }
+}

+ 155 - 0
alien-dining/src/main/java/shop/alien/dining/support/ProductDiscountPricingSupport.java

@@ -0,0 +1,155 @@
+package shop.alien.dining.support;
+
+import shop.alien.entity.store.StoreProductDiscountRule;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 菜品优惠(store_product_discount_rule)估价逻辑,与 alien-store
+ * {@code StoreProductDiscountServiceImpl#resolveBestActiveRules} 保持一致,避免前后端口径不一致。
+ */
+public final class ProductDiscountPricingSupport {
+
+    private static final String TYPE_FREE = "FREE";
+    private static final String TYPE_DISCOUNT = "DISCOUNT";
+    private static final String MODE_PERMANENT = "PERMANENT";
+    private static final String MODE_CUSTOM = "CUSTOM";
+    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
+
+    private ProductDiscountPricingSupport() {
+    }
+
+    /**
+     * @param productIds         菜品 id 列表
+     * @param basePriceByProduct 菜品 id → 原价(totalPrice)
+     * @param now                判定「当前时刻」
+     * @param allRules           已限定门店、status=1、productId in 列表 的规则行
+     * @return 仅含命中规则的菜品 → 折后价(已 HALF_UP 2 位);未命中则不在 map 中
+     */
+    public static Map<Integer, BigDecimal> resolveDiscountedPrices(
+            List<Integer> productIds,
+            Map<Integer, BigDecimal> basePriceByProduct,
+            LocalDateTime now,
+            List<StoreProductDiscountRule> allRules) {
+        if (productIds == null || productIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        if (allRules == null || allRules.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<Integer, List<StoreProductDiscountRule>> byProduct = allRules.stream()
+                .collect(Collectors.groupingBy(StoreProductDiscountRule::getProductId));
+        Map<Integer, BigDecimal> result = new HashMap<>();
+        for (Integer pid : productIds) {
+            List<StoreProductDiscountRule> plist = byProduct.getOrDefault(pid, Collections.emptyList());
+            Map<String, List<StoreProductDiscountRule>> groups = plist.stream()
+                    .collect(Collectors.groupingBy(ProductDiscountPricingSupport::groupKey));
+            BigDecimal base = basePriceByProduct == null ? null : basePriceByProduct.get(pid);
+            StoreProductDiscountRule bestHead = null;
+            BigDecimal bestPrice = null;
+            for (List<StoreProductDiscountRule> group : groups.values()) {
+                if (!groupMatchesNow(group, now)) {
+                    continue;
+                }
+                StoreProductDiscountRule head = group.get(0);
+                BigDecimal dp = computeDiscountedPrice(base, head);
+                if (dp == null && !TYPE_FREE.equals(head.getDiscountType())) {
+                    continue;
+                }
+                if (bestPrice == null || (dp != null && dp.compareTo(bestPrice) < 0)) {
+                    bestPrice = dp;
+                    bestHead = head;
+                }
+            }
+            if (bestHead != null && bestPrice != null) {
+                result.put(pid, bestPrice);
+            }
+        }
+        return result;
+    }
+
+    private static boolean groupMatchesNow(List<StoreProductDiscountRule> group, LocalDateTime now) {
+        for (StoreProductDiscountRule r : group) {
+            if (rowMatchesNow(r, now)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean rowMatchesNow(StoreProductDiscountRule r, LocalDateTime now) {
+        LocalDate day = now.toLocalDate();
+        if (!isDateInRuleRange(day, r)) {
+            return false;
+        }
+        int dow = now.getDayOfWeek().getValue();
+        int mask = r.getWeekdayMask() == null ? 127 : r.getWeekdayMask();
+        if ((mask & (1 << (dow - 1))) == 0) {
+            return false;
+        }
+        LocalTime t = now.toLocalTime();
+        LocalTime st = r.getStartTime();
+        LocalTime et = r.getEndTime();
+        if (st == null && et == null) {
+            return true;
+        }
+        if (st == null || et == null) {
+            return true;
+        }
+        return !t.isBefore(st) && !t.isAfter(et);
+    }
+
+    private static boolean isDateInRuleRange(LocalDate day, StoreProductDiscountRule r) {
+        if (MODE_PERMANENT.equals(r.getEffectiveMode())) {
+            return true;
+        }
+        if (!MODE_CUSTOM.equals(r.getEffectiveMode())) {
+            return false;
+        }
+        if (r.getStartDate() == null || r.getEndDate() == null) {
+            return false;
+        }
+        LocalDate ls = new java.sql.Date(r.getStartDate().getTime()).toLocalDate();
+        LocalDate le = new java.sql.Date(r.getEndDate().getTime()).toLocalDate();
+        return !day.isBefore(ls) && !day.isAfter(le);
+    }
+
+    private static BigDecimal computeDiscountedPrice(BigDecimal total, StoreProductDiscountRule head) {
+        if (TYPE_FREE.equals(head.getDiscountType())) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        if (TYPE_DISCOUNT.equals(head.getDiscountType()) && head.getDiscountRate() != null) {
+            if (total == null) {
+                return null;
+            }
+            return total.multiply(head.getDiscountRate()).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+        }
+        return null;
+    }
+
+    private static String groupKey(StoreProductDiscountRule r) {
+        return r.getStoreId() + "|" + r.getProductId() + "|" + r.getRuleName() + "|" + r.getDiscountType()
+                + "|" + (r.getDiscountRate() == null ? "" : r.getDiscountRate().toPlainString())
+                + "|" + r.getEffectiveMode() + "|" + formatDate(r.getStartDate()) + "|" + formatDate(r.getEndDate());
+    }
+
+    private static String formatDate(Date d) {
+        if (d == null) {
+            return null;
+        }
+        synchronized (DATE_FORMAT) {
+            return DATE_FORMAT.format(d);
+        }
+    }
+}

+ 5 - 1
alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java

@@ -50,10 +50,14 @@ public class StoreOrderDetail {
     @TableField("cuisine_image")
     private String cuisineImage;
 
-    @ApiModelProperty(value = "单价")
+    @ApiModelProperty(value = "成交单价(可含时段优惠)")
     @TableField("unit_price")
     private BigDecimal unitPrice;
 
+    @ApiModelProperty(value = "原价单价(标价快照)")
+    @TableField("list_unit_price")
+    private BigDecimal listUnitPrice;
+
     @ApiModelProperty(value = "数量")
     @TableField("quantity")
     private Integer quantity;

+ 6 - 2
alien-entity/src/main/java/shop/alien/entity/store/UserReservation.java

@@ -41,11 +41,11 @@ public class UserReservation {
     @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
     private Date reservationDate;
 
-    @ApiModelProperty(value = "预约开始时间 HH:mm")
+    @ApiModelProperty(value = "预约开始时间:存库为 yyyy-MM-dd HH:mm(与订桌数据一致);历史数据可能仅有 HH:mm")
     @TableField("start_time")
     private String startTime;
 
-    @ApiModelProperty(value = "预约结束时间 HH:mm(可往后延续)")
+    @ApiModelProperty(value = "预约结束时间:存库为 yyyy-MM-dd HH:mm;历史数据可能仅有 HH:mm")
     @TableField("end_time")
     private String endTime;
 
@@ -86,6 +86,10 @@ public class UserReservation {
     @TableField("reason")
     private String reason;
 
+    @ApiModelProperty(value = "来源类型:0-线上预订,1-app扫码,2-线下开台")
+    @TableField("type")
+    private Integer type;
+
     @ApiModelProperty(value = "删除标记, 0:未删除, 1:已删除")
     @TableField("delete_flag")
     @TableLogic

+ 10 - 1
alien-entity/src/main/java/shop/alien/entity/store/dto/CartItemDTO.java

@@ -28,9 +28,18 @@ public class CartItemDTO {
     @ApiModelProperty(value = "菜品图片")
     private String cuisineImage;
 
-    @ApiModelProperty(value = "单价")
+    @ApiModelProperty(value = "单价(与现价一致,入库/展示均为成交用单价)")
     private BigDecimal unitPrice;
 
+    @ApiModelProperty(value = "原价单价(门店标价 store_cuisine.total_price,随规则对比展示)")
+    private BigDecimal originalUnitPrice;
+
+    @ApiModelProperty(value = "现价单价(命中 store_product_discount_rule 后与原价可能不同;无优惠时等于原价)")
+    private BigDecimal currentUnitPrice;
+
+    @ApiModelProperty(value = "当前是否有生效中的菜品优惠")
+    private Boolean hasActiveDiscount;
+
     @ApiModelProperty(value = "数量")
     private Integer quantity;
 

+ 1 - 1
alien-entity/src/main/java/shop/alien/entity/store/dto/CreateOrderDTO.java

@@ -20,7 +20,7 @@ public class CreateOrderDTO {
     @NotNull(message = "桌号ID不能为空")
     private Integer tableId;
 
-    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id,从预订到店扫码等场景传入;散客不传)")
+    @ApiModelProperty(value = "关联用户预约ID(user_reservation.id,可选;不传时后端根据 tableId 解析当前桌有效预约)")
     private Integer userReservationId;
 
     @ApiModelProperty(value = "就餐人数")

+ 2 - 2
alien-entity/src/main/java/shop/alien/entity/store/dto/DiningWalkInReservationDTO.java

@@ -31,11 +31,11 @@ public class DiningWalkInReservationDTO {
     @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
     private Date reservationDate;
 
-    @ApiModelProperty(value = "开始时间 HH:mm", required = true)
+    @ApiModelProperty(value = "开始时间 HH:mm(与 reservation_date 组合;入库为 yyyy-MM-dd HH:mm)", required = true)
     @NotBlank(message = "开始时间不能为空")
     private String startTime;
 
-    @ApiModelProperty(value = "结束时间 HH:mm;若早于开始时间则视为次日结束。选填,不填则按4小时占用窗口参与冲突校验")
+    @ApiModelProperty(value = "结束时间 HH:mm;若早于当日开始钟点则视为次日结束。选填;入库为 yyyy-MM-dd HH:mm,不填则按占位规则写出结束时刻")
     private String endTime;
 
     @ApiModelProperty(value = "就餐人姓名")

+ 5 - 2
alien-entity/src/main/java/shop/alien/entity/store/dto/UserReservationDTO.java

@@ -30,10 +30,10 @@ public class UserReservationDTO {
     @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
     private Date reservationDate;
 
-    @ApiModelProperty(value = "预约开始时间 HH:mm", example = "09:00")
+    @ApiModelProperty(value = "预约开始时间:建议 yyyy-MM-dd HH:mm;兼容 HH:mm(按预约日拼接)", example = "2026-03-28 09:00")
     private String startTime;
 
-    @ApiModelProperty(value = "预约结束时间 HH:mm", example = "12:00")
+    @ApiModelProperty(value = "预约结束时间:建议 yyyy-MM-dd HH:mm;兼容 HH:mm", example = "2026-03-28 12:00")
     private String endTime;
 
     @ApiModelProperty(value = "预约人数", required = true)
@@ -57,6 +57,9 @@ public class UserReservationDTO {
     @ApiModelProperty(value = "预约人电话")
     private String reservationUserPhone;
 
+    @ApiModelProperty(value = "来源类型:0-线上预订,1-app扫码,2-线下开台;不传默认0")
+    private Integer type;
+
     @ApiModelProperty(value = "关联的桌号ID列表(store_booking_table.id)")
     private List<Integer> tableIds;
 }

+ 4 - 5
alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryWithCuisinesVO.java

@@ -5,7 +5,6 @@ import io.swagger.annotations.ApiModelProperty;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
-import shop.alien.entity.store.StoreCuisine;
 import shop.alien.entity.store.StoreCuisineCategory;
 
 import java.util.List;
@@ -15,7 +14,7 @@ import java.util.List;
  * <p>
  * 与单独接口的返回结构一致:
  * - category 与 GET /store/info/categories 中每个元素的字段完全一致(StoreCuisineCategory)
- * - cuisines 与 GET /store/info/cuisines 返回的列表元素字段完全一致(StoreCuisine
+ * - cuisines 在 StoreCuisine 字段基础上增加 originalPrice、currentPrice、hasActiveDiscount(与菜品优惠规则展示一致
  * </p>
  *
  * @author system
@@ -23,12 +22,12 @@ import java.util.List;
 @Data
 @NoArgsConstructor
 @AllArgsConstructor
-@ApiModel(value = "CategoryWithCuisinesVO", description = "菜品种类及该分类下的菜品列表;category 与 /store/info/categories 单条一致,cuisines 与 /store/info/cuisines 返回列表元素一致")
+@ApiModel(value = "CategoryWithCuisinesVO", description = "菜品种类及该分类下的菜品列表;category 与 /store/info/categories 单条一致;cuisines 含原价/现价")
 public class CategoryWithCuisinesVO {
 
     @ApiModelProperty(value = "菜品种类信息,字段与 GET /store/info/categories 返回的每条一致(StoreCuisineCategory)")
     private StoreCuisineCategory category;
 
-    @ApiModelProperty(value = "该分类下的菜品列表,每项字段与 GET /store/info/cuisines 返回的每条一致(StoreCuisine)")
-    private List<StoreCuisine> cuisines;
+    @ApiModelProperty(value = "该分类下的菜品列表(StoreCuisine 全部字段 + 原价/现价)")
+    private List<StoreCuisineWithPricesVO> cuisines;
 }

+ 7 - 1
alien-entity/src/main/java/shop/alien/entity/store/vo/OrderCuisineItemVO.java

@@ -28,9 +28,15 @@ public class OrderCuisineItemVO {
     @ApiModelProperty(value = "数量")
     private Integer quantity;
 
-    @ApiModelProperty(value = "单价")
+    @ApiModelProperty(value = "单价(与现价一致,为成交单价)")
     private BigDecimal unitPrice;
 
+    @ApiModelProperty(value = "原价单价(标价)")
+    private BigDecimal originalUnitPrice;
+
+    @ApiModelProperty(value = "现价单价(成交单价,含时段优惠)")
+    private BigDecimal currentUnitPrice;
+
     @ApiModelProperty(value = "菜品标签(JSON数组,如:[\"招牌菜\",\"推荐\"])")
     private String tags;
 }

+ 27 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreCuisineWithPricesVO.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 lombok.EqualsAndHashCode;
+import shop.alien.entity.store.StoreCuisine;
+
+import java.math.BigDecimal;
+
+/**
+ * 美食价目表行 + 展示用原价/现价(与 {@link StoreProductDiscountRule} 命中逻辑对齐,用于分类下菜品列表等接口)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel(value = "StoreCuisineWithPricesVO", description = "菜品信息(含原价、现价);其它字段与 StoreCuisine 一致")
+public class StoreCuisineWithPricesVO extends StoreCuisine {
+
+    @ApiModelProperty(value = "原价(门店标价,与 totalPrice 一致)")
+    private BigDecimal originalPrice;
+
+    @ApiModelProperty(value = "现价(按菜品优惠规则当前时刻命中后的价格;无优惠时等于原价)")
+    private BigDecimal currentPrice;
+
+    @ApiModelProperty(value = "当前是否存在生效中的菜品优惠")
+    private Boolean hasActiveDiscount;
+}

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StoreReservationListVo.java

@@ -67,6 +67,9 @@ public class StoreReservationListVo {
     @ApiModelProperty(value = "取消原因(商家端取消)")
     private String reason;
 
+    @ApiModelProperty(value = "来源类型:0-线上预订,1-app扫码,2-线下开台")
+    private Integer type;
+
     @ApiModelProperty(value = "预约状态 0:待确认 1:已确认 2:已到店 3:已取消 4:未到店超时 5:用餐结束")
     private Integer status;
 

+ 5 - 2
alien-entity/src/main/java/shop/alien/entity/store/vo/UserReservationVo.java

@@ -33,10 +33,10 @@ public class UserReservationVo {
     @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
     private Date reservationDate;
 
-    @ApiModelProperty(value = "预约开始时间 HH:mm")
+    @ApiModelProperty(value = "预约开始时间,一般为 yyyy-MM-dd HH:mm")
     private String startTime;
 
-    @ApiModelProperty(value = "预约结束时间 HH:mm")
+    @ApiModelProperty(value = "预约结束时间,一般为 yyyy-MM-dd HH:mm")
     private String endTime;
 
     @ApiModelProperty(value = "预约人数")
@@ -64,6 +64,9 @@ public class UserReservationVo {
     @ApiModelProperty(value = "预约人电话")
     private String reservationUserPhone;
 
+    @ApiModelProperty(value = "来源类型:0-线上预订,1-app扫码,2-线下开台")
+    private Integer type;
+
     @ApiModelProperty(value = "关联的桌号ID列表")
     private List<Integer> tableIds;
 

+ 3 - 0
alien-entity/src/main/resources/db/migration/store_order_detail_list_unit_price.sql

@@ -0,0 +1,3 @@
+-- 订单明细:菜品原价(标价),unit_price 表示成交单价(可含时段优惠)
+ALTER TABLE store_order_detail
+    ADD COLUMN list_unit_price DECIMAL(12, 2) NULL COMMENT '原价(标价,store_cuisine.total_price 快照)' AFTER unit_price;

+ 3 - 0
alien-entity/src/main/resources/db/migration/store_order_user_reservation_id.sql

@@ -0,0 +1,3 @@
+-- 点餐订单可选关联用户预约,便于延迟任务与对账
+ALTER TABLE `store_order`
+    ADD COLUMN `user_reservation_id` int NULL COMMENT '关联用户预约 user_reservation.id(散客为空)' AFTER `table_id`;

+ 3 - 0
alien-entity/src/main/resources/db/migration/store_table_app_qrcode_url.sql

@@ -0,0 +1,3 @@
+-- APP 桌码二维码 URL(与微信小程序码并列存储)
+ALTER TABLE `store_table`
+    ADD COLUMN `app_qrcode_url` VARCHAR(512) DEFAULT NULL COMMENT 'APP桌码二维码URL(JSON: storeId + tableNumber)' AFTER `qrcode_url`;

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

@@ -19,6 +19,7 @@
         <result column="reservation_user_gender" property="reservationUserGender" />
         <result column="reservation_user_phone" property="reservationUserPhone" />
         <result column="reason" property="reason" />
+        <result column="type" property="type" />
         <result column="delete_flag" property="deleteFlag" />
         <result column="created_time" property="createdTime" />
         <result column="created_user_id" property="createdUserId" />
@@ -43,6 +44,7 @@
         <result column="customer_name" property="customerName" />
         <result column="contact_phone" property="contactPhone" />
         <result column="remark" property="remark" />
+        <result column="type" property="type" />
         <result column="status" property="status" />
         <result column="status_text" property="statusText" />
         <result column="total_amount" property="totalAmount" />
@@ -72,6 +74,7 @@
         <result column="reservation_user_name" property="reservationUserName" />
         <result column="reservation_user_gender" property="reservationUserGender" />
         <result column="reservation_user_phone" property="reservationUserPhone" />
+        <result column="type" property="type" />
         <result column="created_time" property="createdTime" />
         <result column="updated_time" property="updatedTime" />
     </resultMap>
@@ -95,6 +98,7 @@
             ur.reservation_user_name,
             ur.reservation_user_gender,
             ur.reservation_user_phone,
+            ur.type,
             ur.created_time,
             ur.updated_time
         FROM
@@ -138,6 +142,7 @@
             IFNULL(lu.real_name, lu.user_name) AS customer_name,
             lu.user_phone AS contact_phone,
             ur.remark,
+            ur.type,
             ur.status,
             CASE ur.status
                 WHEN 0 THEN '待确认'

+ 2 - 1
alien-store/src/main/java/shop/alien/store/controller/StoreServiceFeeRuleController.java

@@ -65,7 +65,8 @@ public class StoreServiceFeeRuleController {
             @ApiImplicitParam(name = "id", value = "规则ID", dataType = "Integer", paramType = "query", required = true)
     })
     @PostMapping("/delete")
-    public R<Boolean> delete(@RequestParam Integer id) {
+    public R<Boolean> delete(@RequestBody StoreServiceFeeRuleSaveDto dto) {
+        Integer id = dto.getId();
         return storeServiceFeeRuleService.deleteRule(id);
     }
 

+ 1 - 1
alien-store/src/main/java/shop/alien/store/controller/UserReservationController.java

@@ -37,7 +37,7 @@ public class UserReservationController {
     @ApiOperationSupport(order = 1)
     @PostMapping("/add")
     public R<Integer> add(@RequestBody UserReservationDTO dto) {
-        log.info("UserReservationController.add?userId={}, storeId={}", dto.getUserId(), dto.getStoreId());
+        log.info("UserReservationController.add?userId={}, storeId={}, type={}", dto.getUserId(), dto.getStoreId(), dto.getType());
         try {
             Integer id = userReservationService.add(dto);
             return R.data(id);

+ 16 - 0
alien-store/src/main/java/shop/alien/store/controller/dining/StoreDiningPathProxyController.java

@@ -57,6 +57,22 @@ public class StoreDiningPathProxyController {
         }
     }
 
+    @ApiOperation("【小程序】按门店与餐桌查询预约详情(透传点餐 → Feign 门店,与 APP 直调 /user/reservation/detailByStoreAndTableRecord 等价)")
+    @GetMapping("/reservation/detail-by-store-table-record")
+    public R<List<UserReservationVo>> reservationDetailByStoreAndTableRecord(
+            HttpServletRequest request,
+            @RequestParam Integer storeId,
+            @RequestParam Integer userReservationTableId,
+            @RequestParam(required = false) String reservationDate) {
+        try {
+            return diningServiceFeign.reservationDetailByStoreAndTableRecord(
+                    auth(request), storeId, userReservationTableId, reservationDate);
+        } catch (Exception e) {
+            log.error("reservationDetailByStoreAndTableRecord: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
     @ApiOperation("获取点餐页面信息")
     @GetMapping("/page-info")
     public R<DiningPageInfoVO> getDiningPageInfo(

+ 11 - 1
alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java

@@ -61,6 +61,16 @@ public interface DiningServiceFeign {
             @RequestBody DiningWalkInReservationDTO dto);
 
     /**
+     * 【微信小程序 / 统一网关】按门店与餐桌查询预约详情;点餐服务内 Feign 门店 {@code /user/reservation/detailByStoreAndTableRecord}。
+     */
+    @GetMapping("/store/dining/reservation/detail-by-store-table-record")
+    R<List<UserReservationVo>> reservationDetailByStoreAndTableRecord(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("storeId") Integer storeId,
+            @RequestParam("userReservationTableId") Integer userReservationTableId,
+            @RequestParam(value = "reservationDate", required = false) String reservationDate);
+
+    /**
      * 获取点餐页面信息
      *
      * @param authorization 请求头 Authorization,供 dining 解析当前用户
@@ -499,7 +509,7 @@ public interface DiningServiceFeign {
      *
      * @param storeId 门店ID
      * @param keyword 菜品名称模糊查询关键词(可选)
-     * @return R.data 为 List&lt;CategoryWithCuisinesVO&gt;
+     * @return R.data 为 List&lt;CategoryWithCuisinesVO&gt;,cuisines 每项含 originalPrice、currentPrice、hasActiveDiscount
      */
     @GetMapping("/store/info/categories-with-cuisines")
     R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisinesByStoreId(

+ 35 - 14
alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java

@@ -2,6 +2,7 @@ package shop.alien.store.service.impl;
 
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.RequiredArgsConstructor;
@@ -102,6 +103,34 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
             }
         }
 
+        // 排序规则(按桌号聚合):
+        // 1) 先按“桌号组的最新时间”倒序(哪个桌号刚创建/更新,整组置顶);
+        // 2) 组内按单条 updatedTime 倒序;
+        // 3) 最后用桌号(名称)保证同名相邻。
+        Map<String, Date> tableMaxUpdated = new HashMap<>();
+        for (StoreServiceFeeRuleListVo vo : records) {
+            String tableName = (vo.getTableNameList() == null || vo.getTableNameList().isEmpty())
+                    ? null
+                    : vo.getTableNameList().get(0);
+            Date ts = vo.getUpdatedTime();
+            if (tableName == null) continue;
+            Date cur = tableMaxUpdated.get(tableName);
+            if (cur == null || (ts != null && ts.after(cur))) {
+                tableMaxUpdated.put(tableName, ts);
+            }
+        }
+        records.sort((a, b) -> {
+            String at = (a.getTableNameList() == null || a.getTableNameList().isEmpty()) ? null : a.getTableNameList().get(0);
+            String bt = (b.getTableNameList() == null || b.getTableNameList().isEmpty()) ? null : b.getTableNameList().get(0);
+            Date ag = tableMaxUpdated.get(at);
+            Date bg = tableMaxUpdated.get(bt);
+            int cmpGroup = Comparator.nullsLast(Date::compareTo).reversed().compare(ag, bg);
+            if (cmpGroup != 0) return cmpGroup;
+            int cmpInner = Comparator.nullsLast(Date::compareTo).reversed().compare(a.getUpdatedTime(), b.getUpdatedTime());
+            if (cmpInner != 0) return cmpInner;
+            return Comparator.nullsFirst(String::compareTo).compare(at, bt);
+        });
+
         Page<StoreServiceFeeRuleListVo> result = new Page<>(pn, ps, records.size());
         result.setRecords(records);
         return R.data(result);
@@ -234,20 +263,12 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
         if (id == null) {
             return R.fail("规则ID不能为空");
         }
-        StoreServiceFeeRule existing = ruleMapper.selectById(id);
-        if (existing == null) {
-            return R.fail("服务费规则不存在");
-        }
-        ruleMapper.delete(new LambdaQueryWrapper<StoreServiceFeeRule>()
-                .eq(StoreServiceFeeRule::getStoreId, existing.getStoreId())
-                .eq(StoreServiceFeeRule::getFeeName, existing.getFeeName())
-                .eq(StoreServiceFeeRule::getFeeType, existing.getFeeType())
-                .eq(StoreServiceFeeRule::getFeeValue, existing.getFeeValue())
-                .eq(StoreServiceFeeRule::getStatus, existing.getStatus())
-                .eq(StoreServiceFeeRule::getEffectiveMode, existing.getEffectiveMode())
-                .eq(StoreServiceFeeRule::getStartDate, existing.getStartDate())
-                .eq(StoreServiceFeeRule::getEndDate, existing.getEndDate()));
-        return R.data(true);
+
+        LambdaUpdateWrapper<StoreServiceFeeRule> updateWrapper = new LambdaUpdateWrapper<>();
+        updateWrapper.eq(StoreServiceFeeRule::getId, id);
+        updateWrapper.set(StoreServiceFeeRule::getDeleteFlag, 1);
+        int result = ruleMapper.update(null, updateWrapper);
+        return R.data(result > 0);
     }
 
     @Override

+ 15 - 2
alien-store/src/main/java/shop/alien/store/service/impl/UserReservationServiceImpl.java

@@ -119,9 +119,14 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         if (dto.getGuestCount() == null || dto.getGuestCount() < 1) {
             throw new RuntimeException("预约人数至少为1");
         }
+        int sourceType = dto.getType() == null ? 0 : dto.getType();
+        if (sourceType < 0 || sourceType > 2) {
+            throw new RuntimeException("type参数不合法,仅支持0-线上预订、1-app扫码、2-线下开台");
+        }
 
         UserReservation entity = new UserReservation();
-        BeanUtils.copyProperties(dto, entity, "id", "tableIds");
+        BeanUtils.copyProperties(dto, entity, "id", "tableIds", "type");
+        entity.setType(sourceType);
         entity.setId(null);
         entity.setReservationNo(generateReservationNo());
         if (entity.getStatus() == null) {
@@ -212,9 +217,17 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         String oldTableNumber = tableIdsToTableNumberString(listTableIdsByReservationId(existing.getId()));
 
         UserReservation entity = new UserReservation();
-        BeanUtils.copyProperties(dto, entity, "tableIds", "reservationNo");
+        BeanUtils.copyProperties(dto, entity, "tableIds", "reservationNo", "type");
         entity.setId(existing.getId());
         entity.setReservationNo(existing.getReservationNo());
+        if (dto.getType() != null) {
+            if (dto.getType() < 0 || dto.getType() > 2) {
+                throw new RuntimeException("type参数不合法,仅支持0-线上预订、1-app扫码、2-线下开台");
+            }
+            entity.setType(dto.getType());
+        } else {
+            entity.setType(existing.getType());
+        }
         this.updateById(entity);
 
         saveReservationTables(existing.getId(), dto.getTableIds());