瀏覽代碼

点餐页面价目表维护 显示现价和原价

lutong 3 周之前
父節點
當前提交
fc011bf1e4

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

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

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

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

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

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

@@ -499,7 +499,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(