瀏覽代碼

008优惠幅度20%限制:创建侧校验+算价预算封顶

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qmj 1 天之前
父節點
當前提交
6c17eaf3a7

文件差異過大導致無法顯示
+ 22 - 0
.claude/homunculus/observations.jsonl


+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/dto/PromotionCalcResponse.java

@@ -142,6 +142,8 @@ public class PromotionCalcResponse {
         private BigDecimal reduce;
         /** 关联ID: 促销活动ID(promo_type=1) 或 券批次ID(promo_type=2) */
         private Long refId;
+        /** 是否被优惠幅度限制封顶(仅封顶时返回, 对应"已达优惠上限") */
+        private Boolean limitCapped;
     }
 
     /**
@@ -167,6 +169,8 @@ public class PromotionCalcResponse {
         private BigDecimal couponPreviewReduce;
         /** 商品券作用于的商品预览明细(仅商品券: 名称/原价/优惠后价) */
         private List<LineItem> productPreview;
+        /** 不可用原因(目前仅: 本单优惠已达优惠上限) */
+        private String unusableReason;
         /** 过期时间 */
         @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Taipei")
         private Date expireTime;

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java

@@ -30,6 +30,9 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
     @Autowired
     private PromotionActivityRuleMapper ruleMapper;
 
+    @Autowired
+    private PromotionDiscountLimitChecker discountLimitChecker;
+
     /**
      * 创建促销活动(含规则)
      */
@@ -51,6 +54,8 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         }
         // 校验:同一门店同一促销类型,同一时间段内只允许一个活动(时间段重叠即拒绝)
         checkTimeOverlap(activity, null);
+        // 校验:优惠幅度限制(平台开关控制,满减档位/折扣率不超上限)
+        discountLimitChecker.checkActivity(activity, rules);
         int rows = promotionActivityMapper.insert(activity);
         Long activityId = activity.getId();
         // 逐条插入规则
@@ -125,6 +130,13 @@ public class PromotionActivityServiceImpl extends ServiceImpl<BaseMapper<Promoti
         }
         // 校验:同一门店同一促销类型,同一时间段内只允许一个活动(排除自身)
         checkTimeOverlap(activity, activityId);
+        // 编辑入参可能不带 type,回填现有值供优惠幅度校验用(写回同值无副作用)
+        if (activity.getType() == null)
+        {
+            activity.setType(existing.getType());
+        }
+        // 校验:优惠幅度限制(平台开关控制,满减档位/折扣率不超上限)
+        discountLimitChecker.checkActivity(activity, rules);
         int rows = promotionActivityMapper.updateById(activity);
         // 删除旧规则
         ruleMapper.delete(new LambdaQueryWrapper<PromotionActivityRule>().eq(PromotionActivityRule::getActivityId, activityId));

+ 125 - 2
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCalcServiceImpl.java

@@ -69,6 +69,9 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
     @Autowired
     private PosOrderMapper posOrderMapper;
 
+    @Autowired
+    private PromotionDiscountLimitChecker discountLimitChecker;
+
     @Override
     public PromotionCalcResponse calculate(PromotionCalcRequest request, Long userId)
     {
@@ -171,6 +174,27 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         BigDecimal afterPromotion = "A".equals(optimalPath) ? subtotalA : subtotalB;
         BigDecimal promotionReduce = originalAmount.subtract(afterPromotion);
 
+        // ---- 5.5 优惠幅度预算(spec FR-025):预算 = 比例 × originalAmount,按 促销→新客→券 层层封顶 ----
+        // budgetLimit=null(开关关闭)即预算无限:下面所有封顶分支全部跳过,行为与现状完全一致
+        BigDecimal budgetLimit = discountLimitChecker.isEnabled()
+                ? originalAmount.multiply(discountLimitChecker.getRatioPercent().movePointLeft(2))
+                        .setScale(0, RoundingMode.HALF_UP)
+                : null;
+        // 促销+新客的已占预算(券之前);候选券列表也复用它算"剩余预算",与当前选中哪张券无关
+        BigDecimal budgetUsedBeforeCoupon = BigDecimal.ZERO;
+        boolean promotionCapped = false;
+        // 半价胜出豁免:第二杯半价结构性封顶该商品小计的25%,不占预算也不封顶(spec Session 2026-09-02)
+        boolean halfPriceExempt = "A".equals(optimalPath)
+                && Integer.valueOf(3).equals(pathAResult.get("appliedType"));
+        if (budgetLimit != null && !halfPriceExempt && promotionReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            BigDecimal capped = promotionReduce.min(budgetLimit);
+            promotionCapped = capped.compareTo(promotionReduce) < 0;
+            budgetUsedBeforeCoupon = budgetUsedBeforeCoupon.add(capped);
+            promotionReduce = capped;
+            afterPromotion = originalAmount.subtract(promotionReduce);
+        }
+
         // ---- 6. 新客立减(type=4) ----
         BigDecimal newCustomerReduce = BigDecimal.ZERO;
         if (userId != null)
@@ -202,8 +226,20 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             }
         }
 
