Ver código fonte

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

liudongzhi 2 semanas atrás
pai
commit
ba395e4238

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

@@ -33,7 +33,7 @@ public class DiningController {
     private final DiningService diningService;
     private final DiningWalkInReservationService diningWalkInReservationService;
 
-    @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);

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

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

+ 14 - 3
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
@@ -103,8 +103,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();
     }
 

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

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

+ 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 = "就餐人数")

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

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