spec.md 46 KB

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;未接入该契约的旧 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 — 促销活动表

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 — 促销规则明细表

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 — 券批次表(模板)

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 — 券规则表

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 — 用户券表(实例)

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 级分摊。由算价接口在下单时写入。

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
  • 美团折扣商品调研报告 — 美团/饿了么折扣商品设置方式对比,逐商品设折扣 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 覆盖;未接入新契约的历史行为保留。