# Implementation Plan: 促销 + 优惠券系统

**Branch**: `008-promotion-coupon` | **Date**: 2026-05-29 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/008-promotion-coupon/spec.md`

## Summary

为 foodie 商家端新增促销活动管理（满减/折扣商品/第二份半价/新客立减 4 种类型）和优惠券管理（满减券/商品券/免配送费券），商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码（SalesPromotion、VipQuanyi）不动，全部新建文件、新表（promotion_ 前缀）、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端（小程序）的领券、下单优惠计算不在本范围。

**技术方案**：后端跟随现有 CRUD 分层（Entity + MyBatis XML Mapper + Service + Controller），6 张新表 + 2 个商家端 Controller + 2 个用户端 Controller（领券/查券 + 算价计算）。前端新增 2 个 Vue 页面，促销活动创建页面用 el-tabs 切换 4 种活动类型表单，每种类型有不同的表单字段和数据提交结构。折扣类型采用**逐商品设折扣**方式（参考美团），每个商品独立设置折扣率，不使用折扣区分组。

## Technical Context

**Language/Version**: Java 17 (Spring Boot 3.x, MyBatis-Plus)
**Primary Dependencies**: Spring Boot, MyBatis-Plus, Vue.js 2.6, Element UI 2.15, vue-i18n
**Storage**: MySQL (6 张新表: promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon, pos_order_promotion)
**Testing**: 手动测试 (Postman + 前端页面验证)
**Target Platform**: 商家 PC 端管理后台 (foodie-store)
**Project Type**: Web 应用 (Java 后端 + Vue 前端)
**Performance Goals**: 无特殊性能要求，标准 CRUD 操作
**Constraints**: 旧代码不动，新表用 promotion_ 前缀，SQL 写入 updatesql/sql.md 手动执行，前端文件 CRLF 换行
**Scale/Scope**: 商家端 2 个新页面 + 后端 6 个实体 + 4 个 Controller (2 商家端 + 2 用户端)

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*

本项目 constitution 未配置具体规则（模板状态），跳过此检查。

## Project Structure

### Documentation (this feature)

```text
specs/008-promotion-coupon/
├── spec.md              # 功能规格（已完成）
├── plan.md              # 本文件 — 实施计划
└── tasks.md             # 任务列表（后续生成）
```

### Source Code (repository root)

```text
# 后端 (foodie_server)
ruoyi-system/src/main/java/com/ruoyi/system/
├── domain/
│   ├── PromotionActivity.java         # 促销活动实体
│   ├── PromotionActivityRule.java     # 促销规则实体
│   ├── PromotionCouponBatch.java      # 券批次实体
│   ├── PromotionCouponRule.java       # 券规则实体
│   └── PromotionUserCoupon.java       # 用户券实体
├── mapper/
│   ├── PromotionActivityMapper.java
│   ├── PromotionActivityRuleMapper.java
│   ├── PromotionCouponBatchMapper.java
│   ├── PromotionCouponRuleMapper.java
│   └── PromotionUserCouponMapper.java
└── service/
    ├── IPromotionActivityService.java
    ├── IPromotionActivityRuleService.java
    ├── IPromotionCouponBatchService.java
    ├── IPromotionCouponRuleService.java
    └── impl/
        ├── PromotionActivityServiceImpl.java
        ├── PromotionActivityRuleServiceImpl.java
        ├── PromotionCouponBatchServiceImpl.java
        └── PromotionCouponRuleServiceImpl.java

ruoyi-system/src/main/resources/mapper/system/
├── PromotionActivityMapper.xml
├── PromotionActivityRuleMapper.xml
├── PromotionCouponBatchMapper.xml
├── PromotionCouponRuleMapper.xml
└── PromotionUserCouponMapper.xml

ruoyi-admin/src/main/java/com/ruoyi/app/mendian/
├── ShPromotionActivityController.java    # 商家促销活动 API
└── ShPromotionCouponController.java      # 商家优惠券 API

ruoyi-admin/src/main/java/com/ruoyi/app/user/
├── UserPromotionCouponController.java    # 用户端优惠券接口（领券、查券）
└── UserPromotionCalcController.java      # 用户端算价接口（计算优惠）