+        // 新客立减同样封顶:实减 = min(设置额, 剩余预算)。创建侧无门槛无分母,统一由预算兜底
+        boolean newCustomerCapped = false;
+        if (budgetLimit != null && newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
+        {
+            BigDecimal remaining = budgetLimit.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
+            BigDecimal capped = newCustomerReduce.min(remaining);
+            newCustomerCapped = capped.compareTo(newCustomerReduce) < 0;
+            budgetUsedBeforeCoupon = budgetUsedBeforeCoupon.add(capped);
+            newCustomerReduce = capped;
+        }
+
         // ---- 7. 优惠券 ----
         BigDecimal couponReduce = BigDecimal.ZERO;
+        boolean couponCapped = false;
         String couponName = null;
         Boolean couponConflict = null;
         String conflictNote = null;
@@ -238,6 +274,13 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                             CouponCalcResult mutexResult = calcCouponReduce(couponRule, batch, itemsWithPrice, originalAmount, priceMap, nameMap);
                             couponReduce = mutexResult.reduce;
                             couponProductItems = mutexResult.items;
+                            // 互斥券路径(spec FR-025):促销/新客清零,券自身用全额预算封顶
+                            if (budgetLimit != null && couponReduce.compareTo(budgetLimit) > 0)
+                            {
+                                couponReduce = budgetLimit;
+                                couponCapped = true;
+                                applyCappedCouponReduce(couponProductItems, couponReduce);
+                            }
                             afterPromotion = originalAmount;
                             promotionReduce = BigDecimal.ZERO;
                             newCustomerReduce = BigDecimal.ZERO;
@@ -275,6 +318,18 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                                             baseForCoupon, priceMap, nameMap);
                                     couponReduce = normalResult.reduce;
                                     couponProductItems = normalResult.items;
+                                    // 普通券路径:实减 = min(可抵金额, 促销+新客占用后的剩余预算),部分抵扣
+                                    if (budgetLimit != null && couponReduce.compareTo(BigDecimal.ZERO) > 0)
+                                    {
+                                        BigDecimal remaining = budgetLimit.subtract(budgetUsedBeforeCoupon)
+                                                .max(BigDecimal.ZERO);
+                                        if (couponReduce.compareTo(remaining) > 0)
+                                        {
+                                            couponReduce = remaining;
+                                            couponCapped = true;
+                                            applyCappedCouponReduce(couponProductItems, couponReduce);
+                                        }
+                                    }
                                 }
                             }
                         }
@@ -365,6 +420,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                 }
             }
             detail.setReduce(promotionReduce);
+            if (promotionCapped)
+            {
+                detail.setLimitCapped(true);
+            }
             details.add(detail);
         }
         if (newCustomerReduce.compareTo(BigDecimal.ZERO) > 0)
@@ -374,6 +433,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             detail.setSubType(4);
             detail.setName("新客立减");
             detail.setReduce(newCustomerReduce);
+            if (newCustomerCapped)
+            {
+                detail.setLimitCapped(true);
+            }
             // 找到新客立减活动ID
             for (PromotionActivity act : activeActivities)
             {
@@ -392,6 +455,10 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
             detail.setSubType(0);
             detail.setName(couponName);
             detail.setReduce(couponReduce);
+            if (couponCapped)
+            {
+                detail.setLimitCapped(true);
+            }
             // 优惠券的refId设为券批次ID
             if (batch != null)
             {
@@ -404,7 +471,8 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         // ---- 11. 可用优惠券列表 ----
         List<Map<String, Object>> availableCouponMaps = buildAvailableCoupons(storeId, userId,
                 originalAmount, afterPromotion, promotionReduce, itemsWithPrice,
-                rulesByProduct, activityMap, priceMap, nameMap, optimalPath);
+                rulesByProduct, activityMap, priceMap, nameMap, optimalPath,
+                budgetLimit, budgetUsedBeforeCoupon);
         response.setAvailableCoupons(availableCouponMaps.stream()
                 .map(this::toAvailableCoupon)
                 .collect(Collectors.toList()));
@@ -476,6 +544,7 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         ac.setAmount((BigDecimal) couponMap.get("amount"));
         ac.setDiscountRate((BigDecimal) couponMap.get("discountRate"));
         ac.setUsable((Boolean) couponMap.get("usable"));
+        ac.setUnusableReason((String) couponMap.get("unusableReason"));
         ac.setConflictWithPromotion((Boolean) couponMap.get("conflictWithPromotion"));
         ac.setCouponPreviewReduce((BigDecimal) couponMap.get("couponPreviewReduce"));
         @SuppressWarnings("unchecked")
@@ -839,8 +908,28 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
         }
     }
 
+    /**
+     * 券被预算封顶后,同步把商品券行预览的优惠后价改为按封顶实减重算
+     * (originalLineTotal − 封顶后实减),保证返回/快照金额与实减一致
+     */
+    private void applyCappedCouponReduce(List<Map<String, Object>> couponItems, BigDecimal cappedReduce)
+    {
+        for (Map<String, Object> lineItem : couponItems)
+        {
+            BigDecimal original = (BigDecimal) lineItem.get("originalLineTotal");
+            if (original != null)
+            {
+                lineItem.put("finalLineTotal", original.subtract(cappedReduce).max(BigDecimal.ZERO)
+                        .setScale(0, RoundingMode.HALF_UP));
+            }
+        }
+    }
+
     /**
      * 构建用户可用优惠券列表
+     *
+     * budgetLimit 为 null(限制开关关闭)时不算预算,行为与现状完全一致;
+     * budgetUsedBeforeCoupon 为促销+新客已占预算(候选券与当前选中券互为替代,故只看券前占用)
      */
     private List<Map<String, Object>> buildAvailableCoupons(Long storeId, Long userId,
                                                             BigDecimal originalAmount,
@@ -851,7 +940,9 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                                                             Map<Long, PromotionActivity> activityMap,
                                                             Map<Long, BigDecimal> priceMap,
                                                             Map<Long, String> nameMap,
-                                                            String optimalPath)
+                                                            String optimalPath,
+                                                            BigDecimal budgetLimit,
+                                                            BigDecimal budgetUsedBeforeCoupon)
     {
         List<Map<String, Object>> available = new ArrayList<>();
         if (userId == null)
@@ -944,6 +1035,38 @@ public class PromotionCalcServiceImpl implements IPromotionCalcService
                 }
             }
 
