# Feature Specification: 商家促销 + 优惠券系统 **Feature Branch**: `008-promotion-coupon` **Created**: 2026-05-29 **Status**: Draft **Input**: 参考美团外卖商家端营销活动机制,为 foodie 系统实现促销和优惠券功能 ## 背景 foodie 系统目前缺少商家端的营销工具。商家无法设置满减、折扣等促销活动,也无法发放优惠券。需要参考美团外卖的机制,实现一套适合 foodie 的促销 + 优惠券系统。 ### 旧代码处理 系统中已有旧的促销/优惠券代码,**全部废弃不使用,不修改**: | 旧代码 | 位置 | 说明 | |--------|------|------| | `SalesPromotion.java` | ruoyi-system/domain | 旧促销活动实体,表 `sales_promotion` | | `VipQuanyi.java` | ruoyi-system/domain | 旧优惠券模板,表 `vip_quanyi` | | `VipUserQuanyi.java` | ruoyi-system/domain | 旧用户券,表 `vip_user_quanyi` | | `SalesPromotionController` | ruoyi-admin/app/promotion | 平台管理端促销接口 | | `ShSalesPromotionController` | ruoyi-admin/app/mendian | 商家端促销接口 | | `VipUserQuanyiController` | ruoyi-admin/app/user | 用户端优惠券接口 | **原则:所有新功能新建文件、新表、新接口,不动旧代码。** ### 新表命名规则 所有新建数据库表使用 `promotion_` 前缀,与旧表区分: | 新表名 | 说明 | |--------|------| | `promotion_activity` | 促销活动表(替代旧 `sales_promotion`) | | `promotion_activity_rule` | 促销规则明细表 | | `promotion_coupon_batch` | 券批次表(替代旧 `vip_quanyi`) | | `promotion_coupon_rule` | 券规则表 | | `promotion_user_coupon` | 用户券表(替代旧 `vip_user_quanyi`) | ### 工作范围 只有两部分: 1. **后端 API**(Java/Spring Boot)— 新建 Entity、Mapper、Service、Controller,包含商家端管理接口和用户端接口(领券、优惠券查询、算价计算) 2. **商家 PC 端前端**(foodie-store Vue.js)— 促销管理页面 + 优惠券管理页面 用户端(小程序)的前端界面不在本 spec 范围内,后续单独做。但后端提供的用户端接口(FR-011~022)在本期实施。 ### 美团商家营销活动分类 美团外卖商家端有16种营销活动,分为三类: - **促销类**(直接影响价格):满减、折扣商品、第二份半价、新客立减、减配送费、满减运费、爆品 - **优惠券类**(用户领券后使用):店内领券、店外发券、下单返券、集点返券、收藏有礼、售卖代金券 - **其他营销**:满赠、买赠、超值换购、好友助力、美团会员红包 ### foodie 范围裁剪 本需求只实现以下功能: **现有促销(4种)**:满减、折扣商品(逐商品设折扣)、第二份半价、新客立减 **2026-10-19 追加(需求已确认,功能未开始)**:满额免运,在促销活动中配置,用户满足门槛自动享受,详见 FR-028~FR-036。本轮交付后端和商家 PC;App 代码不动,代码实现交由 Claude Code,直接使用现有分支。 **不做**:爆品、买赠、无门槛减配送费、阶梯减配送费、好友助力等;满额免运按 2026-10-19 追加规则纳入。 **优惠券**:只做"店内领券"一种发放方式 **不做**:店外发券、下单返券、集点返券、收藏有礼、售卖代金券 ### 促销的作用对象 促销不是都作用于同一个价格层级: | 层级 | 作用对象 | 包含的促销类型 | |------|---------|-------------| | 订单级 | 订单总价 | 满减、新客立减 | | 商品级 | 指定商品单价 | 折扣商品、第二份半价 | | 配送费级(2026-10-19 追加,待实现) | 客户应付配送费 | 满额免运;商家承担配送费,骑手按原规则结算 | ### 互斥规则 参考美团做法,用户下单时系统自动计算两条路径,选最优惠的: - **满减、折扣商品、第二份半价 → 三选一**,系统算两遍自动选最优 - 路径A(走折扣/第二份半价):系统分别计算「折扣」和「第二份半价」各一遍,取两者中更优的作为路径A结果。折扣商品按折扣价,第二份半价商品按半价规则,非活动商品原价 - 路径B(走满减):所有商品恢复原价,匹配满减最高可达档位,再减满减金额 - **新客立减** → 可与以上任意一种叠加 - **互斥在下单时判断**,不限制商家创建活动 ### 叠加计算顺序 ``` 商品原价 → 促销(满减/折扣/第二份半价,三选一)→ 商家满减券 = 实付金额 商品券单独作用在指定商品上,不与满减券叠加 一个订单最多:1个促销 + 1张满减券 ``` **2026-10-19 追加**:命中满额免运时,先全额预留免运费用,再按上述既有商品优惠顺序分配剩余预算;满额免运不占商品促销三选一名额。FR-030 的预算优先规则覆盖本节既有顺序中涉及预算分配的部分,功能尚未实现。 **同享券门槛基准**:满减券的 threshold(使用门槛)基于促销后的金额判断(如促销后¥33,券要求满30,¥33≥¥30 可用)。 **商品券与促销互斥规则**:商品券对应的商品如果正在参加折扣或第二份半价活动,该商品券不可用于该商品(放入不可用券列表),避免折上折。 ## User Scenarios & Testing ### User Story 1 - 商家创建促销活动 (Priority: P1) 商家在后台创建促销活动(满减/折扣/第二份半价/新客立减),活动生效后用户下单时自动享受优惠。未开始的活动可以修改和删除,参考美团商家端交互。 **Why this priority**: 促销是商家最核心的营销需求,没有促销就没有优惠,后续优惠券也无法叠加使用。 **Independent Test**: 商家创建一个满减活动(满20减5),用户下单满20元后自动减5元,订单金额正确。 **操作权限矩阵(参考美团)**: | 活动状态 | 编辑 | 删除 | 结束 | |----------|------|------|------| | 未开始(0) | ✅ | ✅ | ❌ | | 进行中(1) | ❌ | ❌ | ✅ | | 已结束(2) | ❌ | ❌ | ❌ | **Acceptance Scenarios**: 1. **Given** 商家未创建任何促销活动, **When** 商家创建满减活动(满20减5 / 满40减12), **Then** 活动列表显示进行中,用户端店铺显示满减标签 2. **Given** 商家已创建折扣活动(宫保鸡丁7折、麻婆豆腐8折), **When** 用户下单宫保鸡丁(¥20) + 麻婆豆腐(¥18), **Then** 折扣价 ¥14 + ¥14.4 = ¥28.4 3. **Given** 商家同时有满减(满40减12)和折扣(宫保鸡丁7折¥14), **When** 用户下单宫保鸡丁(¥20) + 鸡腿饭(¥25) = ¥45, **Then** 系统算两遍:走折扣¥39 vs 走满减¥33,自动选满减 4. **Given** 商家创建新客立减3元 + 满减(满40减12), **When** 新用户下单¥45, **Then** 先减满减12 = ¥33,再减新客立减3 = ¥30 5. **Given** 折扣活动采用逐商品设折扣, **When** 商家选择可乐设5折、宫保鸡丁设7折、麻婆豆腐设8折, **Then** 可乐¥6×0.5=¥3,宫保鸡丁¥20×0.7=¥14,麻婆豆腐¥18×0.8=¥14.4 --- ### User Story 2 - 商家创建优惠券 (Priority: P2) 商家创建优惠券(满减券/商品券/免配送费券),设置库存和有效期。用户进店时看到领券区,领取后下单使用。未开始和进行中的优惠券可以修改,未开始的可以删除。 **Why this priority**: 优惠券在促销基础上进一步促进复购和转化,但依赖促销系统先完成。 **Independent Test**: 商家创建"满30减5"优惠券100张,用户进店领取,下单满30元时选择使用,实付减5元。 **操作权限矩阵(参考美团)**: | 券状态 | 编辑 | 删除 | 下架 | |--------|------|------|------| | 未开始(0) | ✅ | ✅ | ❌ | | 进行中(1) | ✅ | ❌ | ✅ | | 已结束(2) | ❌ | ❌ | ❌ | | 已下架(3) | ❌ | ❌ | ❌ | **Acceptance Scenarios**: 1. **Given** 商家创建满减券(同享券,满30减5,100张), **When** 用户进店看到领券区并领取, **Then** 用户"我的优惠券"列表显示该券,状态为未使用 2. **Given** 用户已领取满减券(满30减5)且商家有满减活动(满40减12), **When** 用户下单¥45, **Then** 先减满减12=¥33,再减券5=¥28(同享券可叠加) 3. **Given** 用户已领取互斥券(满30减5), **When** 用户下单且订单有满减活动, **Then** 互斥券不可与满减叠加,用户只能二选一 4. **Given** 商家创建商品折扣券(宫保鸡丁5折), **When** 用户下单宫保鸡丁(¥20)并使用该券, **Then** 宫保鸡丁按¥10计算 5. **Given** 优惠券库存为100张,已领取100张, **When** 新用户进店, **Then** 领券区显示"已领完" --- ### User Story 3 - 用户下单时优惠计算和展示 (Priority: P3) 用户在结算页看到优惠明细,系统自动选择最优方案,用户可手动切换。 **Why this priority**: 结算页的优惠展示和计算是用户最终看到的结果,需要在促销和优惠券都完成后实现。 **Independent Test**: 用户选好商品进入结算页,页面展示优惠明细(满减/折扣自动选最优 + 可用券列表),选择券后实付金额实时更新。 **Acceptance Scenarios**: 1. **Given** 商家有满减(满40减12)和折扣(宫保鸡丁7折), **When** 用户进入结算页, **Then** 系统展示两条路径对比,默认选最优 2. **Given** 用户有3张可用券, **When** 用户点击优惠券行, **Then** 弹出券列表,可用券和不可用券分Tab展示,选中券后金额重算 3. **Given** 用户选了互斥券, **When** 订单同时有满减活动, **Then** 满减自动取消,金额重新计算 --- ### Edge Cases - 商家创建了满减活动后又创建折扣活动,两个同时进行中,用户下单时怎么处理?→ 系统自动算两遍选最优,不限制商家创建 - 用户领了券但下单时未达到门槛(券要求满30但订单只有25)→ 券在"不可用"Tab显示,提示"未达到¥30" - 用户领券后商家修改/下架了该券批次 → 已领取的券仍可使用(按领取时的规则),但不能再领取新券 - 满减活动设了3个档位,订单金额正好在两个档位之间 → 命中低档位(如¥39命中满20减5而非满40减12) - 第二份半价商品用户只买了1件 → 不享受优惠,按原价 - 第二份半价商品用户买了多件 → 每2件为一组,每组第2件半价(如买3件:第1、3件全价,第2件半价;买4件:第1、3件全价,第2、4件半价) - 商家结束促销活动时,正在进行中的订单 → 不影响已下单的订单,只影响新订单 - 优惠券过期 → 过期券自动标记为已过期,不可使用 - 折扣活动中同一商品重复添加 → 前端限制同一商品只能添加一次,重复选择时提示并忽略 - 多用户同时领取同一优惠券(库存仅剩1张)→ 数据库原子扣减 `WHERE remain_count > 0`,只有一人成功,其余返回"已领完" ### 算价精度规则 - 所有金额计算使用 `BigDecimal`,中间过程不截断,最终结果 `ROUND_HALF_UP`(四舍五入)保留2位小数 - 最终实付金额最低为 ¥0.01,不出现零或负数 - 两条路径金额完全相等时,默认选路径B(满减路径),因满减对商家毛利更优 - 无促销活动时两条路径结果相同,直接返回原价 - 优惠券有效期计算:`expire_time = 领取日期 + valid_days 天后的 23:59:59`(如6月1日领券,valid_days=7,则6月8日 23:59:59 过期) - 用户券过期判断:查询时实时判断 `expire_time < NOW()` 则标记为已过期(status→2),不依赖定时任务 - 同一门店同一促销类型只允许一个进行中的活动,创建时校验 - 结束活动不可逆,已结束活动不可重新激活 - 商品价格由后端根据 productId 从数据库查询,不信任前端传入的价格 ### User Story 4 - 商家 PC 配置满额免运,后端计算与结算一致 (Priority: P1) 商家在现有“营销管理 → 促销活动”中创建满额免运,设置门槛、开始日期和结束日期,并按现有活动状态查看、编辑、删除或结束。这里增加的是自动生效的促销类型,不另外创建券批次、领券入口或新菜单。App 页面及 App 代码均不在本轮范围。 **Independent Test**: 商家 PC 创建原价满100免运的活动;后端完整报价和创建订单使用同一金额规则。商品原价100、配送费30、预算20时,客户付100,商家承担30,骑手配送毛收入基数仍为30。实际支付、退款、发票及订单优惠快照按实付核对。 **Acceptance Scenarios**: 1. **Given** 商家有权限管理该门店,**When** 创建门槛100的满额免运活动,**Then** 活动列表、详情、编辑回填和结束入口完整,客户端无需领券。 2. **Given** 原价100、配送费30、优惠预算20,**When** 通过新报价契约计算,**Then** 全免30,其余受限优惠为0;原价99则不触发免运。 3. **Given** 原价100、配送费10、预算20且商品促销减8,**When** 还有新客立减或优惠券,**Then** 后续受限优惠最多共减2,客户实付90,配送费优惠不再从餐费扣一次。 4. **Given** 免运已创建订单快照,**When** 活动结束、跨越活动日期或切换支付方式,**Then** 已创建订单金额和免运承担不重新计算。 5. **Given** 旧 App 请求未接入完整配送报价,**When** 使用既有商品算价接口,**Then** 保留旧字段语义和金额行为;不得将本轮商家 PC 和后端交付描述为 App 结算展示已完成。 ## Clarifications ### Session 2026-06-01 - Q: 折扣活动中同一商品重复添加怎么处理? → A: 前端限制同一商品只能添加一次,重复选择时提示并忽略(改为逐商品设折扣,不再有折扣区分组概念) - Q: 折扣商品采用「折扣区分组」还是「逐商品设折扣」? → A: 改为逐商品设折扣(参考美团),每个商品独立设置折扣率,不再使用折扣区/折扣档位分组。调研记录见 `docs/research-meituan-discount-product.md` - Q: 新客立减的"新客"定义? → A: 该门店首次下单的用户(该门店无已完成订单记录) - Q: 算价接口前端传入价格有篡改风险? → A: 算价接口只传productId+quantity,后端根据productId查实际价格 - Q: 第二份半价属于路径A还是独立路径? → A: 作为路径A的变体,系统分别算折扣和第二份半价,取更优作为路径A结果 - Q: 同享券门槛基于促销前还是促销后金额? → A: 基于促销后金额 - Q: 商品在折扣活动中,商品券能否用于该商品? → A: 不可用,放入不可用券列表 - Q: 金额精度和舍入规则? → A: BigDecimal计算,ROUND_HALF_UP保留2位小数,最低¥0.01 - Q: 免配送费券如何获取配送费金额? → A: 算价接口新增可选参数deliveryFee,前端传入当前配送费,后端计算min(coupon.amount, deliveryFee)作为减免额 - Q: 删除促销活动是物理删除还是软删除? → A: 物理删除(DELETE),因为未开始活动不会有订单引用,安全且简单 - Q: 编辑进行中的优惠券时,已领取用户的券规则是否跟随变更? → A: 只影响新领取的券,已领取的券保持原规则不变(快照机制) - Q: 免配送费券在叠加计算顺序中的位置? → A: 在促销+满减券之后独立叠加:实付 = 促销后金额 - 券减免 - min(配送费券.amount, deliveryFee) - Q: 优惠券删除条件? → A: 仅当 status=0 且 receivedCount=0 时允许删除(双重校验,防御性编程) ### Session 2026-06-02 - 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: 互斥券取消促销+新客后,其自身减免同样受预算封顶 ### Session 2026-10-19(满额免运:已确认规则,待实现) - Q: 满额免运放促销活动还是优惠券? → A: 在现有促销活动中增加满额免运,满足条件自动享受,不要求领券;配送费由商家承担,骑手取餐时从商家取得。 - Q: “满100”的门槛按原价还是优惠后金额? → A: 按该门店订单的商品原价合计判断,不扣除商品促销、新客立减或优惠券,不将配送费计入门槛;100 为示例,由商家设置满额门槛。 - Q: 满额免运的优先级? → A: 先全额免除客户配送费,再按商品促销(三选一)→ 新客立减 → 优惠券的既有顺序计算其余受预算限制的优惠。 - Q: 免运费用超过总优惠预算怎么办? → A: 优先保证全免,允许免运金额本身超过预算。其他受预算限制的优惠只能使用 `max(0, 总预算 - 免运金额)`;免运已用完或超过预算时,这些优惠不再追加,不能把全免改成部分减运费。 - Q: 总优惠预算开关关闭或已有预算豁免怎么处理? → A: 关闭时不限制后续优惠预算;现有第二份半价胜出时“不占预算、不封顶”的例外继续保留。本次“其他优惠不再追加”仅指受预算限制的优惠,不撤销原有豁免。 - Q: 骑手配送费是否归零? → A: 仅客户应付配送费归零;商家承担金额、原配送费和客户实付必须区分,骑手配送费仍按原规则结算,避免影响骑手收入或重复结算。 - Q: 本轮实施范围与执行方式? → A: 后端和商家 PC 一起规划;App 端代码不动。继续更新现有 008 spec-kit 文档,不创建分支;Claude Code 负责后续代码实现。本轮由 Codex 完成规划,不编写生产代码。 ### Session 2026-10-19 追加(报价入口统一,已确认) - Q: 自取/堂食要不要也切到 /deliveryQuote? → A: 要。调整后 /deliveryQuote 成为全部用餐类型的唯一报价入口:自取/堂食不要求收货地址,固定返回 R/F/D=0、A=G(即 finalAmount)。 - Q: 商家自配送(029)呢? → A: 也调用。029 已确认"用户侧运费计价规则不变",即与平台骑手同一套路线跳表口径;自配送单照常返回运费 R,但满额免运不评估(F=0、D=R),促销/优惠券/预算规则与其他场景完全一致;运费结算归属仍由订单 selfDelivery=1 快照在结算侧区分。 - Q: 旧 /calculate 废弃吗? → A: 暂不。线上旧版本 App 仍在调用,契约承诺旧行为保留(FR-034);待 App 全量切换并完成版本换代后废弃。App 结算页目标态为单一接口、只读 payableAmount。 ### Session 2026-10-19 追加二(商品规格数据源统一,联调发现) - Q: 报价报“商品无效”但商品正常在售? → A: 联调发现两处问题。其一,报价侧 to_examine 语义写反(菜单口径 '1'=审核通过可售,实现误抄 036 写成 '1' 即拒绝),已修正并补用例;036 报价服务存在同款反写,恢复开发时须一并修。其二,App 菜单的规格回退链路(关联为空读 pos_food.food_sku JSON)与报价校验(只读关联表)不同源,9078 等存量商品规格只在 food_sku。 - Q: 规格数据源怎么统一? → A: **food_spec_relation 为唯一规格源**:App 菜单/详情不再回退读 food_sku;报价与版本2订单 food JSON 的规格加价共用同一方法(resolveSpecSurcharge);存量 food_sku 数据用 updatesql/backfill_food_spec_relation.py 回填关联表(幂等,价格以 JSON 内嵌为准建缺失行)。**执行顺序:各环境先跑回填脚本,再部署去回退版本**,否则存量商品规格会从 App 消失。 - Q: 报错可定位性? → A: invalidProduct/invalidSpec 文案带商品ID(如“商品[9115]无效或不属于该门店”),六语言同步。 **验收示例**(预算开关开启、比例20%,仅列受预算限制的优惠): | 商品原价 | 配送费 | 总预算 | 免运金额 | 其余优惠最多可用预算 | |----------|--------|--------|----------|----------------------| | 100 | 30 | 20 | 30(全免) | 0 | | 100 | 20 | 20 | 20(全免) | 0 | | 100 | 10 | 20 | 10(全免) | 10 | 商品原价未达到门槛时,不触发满额免运,沿用既有优惠规则。上述规则为本项目用户确认的业务选择,不作为美团现行规则的描述。 ## Requirements ### Functional Requirements #### 促销活动管理(商家端) - **FR-001**: 商家 MUST 能创建满减活动,支持设置多个档位(如满20减5 / 满40减12 / 满60减20) - **FR-002**: 商家 MUST 能创建折扣商品活动,采用**逐商品设折扣**方式(参考美团):从商品列表勾选参与的商品,每个商品单独设置折扣率。一个活动内不同商品可以有不同折扣率(如宫保鸡丁7折、麻婆豆腐8折、可乐5折)。不使用「折扣区/折扣档位」分组概念。折扣率范围:10%~99%(即1折~9.9折),步长 0.1,前端用 -/+ 按钮或直接输入 - **FR-003**: 商家 MUST 能创建第二份半价活动,从商品列表勾选参加的商品 - **FR-004**: 商家 MUST 能创建新客立减活动,设置固定减免金额。「新客」定义为:该门店首次下单的用户(该门店无已完成订单记录) - **FR-005**: 商家 MUST 能查看活动列表(进行中/未开始/已结束),能结束进行中的活动 - **FR-005a**: 商家 MUST 能修改未开始的活动(编辑活动名称、时间、规则),复用创建对话框回填数据 - **FR-005b**: 商家 MUST 能删除未开始的活动(物理删除活动及关联规则),删除前需二次确认 - **FR-006**: 系统 MUST 展示互斥规则提示:满减、折扣、第二份半价三选一,新客立减可叠加 #### 优惠券管理(商家端) - **FR-007**: 商家 MUST 能创建满减券,设置同享/互斥属性、使用门槛、减免金额、发放总量、有效天数 - **FR-008**: 商家 MUST 能创建商品券(折扣券/抵用券),关联指定商品,设置折扣率。商品券优惠作用于该商品的每一件(如买3个宫保鸡丁,5折券则全部按5折计算) - **FR-008a**: 商家 MUST 能创建免配送费券,设置使用门槛(满X元可用)、减免金额(固定减免配送费Y元,减免金额≥实际配送费时等同于免配送费)、同享/互斥属性、发放总量、有效天数。算价接口新增可选参数 `deliveryFee`,前端传入当前配送费,后端计算 `min(coupon.amount, deliveryFee)` 作为实际减免额 - **FR-009**: 商家 MUST 能查看优惠券列表,展示领取/使用情况、库存,能下架进行中的优惠券 - **FR-009a**: 商家 MUST 能修改未开始/进行中的优惠券(编辑名称、时间、规则等),复用创建对话框回填数据 - **FR-009b**: 商家 MUST 能删除未开始的优惠券(物理删除券批次+规则),仅当 status=0 且 receivedCount=0 时允许删除,删除前需二次确认 - **FR-010**: 优惠券发放方式只有一种:店内领券(用户进店时在领券区领取) #### 用户端接口(后端API本期实施,前端界面[延后]由小程序实施) - **FR-011**: 用户 MUST 能在店铺首页看到领券区 → 后端提供「查询门店可领优惠券列表」接口 - **FR-012**: 用户 MUST 能领取优惠券,每人每批次限领1张 → 后端提供「领取优惠券」接口 - **FR-013**: 用户 MUST 能在"我的优惠券"中查看已领取的券 → 后端提供「查询用户优惠券列表」接口 - **FR-014**: 用户 MUST 能在结算页看到促销优惠明细 → 后端提供「计算订单优惠」接口 - **FR-015**: 用户 MUST 能在结算页选择使用/不使用优惠券 → 由算价接口参数控制 - **FR-016**: 系统 MUST 在结算页自动计算满减和折扣两条路径,默认选最优 → 算价接口返回两条路径对比 #### 算价逻辑(后端Service本期实施,前端展示[延后]由小程序实施) - **FR-017**: 下单时系统 MUST 同时计算两条路径: - 路径A(走折扣):折扣商品按折扣价,非折扣商品原价,再扣券 - 路径B(走满减):所有商品恢复原价,扣满减,再扣券 - **FR-018**: 系统 MUST 默认选择实付金额更低的路径 - **FR-019**: 用户 MUST 能在结算页手动切换路径 → 算价接口支持指定路径参数 - **FR-020**: 同享券 MUST 能与满减/折扣叠加使用。满减券的 threshold 基于促销后金额判断 - **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(该层优惠额度, 剩余预算)(部分抵扣);第二杯半价胜出的促销减免不占预算也不封顶;免配送费券计入预算;互斥券路径同样受预算约束;实付最低兜底沿用现状。2026-10-19 追加:命中满额免运时,先按 FR-030 预留免运费用,再执行本条的其余预算分配 - **FR-026**: 结算页可用券列表 MUST 按剩余预算反映实际可抵额度,可抵为 0 的券标记不可用(原因"本单优惠已达优惠上限") - **FR-027**: 算价明细与 pos_order_promotion 快照 MUST 反映预算封顶后的实减金额,被封顶的项标注"已达优惠上限";预览与下单走同一算价入口,金额一致 #### 满额免运(2026-10-19 追加,需求已确认,功能未开始) - **FR-028**: 商家 MUST 能在促销活动中配置满额免运门槛及活动有效期;符合条件的订单自动享受,无需用户领取优惠券。 - **FR-029**: 免运门槛 MUST 按该门店订单商品原价合计判断(例如原价满100),不扣除商品促销、新客立减或优惠券,不包含配送费;不得因后续商品优惠降低商品应付金额而撤销免运资格。 - **FR-030**: 满额免运 MUST 具有最高预算分配优先级。令 `B` 为按既有比例和取整规则计算的总优惠预算,`F` 为本单免运金额:客户配送费全免 `F`,即使 `F > B` 也不得截断;其余受限优惠按既有顺序使用 `max(0, B - F)`,`F >= B` 时不再追加受限优惠。切换商品促销路径或互斥券路径时不得释放已预留的免运费用;预算开关关闭时不封顶后续优惠,现有第二份半价预算豁免继续保留。满额免运独立于原有商品促销三选一名额。 - **FR-031**: 满额免运成本 MUST 由商家承担,客户应付配送费为0,骑手配送费按原规则结算。算价、下单金额及优惠快照 MUST 区分配送费原金额、商家承担的免运金额与客户实付,不得通过清零骑手配送费或从商品金额重复扣减来实现免运。 - **FR-032**: 本轮 MUST 包含商家 PC 的满额免运创建、编辑回填、详情、列表类型展示和结束入口,使用现有“促销活动”页面及四种语言;MUST NOT 新增必须领券的免运流程、修改 App 源码、改动平台 PC 或继续实施已暂停的 036 多店下单。 - **FR-033**: 新金额流程 MUST 由服务端核实商品/规格、门店归属、数量、收货地址和配送费,预览与创建复用同一能力;支付只用已固化的客户应付额。报价变化必须返回可识别错误要求重新报价,不得仅记日志后接受客户端伪造或过期的优惠金额。 - **FR-034**: 旧算价接口 `finalAmount` MUST 保持商品应付金额含义。完整配送报价及订单创建的新契约见 [contracts/free-delivery.md](./contracts/free-delivery.md);未接入该契约的旧 App 请求保留旧行为,本轮仅完成商家配置和后端可调用能力,App 的完整免运结算展示另行交付。 - **FR-035**: 订单创建后 MUST 固化免运金额和规则快照;活动结束及支付方式切换不得追改已有金额。到付客户待收=客户应付总额;取餐给商家金额=客户应付总额−原配送费,若为负则方向改为商家给骑手其绝对值。在线已付时商家给骑手原配送费;现金交付和账单/钱包不得重复支付同笔配送费。佣金、税仍依既有规则计算。 - **FR-036**: 命中满额免运时免配送费券 MUST 标为不可用,不重复减免、不核销;因预算不足实减为0的普通券同样不核销。新流程的商品优惠汇总不包含免运,发票原有“不含运费”口径应扣除客户实际支付的配送费,退款上限按实际付款,不将商家承担的免运补贴额外退给用户。 #### 满额免运报价入口统一(2026-10-19 追加,需求已确认) - **FR-037**: `POST /app/userPromotionCalc/deliveryQuote` MUST 支持全部用餐类型,成为唯一报价入口:平台骑手外送(orderType=0 且非自配送)评估满额免运;商家自配送订单 MUST 按同一跳表口径返回运费 R 且免运不评估(F=0、D=R、A=G+R);自取/堂食(orderType=1/2)MUST NOT 要求收货地址并固定返回 R/F/D=0、payableAmount=G;促销/优惠券/预算规则全部场景一致。未知 orderType 或门店不存在仍返回 unsupportedOrder。版本2下单仍限外送(type=0,含自配送门店,其免运快照 F=0)。旧 `/calculate` 行为不变,待 App 版本换代后废弃。 - **FR-038**: 商品规格 MUST 以 `food_spec_relation` 为唯一读取源:App 菜单/详情、满额免运报价与版本2订单 food JSON 的规格选项与加价 MUST 同源;MUST NOT 回退读取 `pos_food.food_sku`。存量 food_sku 数据 MUST 先经回填脚本(updatesql/backfill_food_spec_relation.py,幂等)迁入关联表,再部署去回退版本。规格/商品校验错误文案 MUST 携带商品ID 以便定位。 ### Key Entities - **促销活动 (promotion_activity)**: 商家创建的促销规则,包含类型(满减/折扣/第二份半价/新客立减)、状态、时间范围 - **促销规则 (promotion_activity_rule)**: 促销活动的具体规则数据,一个活动对应多条规则(满减档位/折扣商品/第二份半价商品) - **券批次 (promotion_coupon_batch)**: 优惠券模板,包含券名称、类型、库存、有效期 - **券规则 (promotion_coupon_rule)**: 优惠券的使用规则,包含门槛、金额、折扣率、同享/互斥属性 - **用户券 (promotion_user_coupon)**: 用户领取的券实例,绑定用户和门店,记录状态和使用情况 ## Data Model 所有新建表使用 `promotion_` 前缀,与旧表(`sales_promotion`、`vip_quanyi`、`vip_user_quanyi`)完全独立。 ### promotion_activity — 促销活动表 ```sql CREATE TABLE promotion_activity ( id BIGINT AUTO_INCREMENT PRIMARY KEY, store_id BIGINT NOT NULL COMMENT '门店ID', type TINYINT NOT NULL COMMENT '类型: 1=满减 2=折扣 3=第二份半价 4=新客立减', name VARCHAR(100) NOT NULL COMMENT '活动名称', status TINYINT DEFAULT 0 COMMENT '0=未开始 1=进行中 2=已结束', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_store_type (store_id, type) ) COMMENT '促销活动表'; ``` ### promotion_activity_rule — 促销规则明细表 ```sql CREATE TABLE promotion_activity_rule ( id BIGINT AUTO_INCREMENT PRIMARY KEY, activity_id BIGINT NOT NULL COMMENT '关联促销活动', product_id BIGINT DEFAULT NULL COMMENT '商品ID(折扣/第二份半价用)', threshold DECIMAL(10,2) DEFAULT NULL COMMENT '满减门槛(满X元)', reduce_amount DECIMAL(10,2) DEFAULT NULL COMMENT '减免金额(满减/新客立减)', discount_rate DECIMAL(3,2) DEFAULT NULL COMMENT '折扣率(0.70=7折 / 0.50=半价)', min_quantity INT DEFAULT NULL COMMENT '最低数量(第二份半价=2)', INDEX idx_activity (activity_id) ) COMMENT '促销规则明细'; ``` ### promotion_coupon_batch — 券批次表(模板) ```sql CREATE TABLE promotion_coupon_batch ( id BIGINT AUTO_INCREMENT PRIMARY KEY, store_id BIGINT NOT NULL COMMENT '门店ID', name VARCHAR(100) NOT NULL COMMENT '券名称', coupon_type TINYINT NOT NULL COMMENT '1=满减券 2=商品券 3=免配送费券', total_count INT NOT NULL COMMENT '发放总量', remain_count INT NOT NULL COMMENT '剩余数量', received_count INT DEFAULT 0 COMMENT '已领取数量', status TINYINT DEFAULT 0 COMMENT '0=未开始 1=进行中 2=已结束 3=已下架', start_time DATETIME NOT NULL COMMENT '领取开始时间', end_time DATETIME NOT NULL COMMENT '领取结束时间', valid_days INT NOT NULL COMMENT '领取后有效天数', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_store_status (store_id, status) ) COMMENT '券批次表(模板)'; ``` ### promotion_coupon_rule — 券规则表 ```sql CREATE TABLE promotion_coupon_rule ( id BIGINT AUTO_INCREMENT PRIMARY KEY, batch_id BIGINT NOT NULL COMMENT '关联券批次', product_id BIGINT DEFAULT NULL COMMENT '商品ID(商品券用,满减券=NULL)', is_mutex TINYINT DEFAULT 0 COMMENT '0=同享券 1=互斥券(仅满减券使用;商品券此字段为NULL,互斥逻辑由促销活动判断)', threshold DECIMAL(10,2) DEFAULT NULL COMMENT '使用门槛:满减券=促销后订单金额门槛,商品券=该商品小计金额门槛(单价×数量)', amount DECIMAL(10,2) DEFAULT NULL COMMENT '减免金额(满减券/抵用券)', discount_rate DECIMAL(3,2) DEFAULT NULL COMMENT '折扣率(商品折扣券用)', INDEX idx_batch (batch_id) ) COMMENT '券规则'; ``` ### promotion_user_coupon — 用户券表(实例) ```sql CREATE TABLE promotion_user_coupon ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '用户ID', batch_id BIGINT NOT NULL COMMENT '券批次ID', store_id BIGINT NOT NULL COMMENT '门店ID', status TINYINT DEFAULT 0 COMMENT '0=未使用 1=已使用 2=已过期 3=冻结', order_id BIGINT DEFAULT NULL COMMENT '使用的订单ID', receive_time DATETIME NOT NULL COMMENT '领取时间', use_time DATETIME DEFAULT NULL COMMENT '使用时间', expire_time DATETIME NOT NULL COMMENT '过期时间', INDEX idx_user_status (user_id, status), INDEX idx_store (store_id) ) COMMENT '用户领券记录'; ``` ### pos_order_promotion — 订单优惠明细表 下单时快照记录每个订单命中的优惠信息,优惠类型级别(每条记录 = 一种优惠),不做 SKU 级分摊。由算价接口在下单时写入。 ```sql CREATE TABLE pos_order_promotion ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL COMMENT '关联订单ID', promo_type TINYINT NOT NULL COMMENT '优惠类型: 1=促销活动 2=商家优惠券', promo_sub_type TINYINT DEFAULT NULL COMMENT '促销子类型: 1=满减 2=折扣 3=第二份半价 4=新客立减 (promo_type=1时有效)', promo_id BIGINT DEFAULT NULL COMMENT '促销活动ID(promo_type=1) 或 券批次ID(promo_type=2)', user_coupon_id BIGINT DEFAULT NULL COMMENT '用户券ID (仅优惠券 promo_type=2 时有值, 关联 promotion_user_coupon.id)', promo_name VARCHAR(200) NOT NULL COMMENT '快照名称: 如"午市满减(满40减12)"', promo_detail VARCHAR(500) DEFAULT NULL COMMENT '快照详情JSON: 如{"threshold":40,"reduce":12}', reduce_amount DECIMAL(10,2) NOT NULL COMMENT '减免金额', path_summary VARCHAR(500) DEFAULT NULL COMMENT '路径对比摘要, 仅第一条记录有值, 如"满减路径¥33 vs 折扣路径¥39, 选择满减"', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_id) ) COMMENT '订单优惠明细'; ``` #### 字段说明 | 字段 | 作用 | 示例 | |------|------|------| | `promo_type` | 区分是促销还是优惠券 | 1=促销, 2=优惠券 | | `promo_sub_type` | 促销的具体子类型 | 1=满减, 2=折扣, 3=第二份半价, 4=新客立减;优惠券时为 null | | `promo_id` | 关联原始活动或券批次 | promo_type=1 时为 promotion_activity.id;promo_type=2 时为 promotion_coupon_batch.id | | `user_coupon_id` | 关联用户领取的券实例 | promo_type=2 时为 promotion_user_coupon.id;促销时为 null | | `promo_name` | 下单时的快照名称 | "午市满减(满40减12)" | | `promo_detail` | 规则快照 JSON | `{"type":"manjian","threshold":40,"reduce":12}` | | `reduce_amount` | 实际减免金额 | 12.00 | | `path_summary` | 路径对比摘要 | "满减路径¥33 vs 折扣路径¥39, 选择满减",仅第一条记录有值 | #### 示例数据 订单:宫保鸡丁¥20 + 鸡腿饭¥25 = ¥45,商家有满减(满40减12)和折扣(宫保鸡丁7折),用户有满30减5同享券,新用户 | promo_type | promo_sub_type | promo_name | reduce_amount | path_summary | |---|---|---|---|---| | 1(促销) | 1(满减) | 午市满减(满40减12) | 12.00 | 满减路径¥33 vs 折扣路径¥39, 选择满减 | | 2(优惠券) | null | 满30减5(同享券) | 5.00 | null | | 1(促销) | 4(新客立减) | 新客立减 | 3.00 | null | #### 与 pos_order 现有字段的关系 下单时双写: - `pos_order` 的 `mdSalesReduction`、`mdDiscountAmount` 等汇总字段照常写入 - `pos_order_promotion` 写入每条优惠明细(快照) 查询场景: - 订单列表:只看 `pos_order` 汇总字段 - 订单详情/退款:查 `pos_order_promotion` 获取明细 #### 设计决策 - **单表扁平设计**:一张表存储所有优惠明细,每条记录代表一种优惠,一个订单通常 3-5 条记录 - **优惠类型级别**:不做 SKU 级优惠分摊,所有优惠都是商家级别 - **快照机制**:下单时将促销名称、规则、金额拍快照存入,后续促销活动修改不影响已下单的订单 - **路径对比摘要**:`path_summary` 字段记录系统计算的路径对比结果(如满减 vs 折扣),仅存在该订单的第一条明细记录上,其余记录该字段为 null - **pos_order 现有字段保留**:不删除现有的 `mdSalesReduction`、`mdDiscountAmount` 等字段,新表明细作为补充信息 ### 表关系 ``` 促销模块: promotion_activity 1 → N promotion_activity_rule 优惠券模块: promotion_coupon_batch 1 → 1 promotion_coupon_rule promotion_coupon_batch 1 → N promotion_user_coupon 订单优惠: pos_order 1 → N pos_order_promotion pos_order_promotion N → 1 promotion_activity (promo_type=1 时) pos_order_promotion N → 1 promotion_coupon_batch (promo_type=2 时) pos_order_promotion N → 1 promotion_user_coupon (promo_type=2 时) ``` ## Success Criteria ### Measurable Outcomes - **SC-001**: 商家能在3分钟内完成一个满减活动的创建(包括设置3个档位) - **SC-002**: 商家能在3分钟内完成一个折扣活动的创建(选择5个商品 + 逐个设折扣率) - **SC-003**: 商家能在2分钟内完成一张优惠券的创建 - **SC-004**: 用户下单时优惠计算正确,结算页实时展示优惠明细 - **SC-005**: 满100%覆盖互斥规则:满减/折扣/第二份半价三选一,新客立减可叠加 ## Assumptions - **旧代码不动**:`SalesPromotion`、`VipQuanyi`、`VipUserQuanyi` 等旧实体和旧接口全部废弃,新功能完全新建文件 - **新表前缀 `promotion_`**:与旧表(`sales_promotion`、`vip_quanyi`)完全独立 - **工作范围**:后端 API(商家端管理接口 + 用户端接口)+ 商家 PC 端前端(foodie-store) - **用户端前端不在本范围**:小程序端的领券页面、结算页展示后续单独做,但后端接口(FR-011~022)本期实施 - 促销和优惠券都是门店级别(storeId),不是跨店通用 - `pos_order_promotion`(订单优惠明细表):下单时快照记录优惠明细,单表扁平设计,优惠类型级别,不做 SKU 分摊。本期建表并实现写入逻辑 - 现有 `pos_order` 表的优惠汇总字段(`mdSalesReduction`、`mdDiscountAmount` 等)保留不动,`pos_order_promotion` 明细表作为补充 - 平台券不在本需求范围内,只做商家级促销和商家券 - 优惠幅度限制(2026-09-02):开关默认值在 SQL 中为启用(true),上线前可在 updatesql/sql.md 调整;创建侧校验存量不追溯,算价侧预算对存量自动生效 - SQL 变更写入 `updatesql/sql.md`,由开发者手动执行 ## 参考资源 - 美团外卖商家活动创建攻略(知乎) - 美团外卖优惠规则(官方) - 美团外卖平台市场营销规则(rules-center.meituan.com) - 优惠券系统设计(知乎) - 大厂优惠券系统设计(掘金) - Demo文件:`meituan-promotion-types-demo.html`、`merchant-promotion-ui-demo.html`、`meituan-checkout-demo.html` - [美团折扣商品调研报告](../../docs/research-meituan-discount-product.md) — 美团/饿了么折扣商品设置方式对比,逐商品设折扣 vs 折扣区分组 ## 2026-10-19 实施约定与需求来源 **用户已确认**:促销自动生效、原价门槛、免运预算最高优先且允许超预算、商家承担与骑手取餐收取、保留既有预算豁免;本轮包含商家 PC,App 不动,Claude Code 实施,不建分支。 **本次规划选择**(供执行者遵守,不冒称用户原话):活动类型采用5、单个门槛;复用活动规则表,不增加券批次;以独立内部配送上下文和新增完整配送报价接口保护旧算价契约;新金额流程用显式版本2接入,旧请求不静默改价;首期只覆盖平台骑手配送的单店外送,自取、堂食、商家自配送、闪送和036合并配送不接入;所有金额与 API 契约、订单快照字段和兼容规则以 plan.md 本次变更及 contracts/free-delivery.md 为准。活动按报价/创建时的本地日期生效,预约单在创建时锁定权益,不等到预约时段再判断活动。 **实现状态**(2026-10-19 更新,Claude Code 实施):后端与商家 PC 已实现并通过定向测试与模块回归(T074~T077 全部勾选)。type=5 活动 CRUD/归属/行锁/按日期有效状态、三参加价重载与预算优先、POST /app/userPromotionCalc/deliveryQuote、版本2订单固化与真实用券、商家分成 G=A−D、发票 A−D、三方派生交款字段、支付切换方向均落地;SQL 已登记 updatesql/sql.md 末尾待手动执行。**未完成**:商家 PC 浏览器手工验收(四语言/重叠拒绝等,T078 人工项)、App 端未接入(本轮明确不改)、生产数据库迁移未执行。历史章节中“四种促销”“只看 status=1”“互斥券使用完整预算”“总优惠不超过预算”等说明,在本次新增类型/新金额契约命中时按 FR-028~FR-036 覆盖;未接入新契约的历史行为保留。