updatesql/
└── sql.md                                # SQL 迁移脚本（追加建表语句）

# 前端 (foodie-store)
src/
├── api/
│   ├── promotionActivity.js             # 促销活动 API
│   └── promotionCoupon.js               # 优惠券 API
├── views/
│   ├── PromotionActivity.vue            # 促销活动管理页面
│   └── CouponBatch.vue                  # 优惠券管理页面
├── components/
│   └── Aside.vue                        # 侧边栏（新增菜单项）
├── router/
│   └── index.js                         # 路由（新增2条路由）
└── lang/
    ├── zh.js                            # 中文 i18n（新增 promoMenu/promoActivity/couponBatch）
    ├── tw.js                            # 繁体中文
    ├── en.js                            # 英文
    └── vi.js                            # 越南语
```

**Structure Decision**: 后端遵循现有分层架构（domain/mapper/service/controller），前端页面放在 views/ 目录，API 放在 api/ 目录。不新建子目录，与现有文件组织方式一致。

## Key Design Decisions

### 1. 折扣类型采用逐商品设折扣（参考美团）

折扣商品(type=2)采用**逐商品设折扣**方式（参考美团/饿了么），不使用「折扣区/折扣档位」分组概念。

商家操作流程：
1. 点击「添加商品」打开商品选择弹窗，勾选参与折扣的商品
2. 选中后每个商品在列表中独立一行，可分别设置折扣率（10%~99%，步长0.1，-/+按钮或直接输入）
3. 折后价实时计算显示
4. 同一商品只能添加一次

前端提交的 rules 即为扁平列表，与后端 `promotion_activity_rule` 表结构一一对应：
```
rules: [
    {productId: 可乐ID, discountRate: 0.5},
    {productId: 宫保鸡丁ID, discountRate: 0.7},
    {productId: 麻婆豆腐ID, discountRate: 0.8}
  ]
```

**UI 示意**：
```
活动名称: [超值特惠]        活动时间: [开始] ~ [结束]