+            // 预算封顶(spec FR-026):候选实抵 = min(原可抵金额, 剩余预算);互斥券选中会清掉促销,用全额预算
+            // 放在商品券预览之后,封顶后的实抵值覆盖未封顶预览
+            if (budgetLimit != null)
+            {
+                // 互斥券只有在实际会取消促销时(促销>0,与 calculate 互斥分支进入条件一致)才享受全额预算;
+                // 无促销时互斥券走普通分支,按促销+新客占用后的剩余预算封顶
+                boolean mutexActive = isMutex == 1 && promotionReduce.compareTo(BigDecimal.ZERO) > 0;
+                BigDecimal candidateBudget = mutexActive ? budgetLimit
+                        : budgetLimit.subtract(budgetUsedBeforeCoupon).max(BigDecimal.ZERO);
+                BigDecimal baseForCalc = mutexActive ? originalAmount : afterPromotion;
+                CouponCalcResult budgetCalc = calcCouponReduce(rule, batch, itemsWithPrice,
+                        baseForCalc, priceMap, nameMap);
+                BigDecimal cappedReduce = budgetCalc.reduce.min(candidateBudget);
+                if (budgetCalc.reduce.compareTo(BigDecimal.ZERO) > 0
+                        && cappedReduce.compareTo(BigDecimal.ZERO) <= 0)
+                {
+                    usable = false;
+                    couponInfo.put("unusableReason", "本单优惠已达优惠上限");
+                    couponInfo.put("couponPreviewReduce", BigDecimal.ZERO);
+                }
+                else if (cappedReduce.compareTo(BigDecimal.ZERO) > 0)
+                {
+                    couponInfo.put("couponPreviewReduce", cappedReduce);
+                    if (cappedReduce.compareTo(budgetCalc.reduce) < 0)
+                    {
+                        // 商品券预览行同步按封顶实减重算,避免预览行金额与 couponPreviewReduce 矛盾
+                        applyCappedCouponReduce(budgetCalc.items, cappedReduce);
+                        couponInfo.put("productPreview", budgetCalc.items);
+                    }
+                }
+            }
+
             couponInfo.put("usable", usable);
             available.add(couponInfo);
         }

+ 19 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImpl.java

@@ -35,6 +35,9 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
     @Autowired
     private PromotionCouponRuleMapper ruleMapper;
 
+    @Autowired
+    private PromotionDiscountLimitChecker discountLimitChecker;
+
     /**
      * 创建优惠券批次(含规则)
      */
@@ -42,6 +45,8 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
     @Transactional
     public boolean createBatch(PromotionCouponBatch batch, PromotionCouponRule rule)
     {
+        // 校验:优惠幅度限制(平台开关控制,超上限拒绝)
+        discountLimitChecker.checkCouponBatch(batch, rule);
         Date now = new Date();
         batch.setRemainCount(batch.getTotalCount());
         batch.setReceivedCount(0);
@@ -72,6 +77,8 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
     @Transactional
     public boolean createBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules)
     {
+        // 校验:优惠幅度限制(平台开关控制,超上限拒绝)
+        discountLimitChecker.checkCouponBatch(batch, rules);
         Date now = new Date();
         batch.setRemainCount(batch.getTotalCount());
         batch.setReceivedCount(0);
@@ -172,7 +179,12 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
         }
         else
         {
-            // 无人领取,更新批次并替换规则
+            // 无人领取,更新批次并替换规则;替换前校验新规则优惠幅度(已被领取且规则未变的路径不触发,存量豁免)
+            if (batch.getCouponType() == null)
+            {
+                batch.setCouponType(existing.getCouponType());
+            }
+            discountLimitChecker.checkCouponBatch(batch, rule);
             promotionCouponBatchMapper.updateById(batch);
             if (rule != null)
             {
@@ -223,6 +235,12 @@ public class PromotionCouponBatchServiceImpl extends ServiceImpl<BaseMapper<Prom
         }
         else
         {
+            // 无人领取,替换规则前校验新规则优惠幅度(已被领取且规则未变的路径不触发,存量豁免)
+            if (batch.getCouponType() == null)
+            {
+                batch.setCouponType(existing.getCouponType());
+            }
+            discountLimitChecker.checkCouponBatch(batch, rules);
             promotionCouponBatchMapper.updateById(batch);
             if (rules != null && !rules.isEmpty())
             {

+ 303 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionDiscountLimitChecker.java

@@ -0,0 +1,303 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosFood;
+import com.ruoyi.system.domain.PromotionActivity;
+import com.ruoyi.system.domain.PromotionActivityRule;
+import com.ruoyi.system.domain.PromotionCouponBatch;
+import com.ruoyi.system.domain.PromotionCouponRule;
+import com.ruoyi.system.mapper.PosFoodMapper;
+import com.ruoyi.system.service.ISysConfigService;
+
+/**
+ * 优惠幅度限制校验器(防止恶意竞争,平台开关控制)。
+ *
+ * 为什么只做"创建侧单项校验":订单级的总优惠上限由算价侧预算统一封顶
+ * (见 PromotionCalcServiceImpl),这里只负责在商家新增/修改时拦住
+ * 明显超比例的设置,避免"挂大招牌、结账被砍"的虚假宣传。
+ *
+ * 口径(specs/008-promotion-coupon spec.md FR-024):
+ * - 满减促销每档 / 满减券 / 免配送费券:减免 ≤ 比例 × 门槛
+ * - 折扣促销每商品 / 商品折扣券:折扣率 ≥ 1 − 比例(如20%时最低8折)
+ * - 商品抵用券:减免 ≤ 比例 × 商品价格
+ * - 第二杯半价、新客立减:创建侧不校验(前者结构性封顶25%,后者无门槛无分母、由算价预算管)
+ */
+@Component
+public class PromotionDiscountLimitChecker
+{
+    /** 开关参数:sys_config,true=启用限制 */
+    private static final String CONFIG_ENABLED = "promotion.discount.limit.enabled";
+
+    /** 比例参数:sys_config,单位百分比(20 = 20%) */
+    private static final String CONFIG_RATIO = "promotion.discount.limit.ratio";
+
+    /** 比例缺省值(百分比),参数缺失或非法时兜底,保证开关+参数未配置时限制不生效/不误伤 */
+    private static final BigDecimal DEFAULT_RATIO_PERCENT = new BigDecimal("20");
+
+    private static final BigDecimal HUNDRED = new BigDecimal("100");
+
+    @Autowired
+    private ISysConfigService configService;
+
+    @Autowired
+    private PosFoodMapper posFoodMapper;
+
+    /**
+     * 校验促销活动规则(活动创建/修改时调用)。开关关闭时不做任何校验。
+     */
+    public void checkActivity(PromotionActivity activity, List<PromotionActivityRule> rules)
+    {
+        if (!isEnabled() || activity == null || activity.getType() == null
+                || rules == null || rules.isEmpty())
+        {
+            return;
+        }
+        Integer type = activity.getType();
+        if (type == 1)
+        {
+            checkManjianRules(rules);
+        }
+        else if (type == 2)
+        {
+            checkDiscountRules(rules);
+        }
+        // type=3 第二杯半价 / type=4 新客立减:创建侧不校验
+    }
+
+    /**
+     * 校验优惠券规则(券批次创建/规则被替换时调用)。开关关闭时不做任何校验。
+     */
+    public void checkCouponBatch(PromotionCouponBatch batch, PromotionCouponRule rule)
+    {
+        checkCouponBatch(batch, rule == null ? null : Collections.singletonList(rule));
+    }
+
+    /**
+     * 校验优惠券规则(多商品券)。开关关闭时不做任何校验。
+     */
+    public void checkCouponBatch(PromotionCouponBatch batch, List<PromotionCouponRule> rules)
+    {
+        if (!isEnabled() || batch == null || batch.getCouponType() == null
+                || rules == null || rules.isEmpty())
+        {
+            return;
+        }
+        Integer type = batch.getCouponType();
+        if (type == 1)
+        {
+            checkManjianCoupons(rules);
+        }
+        else if (type == 2)
+        {
+            checkProductCoupons(rules);
+        }
+        else if (type == 3)
+        {
+            checkFreeDeliveryCoupons(rules);
+        }
+    }
+
+    /**
+     * 满减促销:每个档位 减免 ≤ 比例 × 门槛。
+     * 无门槛或无减免的档位没有比例锚点,跳过(算价预算仍会封顶)。
+     */
+    private void checkManjianRules(List<PromotionActivityRule> rules)
+    {
+        BigDecimal percent = getRatioPercent();
+        BigDecimal ratio = percent.movePointLeft(2);
+        for (PromotionActivityRule rule : rules)
+        {
+            if (rule == null || rule.getThreshold() == null || rule.getReduceAmount() == null
+                    || rule.getThreshold().compareTo(BigDecimal.ZERO) <= 0)
+            {
+                continue;
+            }
+            BigDecimal maxReduce = rule.getThreshold().multiply(ratio);
+            if (rule.getReduceAmount().compareTo(maxReduce) > 0)
+            {
+                throw new ServiceException(String.format(
+                        "满减档位(满%s减%s)超过最大优惠幅度%s%%,最多可减%s",
+                        fmt(rule.getThreshold()), fmt(rule.getReduceAmount()),
+                        fmt(percent), fmt(maxReduce)));
+            }
+        }
+    }
+
+    /**
+     * 折扣促销:每个商品折扣率 ≥ 1 − 比例(如20%时最低8折)。
+     */
+    private void checkDiscountRules(List<PromotionActivityRule> rules)
+    {
+        BigDecimal percent = getRatioPercent();
+        BigDecimal floor = BigDecimal.ONE.subtract(percent.movePointLeft(2));
+        for (PromotionActivityRule rule : rules)
+        {
+            if (rule == null || rule.getDiscountRate() == null)
+            {
+                continue;
+            }
+            if (rule.getDiscountRate().compareTo(floor) < 0)
+            {
+                throw new ServiceException(String.format(
+                        "商品折扣(%s折)低于最低限制%s折(最大优惠幅度%s%%)",
+                        zhe(rule.getDiscountRate()), zhe(floor), fmt(percent)));
+            }
+        }
+    }
+
+    /**
+     * 满减券:减免 ≤ 比例 × 使用门槛。
+     * 无门槛满减券没有比例锚点,创建侧放行(算价预算按订单总账封顶)。
+     */
+    private void checkManjianCoupons(List<PromotionCouponRule> rules)
+    {
+        BigDecimal percent = getRatioPercent();
+        BigDecimal ratio = percent.movePointLeft(2);
+        for (PromotionCouponRule rule : rules)
+        {
+            if (rule == null || rule.getAmount() == null
+                    || rule.getThreshold() == null || rule.getThreshold().compareTo(BigDecimal.ZERO) <= 0)
+            {
+                continue;
+            }
+            BigDecimal maxReduce = rule.getThreshold().multiply(ratio);
+            if (rule.getAmount().compareTo(maxReduce) > 0)
+            {
+                throw new ServiceException(String.format(
+                        "优惠券减免金额%s超过最大优惠幅度%s%%(门槛%s下最多减%s)",
+                        fmt(rule.getAmount()), fmt(percent),
+                        fmt(rule.getThreshold()), fmt(maxReduce)));
+            }
+        }
+    }
+
+    /**
+     * 商品券:折扣券 折扣率 ≥ 1 − 比例;抵用券 减免 ≤ 比例 × 商品价格。
+     * 商品不存在/无价格时券在算价侧命中不了任何商品(减0),不构成实际优惠,跳过校验。
+     */
+    private void checkProductCoupons(List<PromotionCouponRule> rules)
+    {
+        BigDecimal percent = getRatioPercent();
+        BigDecimal ratio = percent.movePointLeft(2);
+        BigDecimal floor = BigDecimal.ONE.subtract(ratio);
+        for (PromotionCouponRule rule : rules)
+        {
+            if (rule == null)
+            {
+                continue;
+            }
+            if (rule.getDiscountRate() != null)
+            {
+                if (rule.getDiscountRate().compareTo(floor) < 0)
+                {
+                    throw new ServiceException(String.format(
+                            "商品券折扣(%s折)低于最低限制%s折(最大优惠幅度%s%%)",
+                            zhe(rule.getDiscountRate()), zhe(floor), fmt(percent)));
+                }
+                continue;
+            }
+            if (rule.getAmount() == null || rule.getAmount().compareTo(BigDecimal.ZERO) <= 0
+                    || rule.getProductId() == null)
+            {
+                continue;
+            }
+            PosFood food = posFoodMapper.selectById(rule.getProductId());
+            if (food == null || food.getPrice() == null
+                    || food.getPrice().compareTo(BigDecimal.ZERO) <= 0)
+            {
+                continue;
+            }
+            BigDecimal maxReduce = food.getPrice().multiply(ratio);
+            if (rule.getAmount().compareTo(maxReduce) > 0)
+            {
+                throw new ServiceException(String.format(
+                        "抵用券减免金额%s超过商品(%s, %s元)最大优惠幅度%s%%,最多可减%s",
+                        fmt(rule.getAmount()), rule.getProductId(), fmt(food.getPrice()),
+                        fmt(percent), fmt(maxReduce)));
+            }
+        }
+    }
+
+    /**
+     * 免配送费券:必须设使用门槛,且减免 ≤ 比例 × 门槛。
+     * 无门槛时没有比例锚点,而现有算价把免配送费券当普通减免从餐费里扣,
+     * 无门槛大额券等同无上限满减券,所以这里直接拒绝并提示设门槛。
+     */
+    private void checkFreeDeliveryCoupons(List<PromotionCouponRule> rules)
+    {
+        BigDecimal percent = getRatioPercent();
+        BigDecimal ratio = percent.movePointLeft(2);
+        for (PromotionCouponRule rule : rules)
+        {
+            if (rule == null || rule.getAmount() == null)
+            {
+                continue;
+            }
+            if (rule.getThreshold() == null || rule.getThreshold().compareTo(BigDecimal.ZERO) <= 0)
+            {
+                throw new ServiceException("免配送费券需设置使用门槛,无门槛时无法限定优惠幅度");
+            }
+            BigDecimal maxReduce = rule.getThreshold().multiply(ratio);
+            if (rule.getAmount().compareTo(maxReduce) > 0)
+            {
+                throw new ServiceException(String.format(
+                        "免配送费券减免金额%s超过最大优惠幅度%s%%(门槛%s下最多减%s)",
+                        fmt(rule.getAmount()), fmt(percent),
+                        fmt(rule.getThreshold()), fmt(maxReduce)));
+            }
+        }
+    }
+
+    /**
+     * 开关是否启用。参数未配置(SQL 未执行)视为关闭,行为与现状完全一致。
+     * 算价侧(PromotionCalcServiceImpl)复用同一开关决定预算是否生效。
+     */
+    public boolean isEnabled()
+    {
+        return "true".equalsIgnoreCase(configService.selectConfigByKey(CONFIG_ENABLED));
+    }
+
+    /**
+     * 读取优惠上限比例(百分比)。参数缺失或非法时回退默认20,避免运营改错参数导致校验瘫痪。
+     * 算价侧复用:预算 = 比例 × 订单商品原价合计。
+     */
+    public BigDecimal getRatioPercent()
+    {
+        try
+        {
+            String value = configService.selectConfigByKey(CONFIG_RATIO);
+            if (value != null && !value.trim().isEmpty())
+            {
+                BigDecimal percent = new BigDecimal(value.trim());
+                if (percent.compareTo(BigDecimal.ZERO) > 0 && percent.compareTo(HUNDRED) < 0)
+                {
+                    return percent;
+                }
+            }
+        }
+        catch (NumberFormatException ignored)
+        {
+            // 非法值走默认比例
+        }
+        return DEFAULT_RATIO_PERCENT;
+    }
+
+    /** 金额展示:去掉无意义的尾零(40.00 → 40) */
+    private String fmt(BigDecimal value)
+    {
+        return value.stripTrailingZeros().toPlainString();
+    }
+
+    /** 折扣率转"折"展示:0.80 → 8 */
+    private String zhe(BigDecimal rate)
+    {
+        return rate.multiply(BigDecimal.TEN).stripTrailingZeros().toPlainString();
+    }
+}

+ 29 - 0
specs/008-promotion-coupon/plan.md

@@ -350,3 +350,32 @@ promotion_coupon_batch 1 → N promotion_user_coupon
 |-----------|------------|-------------------------------------|
 | 折扣类型前端分组概念(已废弃→改为逐商品设折扣) | 原设计按折扣率分组管理商品,参考美团后改为逐商品设折扣 | 分组概念增加商家认知负担,美团实际不使用分组 |
 | 4种活动类型共用一个创建对话框 | 商家从同一个入口创建不同类型的活动 | 每种类型独立页面会导致页面冗余 |
+## 需求变更 2026-09-02:优惠幅度 20% 限制(防止恶意竞争)
+
+### 背景
+
+平台要求限制优惠幅度防止恶意竞争:所有促销+优惠券的总优惠不能超过订单的 20%。经多轮口径讨论(见 spec.md Session 2026-09-02),定为"创建侧各自校验 + 算价侧总账预算"双层执行,平台参数开关控制。
+
+### 方案要点
+
+1. **开关**:sys_config 两个参数——promotion.discount.limit.enabled(默认 true)、promotion.discount.limit.ratio(默认 20)。后台"参数管理"页启停,前端零改动。开关关闭时所有新增逻辑不生效,行为与现状完全一致。
+2. **创建侧校验**:新建 PromotionDiscountLimitChecker(ruoyi-system,@Component,读开关+比例):
+   - 活动侧挂在 PromotionActivityServiceImpl.createActivity/updateActivity(写规则前)
+   - 券侧挂在 PromotionCouponBatchServiceImpl.createBatch 两个重载开头,以及 updateBatch 两个重载中"规则实际被替换"的分支(receivedCount==0)——已被领取且规则未变的编辑不触发(存量豁免)
+   - 商品抵用券按 productId 查 PosFoodMapper 取价格
+   - 报错走 ServiceException(与两个 Service 现有校验风格一致),foodie-store 现有错误 toast 直接展示
+3. **算价侧预算**(PromotionCalcServiceImpl):
+   - 预算 = 比例 × originalAmount(商品原价合计,促销前)
+   - 按现有顺序 促销→新客→券 层层封顶:每层实减 = min(该层额度, 剩余预算)(部分抵扣,方案A)
+   - 第二杯半价:路径A胜出且胜出机制为半价时,促销减免不占预算不封顶(路径A计算需标记胜出机制是折扣还是半价)
+   - 互斥券路径:促销+新客清零后,互斥券减免同样封顶
+   - buildAvailableCoupons 按剩余预算算每张券实际可抵额度,抵 0 的标记不可用(原因"本单优惠已达优惠上限")
+   - 明细与 pos_order_promotion 快照写实减金额,封顶项标注"已达优惠上限"
+   - 预览(UserPromotionCalcController)与下单(OrderPromotionHelper)同走 calculate(),天然一致
+4. **不碰**:领券/下单流程结构、数据库表结构、mapper XML、前端页面。仅 updatesql/sql.md 增 2 行 sys_config INSERT。
+
+### 风险与已知取舍
+
+- 启用后存量超标门店的实际减免立刻被压到 20%(目的本身,但商家端会感知"优惠变小")
+- 路径选择仍按封顶前金额对比(封顶后满减路径可能劣于半价路径的极端场景接受,不做二次选择)
+- 免配送费券"按实际配送费封顶"是规格与实现的既有偏差(FR-008a 的 deliveryFee 参数从未实现),本次不修,由创建侧门槛校验+预算封顶兜住

+ 28 - 0
specs/008-promotion-coupon/spec.md

@@ -213,6 +213,18 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - Q: 折扣率允许范围和 UI 控件? → A: 1折~9.9折(10%~99%),步长 0.1,用 -/+ 按钮或直接输入。超出范围前端阻止
 - Q: 用户领券并发超发怎么防止? → A: 数据库原子扣减:`UPDATE remain_count = remain_count - 1, received_count = received_count + 1 WHERE id = #{id} AND remain_count > 0`,affected rows=0 则返回"已领完"
 
+### Session 2026-09-02(优惠幅度 20% 限制,防止恶意竞争)
+
+- Q: 限制范围只包括优惠券吗? → A: 促销活动一起限制。满减/折扣促销、满减券、商品券、免配送费券都受限;第二杯半价豁免(商家无可设金额参数、结构性封顶该商品小计的25%)
+- Q: "总优惠幅度不能大于20%"的执行口径? → A: 双层执行:①创建/修改时各项自身 ≤ 比例 × 各自锚点(满减档位=门槛、折扣=8折下限、商品抵用券=商品价格);②算价时总账预算 = 比例 × 订单商品原价,按 促销→新客立减→券 的顺序层层封顶。不做创建时跨活动叠加预算(曾评估"同店叠加总账"方案,因门槛交点计算复杂且会误拦小券组合而放弃,改由算价侧兜底)
+- Q: 剩余预算不够抵券面额时怎么处理? → A: 部分抵扣(实减 = min(面额, 剩余预算)),不是整张作废——规则统一、能把20%空间用满
+- Q: 第二杯半价计入算价预算吗? → A: 不占预算也不被封顶,否则其本身的25%会被砍到20%等于变相废掉该功能
+- Q: 免配送费券怎么处理? → A: 创建时必须有使用门槛且减免 ≤ 比例×门槛(无门槛拒绝并提示设门槛);算价时计入预算(现有实现免配送费券与满减券同路径、从餐费里减,计入可顺带堵存量离谱券)
+- Q: 新客立减为什么创建时不校验? → A: 只有减免额没有门槛、创建时无分母;由算价预算统一封顶(实减 = min(设置额, 剩余预算))
+- Q: 开关怎么做? → A: sys_config 参数 promotion.discount.limit.enabled(开关)/ promotion.discount.limit.ratio(比例%,默认20),后台"参数管理"页启停,前端零改动;关闭=行为与现状完全一致
+- Q: 存量怎么处理? → A: 创建侧不追溯(已被领取且规则未变的编辑不受影响,仅规则实际被替换时校验新值);算价侧存量自动被预算封顶
+- Q: 互斥券路径怎么算? → A: 互斥券取消促销+新客后,其自身减免同样受预算封顶
+
 ## Requirements
 
 ### Functional Requirements
@@ -258,6 +270,21 @@ foodie 系统目前缺少商家端的营销工具。商家无法设置满减、
 - **FR-021**: 互斥券 MUST 不能与满减/折扣/第二份半价叠加
 - **FR-022**: 一个订单最多使用 1个促销 + 1张满减券。商品券对应的商品如正在参加折扣/第二份半价活动,该券不可用于该商品
 
+#### 优惠幅度限制(2026-09-02 追加,平台开关控制)
+
+- **FR-023**: 平台 MUST 提供优惠幅度限制开关:sys_config 参数 promotion.discount.limit.enabled(true/false)与 promotion.discount.limit.ratio(百分比,默认20)。关闭时系统行为与现状完全一致
+- **FR-024**: 开关启用时,商家新增/修改以下内容 MUST 被校验(超限拒绝,ServiceException 指明具体档位/券):
+  - 满减促销:每个档位 减免 ≤ 比例 × 门槛
+  - 折扣促销:每个商品折扣率 ≥ (1 − 比例),如20%时最低8折
+  - 满减券:减免 ≤ 比例 × 使用门槛
+  - 商品折扣券:折扣率 ≥ (1 − 比例)
+  - 商品抵用券:减免 ≤ 比例 × 商品价格
+  - 免配送费券:必须设使用门槛,且减免 ≤ 比例 × 门槛
+  - 第二杯半价、新客立减:创建时不校验
+- **FR-025**: 开关启用时,算价 MUST 执行总优惠预算:预算 = 比例 × 订单商品原价合计,按 促销(三选一)→ 新客立减 → 优惠券 顺序层层封顶,每层实减 = min(该层优惠额度, 剩余预算)(部分抵扣);第二杯半价胜出的促销减免不占预算也不封顶;免配送费券计入预算;互斥券路径同样受预算约束;实付最低兜底沿用现状
+- **FR-026**: 结算页可用券列表 MUST 按剩余预算反映实际可抵额度,可抵为 0 的券标记不可用(原因"本单优惠已达优惠上限")
+- **FR-027**: 算价明细与 pos_order_promotion 快照 MUST 反映预算封顶后的实减金额,被封顶的项标注"已达优惠上限";预览与下单走同一算价入口,金额一致
+
 ### Key Entities
 
 - **促销活动 (promotion_activity)**: 商家创建的促销规则,包含类型(满减/折扣/第二份半价/新客立减)、状态、时间范围
@@ -455,6 +482,7 @@ CREATE TABLE pos_order_promotion (
 - `pos_order_promotion`(订单优惠明细表):下单时快照记录优惠明细,单表扁平设计,优惠类型级别,不做 SKU 分摊。本期建表并实现写入逻辑
 - 现有 `pos_order` 表的优惠汇总字段(`mdSalesReduction`、`mdDiscountAmount` 等)保留不动,`pos_order_promotion` 明细表作为补充
 - 平台券不在本需求范围内,只做商家级促销和商家券
+- 优惠幅度限制(2026-09-02):开关默认值在 SQL 中为启用(true),上线前可在 updatesql/sql.md 调整;创建侧校验存量不追溯,算价侧预算对存量自动生效
 - SQL 变更写入 `updatesql/sql.md`,由开发者手动执行
 
 ## 参考资源

+ 34 - 2
specs/008-promotion-coupon/tasks.md

@@ -307,6 +307,36 @@
 
 ---
 
+## Phase 5.10: 优惠幅度 20% 限制 (2026-09-02 需求变更,防恶意竞争)
+
+**Purpose**: 平台开关控制的双层限制——创建/修改时各项自身 ≤ 20%(比例可配),算价时总优惠预算层层封顶
+
+**Goal**: 启用开关后,商家无法创建超比例的促销/优惠券;任何订单实际总优惠 ≤ 比例 × 商品原价;关闭开关行为与现状完全一致
+
+**详细口径**: 见 spec.md Session 2026-09-02 与 FR-023~FR-027;技术方案见 plan.md「需求变更 2026-09-02」章节
+
+### 数据库
+
+- [x] T064 在 `updatesql/sql.md` 追加 2 条 sys_config INSERT:promotion.discount.limit.enabled(默认 true)、promotion.discount.limit.ratio(默认 20),带日期用途注释,提醒开发者手动执行
+
+### 创建侧校验
+
+- [x] T065 新建 `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionDiscountLimitChecker.java`(@Component)— 读 sys_config 开关+比例;checkActivity(activity, rules):满减每档 reduceAmount ≤ 比例×threshold、折扣每商品 discountRate ≥ 1−比例、type 3(第二杯半价)/type 4(新客立减)跳过;checkCouponBatch(batch, rule 或 rules):满减券/免配送费券 amount ≤ 比例×threshold(免配送费券 threshold 为空先拒绝提示设门槛)、商品折扣券 discountRate ≥ 1−比例、商品抵用券 amount ≤ 比例×PosFood.price(PosFoodMapper 查价,多商品券逐条查);超限抛 ServiceException(中文、指明具体档位/券,风格与两 Service 现有校验一致)
+- [x] T066 挂接校验:`PromotionActivityServiceImpl` createActivity/updateActivity 写规则前调用 checkActivity;`PromotionCouponBatchServiceImpl` createBatch 两个重载开头 + updateBatch 两个重载中"规则实际被替换"分支(receivedCount==0 的 else 分支)调用 checkCouponBatch——已被领取且规则未变的编辑不触发(存量豁免)
+
+### 算价侧预算
+
+- [x] T067 `PromotionCalcServiceImpl.calculate` 加预算累积器 — 开关关闭时预算视为无限;预算 = 比例 × originalAmount;路径A计算标记胜出机制(折扣 vs 第二杯半价);促销实减 = min(促销减免, 剩余预算)(半价胜出时豁免:不占预算不封顶)→ 新客实减 = min(newCustomerReduce, 剩余) → 券实减 = min(couponReduce, 剩余);互斥券分支:促销/新客清零、互斥券自身同样封顶;afterPromotion/finalAmount/明细全部按实减值重算;实付最低兜底沿用现状
+- [x] T068 `buildAvailableCoupons` 按剩余预算计算每张候选券实际可抵额度(min(calcCouponReduce, 剩余)),可抵 0 的置为不可用并给原因"本单优惠已达优惠上限";算价明细与 pos_order_promotion 快照反映封顶后实减金额,被封顶项标注上限提示
+
+### 验证
+
+- [x] T069 JDK21 编译通过 + review 子代理逐点核对:6 个挂接点、半价豁免分支、互斥券路径、预算对明细/快照一致性、开关关闭时与现状逐行为一致(含下架/删除等旁路不受影响)
+
+**Checkpoint**: 开关启用后创建侧拦截超比例设置、算价侧总优惠 ≤ 比例×商品原价;关闭开关全链路回到现状
+
+---
+
 ## Phase 6: Polish & 验证
 
 **Purpose**: 端到端验证
@@ -328,6 +358,7 @@
 - **Phase 4 (US2 优惠券)**: 依赖 Phase 2,可与 Phase 3 并行
 - **Phase 5 (Menu/i18n)**: 依赖 Phase 3 + Phase 4 的页面文件存在
 - **Phase 5.5 (用户端接口)**: 依赖 Phase 2 的实体/Mapper + Phase 3 的促销 Activity Service + Phase 4 的优惠券 Service
+- **Phase 5.10 (优惠幅度限制)**: 依赖 Phase 3/4 的两个 Service 与 Phase 5.5 的算价 Service
 - **Phase 6 (验证)**: 依赖全部完成(包括 Phase 5.9 折扣改动)
 
 ### Parallel Opportunities
@@ -347,8 +378,9 @@ T001 → T002(手动)
   → T018-T023 (Phase 3, 促销活动) + T024-T029 (Phase 4, 优惠券) [可并行]
   → T030-T035 (Phase 5, 菜单路由i18n)
   → T039-T045 (Phase 5.5, 用户端接口)
-  → T061-T063 (Phase 5.9, 折扣商品改为逐商品设折扣) ← 当前待实施
-  → T036-T038 (Phase 6, 验证)
+  → T061-T063 (Phase 5.9, 折扣商品改为逐商品设折扣)
+  → T064-T069 (Phase 5.10, 优惠幅度20%限制)
+  → T036-T038 (Phase 6, 验证) ← 当前待实施
 ```
 
 ---

部分文件因文件數量過多而無法顯示