商品列表:
┌──────────────┬────────┬──────────────┬──────────┬────────┐
│ 商品名称      │ 原价    │ 折扣率        │ 折后价    │ 操作    │
├──────────────┼────────┼──────────────┼──────────┼────────┤
│ 可乐          │ 6      │ 50% [-][+]   │ 3.0      │ 移除   │
│ 宫保鸡丁      │ 20     │ 70% [-][+]   │ 14.0     │ 移除   │
│ 麻婆豆腐      │ 18     │ 80% [-][+]   │ 14.4     │ 移除   │
└──────────────┴────────┴──────────────┴──────────┴────────┘
[+ 添加商品]
```

**调研记录**：`docs/research-meituan-discount-product.md`

### 2. 活动状态管理

活动状态使用显式字段（0=未开始, 1=进行中, 2=已结束），在创建时根据 startTime 自动设置。不使用定时任务自动更新状态（简化实现），列表查询时按 status 字段过滤即可。

### 3. 商品选择弹窗复用

现有的 `Quanyi.vue` 中已有商品选择弹窗模式（分类下拉 + 搜索 + 商品表格 + 分页）。PromotionActivity.vue 和 CouponBatch.vue 复用相同的交互模式和 API 调用方式，不抽取为公共组件（遵循项目现有风格）。

### 4. 前端提交数据结构

促销活动创建：前端发送完整的 `{ storeId, type, name, startTime, endTime, rules: [...] }` JSON，后端在一个事务中插入 activity + rules。

优惠券创建：前端发送 `{ storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` JSON，后端在一个事务中插入 batch + rule。

## API Contracts

### ShPromotionActivityController

| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/system/shPromotionActivity/list` | 分页列表 | params: page, size, storeId, type?, status? | `{ code:200, data: { records:[], total, current, size } }` |
| GET | `/system/shPromotionActivity/{id}` | 详情(含规则) | path: id | `{ code:200, data: { id, storeId, type, name, status, startTime, endTime, rules: [...] } }` |
| POST | `/system/shPromotionActivity` | 创建活动 | body: `{ storeId, type, name, startTime, endTime, rules: [...] }` | `{ code:200, msg:"操作成功" }` |
| PUT | `/system/shPromotionActivity` | 修改活动(仅未开始) | body: `{ id, storeId, type, name, startTime, endTime, rules: [...] }` | `{ code:200, msg:"操作成功" }` |
| DELETE | `/system/shPromotionActivity/{id}` | 删除活动(仅未开始) | path: id | `{ code:200, msg:"操作成功" }` |
| PUT | `/system/shPromotionActivity/{id}/end` | 结束活动 | path: id | `{ code:200, msg:"操作成功" }` |

### ShPromotionCouponController

| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/system/shPromotionCoupon/list` | 分页列表 | params: page, size, storeId, couponType?, status? | `{ code:200, data: { records:[], total } }` |
| GET | `/system/shPromotionCoupon/{id}` | 详情(含规则) | path: id | `{ code:200, data: { id, storeId, name, couponType, totalCount, ..., rule: {...} } }` |
| POST | `/system/shPromotionCoupon` | 创建券 | body: `{ storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` | `{ code:200, msg:"操作成功" }` |
| PUT | `/system/shPromotionCoupon` | 修改券(未开始/进行中) | body: `{ id, storeId, name, couponType, totalCount, validDays, startTime, endTime, rule: {...} }` | `{ code:200, msg:"操作成功" }` |
| DELETE | `/system/shPromotionCoupon/{id}` | 删除券(仅未开始) | path: id | `{ code:200, msg:"操作成功" }` |
| PUT | `/system/shPromotionCoupon/{id}/offShelf` | 下架券 | path: id | `{ code:200, msg:"操作成功" }` |

### UserPromotionCouponController（用户端 — 领券/查券）

| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| GET | `/app/userPromotionCoupon/storeCoupons` | 查询门店可领优惠券列表 | params: storeId | `{ code:200, data: [{ id, name, couponType, threshold, amount, discountRate, isMutex, totalCount, remainCount, hasReceived }] }` |
| POST | `/app/userPromotionCoupon/receive` | 领取优惠券 | body: `{ batchId, storeId }` | `{ code:200, data: { userCouponId }, msg:"领取成功" }` |
| GET | `/app/userPromotionCoupon/myCoupons` | 查询用户优惠券列表 | params: status?(0=未使用/1=已使用/2=已过期), storeId? | `{ code:200, data: [{ id, batchId, storeId, name, couponType, threshold, amount, discountRate, isMutex, status, expireTime }] }` |

### UserPromotionCalcController（用户端 — 算价计算）

| Method | Path | Description | Request | Response |
|--------|------|-------------|---------|----------|
| POST | `/app/userPromotionCalc/calculate` | 计算订单优惠 | body: `{ storeId, items:[{productId, quantity}], couponId?, forcePath? }` | 见下方 Response 示例 |

**算价接口参数说明**:
- `items` 中只传 `productId` 和 `quantity`，**不传 price**。后端根据 productId 从数据库查询商品实际价格，防止前端篡改
- `couponId`: 可选，传入用户券ID则计算使用该券后的价格；不传则不使用券
- `forcePath`: 可选，"A"=强制走折扣路径，"B"=强制走满减路径，不传或非法值=自动选最优
- 接口需通过登录态获取 userId，用于判断新客立减资格和查询用户可用券

**算价接口 Response 示例 1（满减+优惠券+新客立减）**:
```json
{
  "code": 200,
  "data": {
    "originalAmount": 45.00,
    "pathA": {
      "label": "折扣",
      "items": [{ "productId": 1, "originalPrice": 20.00, "finalPrice": 14.00 }],
      "subtotal": 39.00,
      "promotionReduce": 6.00
    },
    "pathB": {
      "label": "满减",
      "items": [{ "productId": 1, "originalPrice": 20.00, "finalPrice": 20.00 }],
      "subtotal": 33.00,
      "promotionReduce": 12.00,
      "matchedRule": { "threshold": 40, "reduce": 12 }
    },
    "optimalPath": "B",
    "couponReduce": 5.00,
    "finalAmount": 28.00,
    "details": [
      { "type": "promotion", "subType": 1, "name": "午市满减(满40减12)", "reduce": 12.00 },
      { "type": "coupon", "name": "满30减5(同享券)", "reduce": 5.00 }
    ],
    "availableCoupons": [
      { "id": 10, "name": "满30减5(同享券)", "couponType": 1, "isMutex": 0, "threshold": 30.00, "amount": 5.00 },
      { "id": 11, "name": "满30减5(互斥券)", "couponType": 1, "isMutex": 1, "threshold": 30.00, "amount": 5.00 }
    ]
  }
}
```

**算价接口 Response 示例 2（互斥券场景）**:
```json
{
  "code": 200,
  "data": {
    "originalAmount": 45.00,
    "pathA": { "label": "折扣", "subtotal": 39.00, "promotionReduce": 6.00 },
    "pathB": { "label": "满减", "subtotal": 33.00, "promotionReduce": 12.00 },
    "optimalPath": "B",
    "couponId": 11,
    "couponName": "满30减5(互斥券)",
    "couponConflict": true,
    "conflictNote": "互斥券不可与满减叠加，选择此券将取消满减优惠",
    "finalAmount": 40.00,
    "details": [
      { "type": "coupon", "name": "满30减5(互斥券)", "reduce": 5.00 }
    ]
  }
}
```

**算价接口 Response 示例 3（第二份半价场景）**:
```json
{
  "code": 200,
  "data": {
    "originalAmount": 60.00,
    "pathA": {
      "label": "第二份半价",
      "items": [
        { "productId": 3, "originalPrice": 20.00, "finalPrice": 20.00, "discountNote": "第1件原价" },
        { "productId": 3, "originalPrice": 20.00, "finalPrice": 10.00, "discountNote": "第2件半价" }
      ],
      "subtotal": 50.00,
      "promotionReduce": 10.00
    },
    "pathB": { "label": "无满减活动", "subtotal": 60.00, "promotionReduce": 0 },
    "optimalPath": "A",
    "finalAmount": 50.00,
    "details": [
      { "type": "promotion", "subType": 3, "name": "第二份半价(可乐)", "reduce": 10.00 }
    ]
  }
}
```

### Request Body Examples

**创建满减活动**:
```json
{
  "storeId": 1,
  "type": 1,
  "name": "午市满减",
  "startTime": "2026-06-01 10:00:00",
  "endTime": "2026-06-30 22:00:00",
  "rules": [
    { "threshold": 20.00, "reduceAmount": 5.00 },
    { "threshold": 40.00, "reduceAmount": 12.00 },
    { "threshold": 60.00, "reduceAmount": 20.00 }
  ]
}
```

**创建折扣活动**:
```json
{
  "storeId": 1,
  "type": 2,
  "name": "夏季折扣",
  "startTime": "2026-06-01 00:00:00",
  "endTime": "2026-06-30 23:59:59",
  "rules": [
    { "discountRate": 0.40, "productId": 101 },
    { "discountRate": 0.40, "productId": 102 },
    { "discountRate": 0.70, "productId": 201 }
  ]
}
```

**创建满减券**:
```json
{
  "storeId": 1,
  "name": "满30减5",
  "couponType": 1,
  "totalCount": 100,
  "validDays": 7,
  "startTime": "2026-06-01 00:00:00",
  "endTime": "2026-06-30 23:59:59",
  "rule": {
    "isMutex": 0,
    "threshold": 30.00,
    "amount": 5.00
  }
}
```

## Data Model

参见 spec.md 的 Data Model 章节，6 张表完整 DDL 已在 spec 中定义。

表关系：
```
promotion_activity 1 → N promotion_activity_rule
promotion_coupon_batch 1 → 1 promotion_coupon_rule
promotion_coupon_batch 1 → N promotion_user_coupon
```

## Complexity Tracking

| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| 折扣类型前端分组概念（已废弃→改为逐商品设折扣） | 原设计按折扣率分组管理商品，参考美团后改为逐商品设折扣 | 分组概念增加商家认知负担，美团实际不使用分组 |
| 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 参数从未实现），本次不修，由创建侧门槛校验+预算封顶兜住
