# Tasks: 促销 + 优惠券系统

**Input**: Design documents from `/specs/008-promotion-coupon/`
**Prerequisites**: plan.md (required), spec.md (required for user stories)

## Format: `[ID] [P?] [Story] Description`

- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (US1=促销活动, US2=优惠券)
- Include exact file paths in descriptions

---

## Phase 1: Setup (数据库 + 基础设施)

**Purpose**: 创建数据库表，为后续所有任务提供基础

- [x] T001 追加 6 张新表 DDL 到 `foodie_server/updatesql/sql.md`（promotion_activity, promotion_activity_rule, promotion_coupon_batch, promotion_coupon_rule, promotion_user_coupon, pos_order_promotion），DDL 来自 spec.md Data Model 章节
- [x] T002 手动执行 SQL 建表（提醒开发者操作）

---

## Phase 2: Foundational (后端实体 + 数据层)

**Purpose**: 后端核心数据层，所有 Controller 和前端页面依赖此阶段完成

### 实体类 (5个文件，全部可并行)

- [x] T003 [P] 创建 PromotionActivity 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivity.java` — 字段: id, storeId, type, name, status, startTime, endTime, createTime, updateTime；非持久化: List<PromotionActivityRule> rules, boolean editable。使用 @Data @TableName @TableId(type=IdType.AUTO) @JsonFormat 注解，参考 SalesPromotion.java
- [x] T004 [P] 创建 PromotionActivityRule 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionActivityRule.java` — 字段: id, activityId, productId, threshold(BigDecimal), reduceAmount(BigDecimal), discountRate(BigDecimal), minQuantity(Integer)；非持久化: productName, productImage, productPrice
- [x] T005 [P] 创建 PromotionCouponBatch 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponBatch.java` — 字段: id, storeId, name, couponType, totalCount, remainCount, receivedCount, status, startTime, endTime, validDays, createTime, updateTime；非持久化: PromotionCouponRule rule, boolean editable
- [x] T006 [P] 创建 PromotionCouponRule 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionCouponRule.java` — 字段: id, batchId, productId, isMutex(Integer), threshold(BigDecimal), amount(BigDecimal), discountRate(BigDecimal)；非持久化: productName, productImage, productPrice
- [x] T007 [P] 创建 PromotionUserCoupon 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PromotionUserCoupon.java` — 字段: id, userId, batchId, storeId, status, orderId, receiveTime, useTime, expireTime
- [x] T007a [P] 创建 PosOrderPromotion 实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderPromotion.java` — 字段: id, orderId, promoType, promoSubType, promoId, userCouponId, promoName, promoDetail, reduceAmount, pathSummary, createTime

### Mapper 接口 (全部可并行)

- [x] T008 [P] 创建 PromotionActivityMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityMapper.java` — extends BaseMapper<PromotionActivity>
- [x] T009 [P] 创建 PromotionActivityRuleMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionActivityRuleMapper.java` — extends BaseMapper<PromotionActivityRule>，额外方法 List<PromotionActivityRule> selectRulesByActivityId(Long activityId)
- [x] T010 [P] 创建 PromotionCouponBatchMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponBatchMapper.java` — extends BaseMapper<PromotionCouponBatch>
- [x] T011 [P] 创建 PromotionCouponRuleMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionCouponRuleMapper.java` — extends BaseMapper<PromotionCouponRule>，额外方法 PromotionCouponRule selectRuleByBatchId(Long batchId)
- [x] T012 [P] 创建 PromotionUserCouponMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PromotionUserCouponMapper.java` — extends BaseMapper<PromotionUserCoupon>
- [x] T012a [P] 创建 PosOrderPromotionMapper `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderPromotionMapper.java` — extends BaseMapper<PosOrderPromotion>

### Mapper XML (全部可并行)

- [x] T013 [P] 创建 PromotionActivityMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionActivityMapper.xml` — resultMap 映射全部字段
- [x] T014 [P] 创建 PromotionActivityRuleMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionActivityRuleMapper.xml` — resultMap + selectRulesByActivityId (LEFT JOIN pos_food 获取 productName/productImage/productPrice)
- [x] T015 [P] 创建 PromotionCouponBatchMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionCouponBatchMapper.xml` — resultMap 映射全部字段
- [x] T016 [P] 创建 PromotionCouponRuleMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionCouponRuleMapper.xml` — resultMap + selectRuleByBatchId (LEFT JOIN pos_food)
- [x] T017 [P] 创建 PromotionUserCouponMapper.xml `ruoyi-system/src/main/resources/mapper/system/PromotionUserCouponMapper.xml` — resultMap 映射全部字段
- [x] T017a [P] 创建 PosOrderPromotionMapper.xml `ruoyi-system/src/main/resources/mapper/system/PosOrderPromotionMapper.xml` — resultMap 映射全部字段

**Checkpoint**: 数据层完成，可进入 Service 和 Controller

---

## Phase 3: User Story 1 - 商家创建促销活动 (Priority: P1) 🎯 MVP

**Goal**: 商家能创建满减/折扣/第二份半价/新客立减活动，查看活动列表，结束进行中的活动

**Independent Test**: 商家创建一个满减活动（满20减5/满40减12），列表显示进行中，能查看详情，能结束活动

### Service 层 (US1)

- [x] T018 [US1] 创建 IPromotionActivityService `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityService.java` — extends IService<PromotionActivity>，方法: createActivity(activity, rules), selectActivityWithRules(id), endActivity(id)
- [x] T019 [US1] 创建 PromotionActivityServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionActivityServiceImpl.java` — 实现 createActivity (@Transactional: 插入activity+批量插入rules, 根据 startTime 设置初始 status), selectActivityWithRules (查activity+查rules), endActivity (status→2)
- [x] T020 [US1] 创建 IPromotionActivityRuleService + PromotionActivityRuleServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionActivityRuleService.java` 和 `impl/PromotionActivityRuleServiceImpl.java` — selectRulesByActivityId 委托给 mapper

### Controller (US1)

- [x] T021 [US1] 创建 ShPromotionActivityController `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionActivityController.java` — @RestController @RequestMapping("/system/shPromotionActivity")，参考 ShSalesPromotionController。端点: GET /list (page,size,storeId,type?,status? → LambdaQueryWrapper 分页), GET /{id} (selectActivityWithRules), POST / (RequestBody含rules→createActivity), PUT /{id}/end (endActivity)

### 前端 API (US1)

- [x] T022 [US1] 创建 promotionActivity.js `foodie-store/src/api/promotionActivity.js` — 导出 listPromotionActivity(query), getPromotionActivity(id), addPromotionActivity(data), endPromotionActivity(id)，参考 src/api/promotion.js 模式

### 前端页面 (US1)

- [x] T023 [US1] 创建 PromotionActivity.vue `foodie-store/src/views/PromotionActivity.vue` — 促销活动管理页面：
  - **列表区域**: 面包屑 + 门店选择(复用xuanzhemd模式) + 状态筛选radio(全部/未开始/进行中/已结束) + el-table(ID/名称/类型el-tag/状态el-tag/时间/操作-结束按钮) + el-pagination
  - **创建对话框** el-dialog width=750px + el-tabs v-model=activeTab:
    - Tab1 满减(type=1): 活动名称 + 时间范围 + 动态档位行(v-for, 每行两个el-input-number:满X减Y) + 添加/删除档位按钮
    - Tab2 折扣(type=2): 活动名称 + 时间范围 + 折扣组列表(v-for, 每组含折扣率+已选商品table+添加商品按钮) + 添加/删除折扣区按钮。**同一商品只能分配到一个折扣区，添加时自动从其他折扣区移除**
    - Tab3 第二份半价(type=3): 活动名称 + 时间范围 + 已选商品table + 选择商品按钮
    - Tab4 新客立减(type=4): 活动名称 + 时间范围 + 减免金额el-input-number
  - **商品选择弹窗**: 分类el-select + 搜索el-input + 商品el-table + el-pagination (复用Quanyi.vue商品加载API模式)
  - **提交逻辑** tijiaobaocun(): 根据activeTab转换数据为 {storeId,type,name,startTime,endTime,rules:[...]}，折扣组展开为扁平rules，调用addPromotionActivity
  - **互斥提示**: 创建对话框顶部展示互斥规则提示文案（FR-006）："满减、折扣、第二份半价三选一（系统自动选最优），新客立减可叠加"
  - **i18n**: 所有文字用 $t('promoActivity.xxx')

**Checkpoint**: 促销活动完整功能可用——创建4种类型活动、查看列表、查看详情、结束活动

---

## Phase 4: User Story 2 - 商家创建优惠券 (Priority: P2)

**Goal**: 商家能创建满减券/商品券，设置同享/互斥属性和库存，查看列表，下架券

**Independent Test**: 商家创建"满30减5"满减券100张，列表显示，能查看详情，能下架

### Service 层 (US2)

- [x] T024 [P] [US2] 创建 IPromotionCouponBatchService `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponBatchService.java` — 方法: createBatch(batch, rule), selectBatchWithRule(id), offShelfBatch(id)
- [x] T025 [P] [US2] 创建 PromotionCouponBatchServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PromotionCouponBatchServiceImpl.java` — 实现 createBatch (@Transactional: remainCount=totalCount, 插入batch+rule), selectBatchWithRule (查batch+查rule), offShelfBatch (status→3)
- [x] T026 [P] [US2] 创建 IPromotionCouponRuleService + PromotionCouponRuleServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCouponRuleService.java` 和 `impl/PromotionCouponRuleServiceImpl.java` — selectRuleByBatchId 委托给 mapper

### Controller (US2)

- [x] T027 [US2] 创建 ShPromotionCouponController `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/ShPromotionCouponController.java` — @RestController @RequestMapping("/system/shPromotionCoupon")。端点: GET /list (page,size,storeId,couponType?,status?), GET /{id} (selectBatchWithRule), POST / (RequestBody含rule→createBatch), PUT /{id}/offShelf

### 前端 API (US2)

- [x] T028 [P] [US2] 创建 promotionCoupon.js `foodie-store/src/api/promotionCoupon.js` — 导出 listPromotionCoupon(query), getPromotionCoupon(id), addPromotionCoupon(data), offShelfPromotionCoupon(id)

### 前端页面 (US2)

- [x] T029 [US2] 创建 CouponBatch.vue `foodie-store/src/views/CouponBatch.vue` — 优惠券管理页面：
  - **列表区域**: 面包屑 + 门店选择 + 状态筛选(全部/未开始/进行中/已结束/已下架) + el-table(ID/名称/类型/总库存/剩余/已领取/状态/时间/有效期/操作-下架按钮) + el-pagination
  - **创建对话框** el-dialog width=600px:
    - 券类型 el-radio-group (满减券/商品券)
    - 通用字段: 名称el-input, 门店el-select, 总量el-input-number, 有效天数el-input-number, 时间范围el-date-picker
    - 满减券(v-if couponType===1): 叠加属性el-radio(同享/互斥), 使用门槛el-input-number, 减免金额el-input-number
    - 商品券(v-if couponType===2): 选择商品按钮(复用商品弹窗), 折扣率el-input-number
  - **提交逻辑** tijiaobaocun(): 构建 {storeId,name,couponType,totalCount,validDays,startTime,endTime,rule:{isMutex,threshold,amount,...}}，调用addPromotionCoupon
  - **i18n**: 所有文字用 $t('couponBatch.xxx')

**Checkpoint**: 优惠券完整功能可用——创建满减券/商品券、查看列表、查看详情、下架券

---

## Phase 5: Menu + Router + i18n (跨 US)

**Purpose**: 前端菜单、路由、多语言——连接所有页面的基础设施

> **注意**: 前端文件使用 CRLF 换行，必须用 Python 脚本编辑，不能用 Edit 工具。

- [x] T030 在 `foodie-store/src/router/index.js` 的 /manage children 中添加路由 promotion-activity → PromotionActivity.vue 和 coupon-batch → CouponBatch.vue
- [x] T031 在 `foodie-store/src/components/Aside.vue` 末尾（el-submenu index="6" 之后）添加新的 el-submenu index="7"「营销管理」，含两个 menu-item: 促销活动(/manage/promotion-activity) + 优惠券管理(/manage/coupon-batch)
- [x] T032 [P] 在 `foodie-store/src/lang/zh.js` 末尾追加 promoMenu（营销管理/促销活动/优惠券管理）、promoActivity（~40个key覆盖页面全部文案）、couponBatch（~30个key）三个 i18n section
- [x] T033 [P] 在 `foodie-store/src/lang/tw.js` 追加同结构繁体中文翻译
- [x] T034 [P] 在 `foodie-store/src/lang/en.js` 追加同结构英文翻译
- [x] T035 [P] 在 `foodie-store/src/lang/vi.js` 追加同结构越南语翻译

**Checkpoint**: 侧边栏显示新菜单，页面可访问，多语言正常

---

## Phase 5.5: 用户端后端接口 (FR-011~022)

**Purpose**: 实现用户端的领券、查券、算价计算后端接口（前端界面由小程序实施，不做）

**Goal**: 用户端小程序可调用后端 API 完成领券、查看优惠券、结算页优惠计算

**Independent Test**: 用 Postman 调用算价接口，传入购物车商品，返回两条路径对比 + 最优方案 + 可用券列表

### Service 层

- [x] T039 [P] 创建 IPromotionUserCouponService + PromotionUserCouponServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionUserCouponService.java` 和 `impl/PromotionUserCouponServiceImpl.java` — 方法: receiveCoupon(userId, batchId, storeId)（检查限领1张、**乐观锁扣库存 `UPDATE remain_count=remain_count-1 WHERE remain_count>0`**、计算expireTime=领取日期+validDays天23:59:59、@Transactional）, selectStoreCouponsForUser(userId, storeId)（查询门店进行中券批次+用户是否已领标记，不分页）, selectMyCoupons(userId, status?, storeId?)（返回含storeName，实时判断expire_time<NOW()则status→2）
- [x] T040 创建 IPromotionCalcService + PromotionCalcServiceImpl `ruoyi-system/src/main/java/com/ruoyi/system/service/IPromotionCalcService.java` 和 `impl/PromotionCalcServiceImpl.java` — 核心算价逻辑：
  - calculate(storeId, items[{productId,quantity}], userId, couponId?, forcePath?) → 返回路径对比结果
  - 内部逻辑：
    1. **根据productId从数据库查询商品实际价格**（不信任前端传价）
    2. 查门店所有进行中的促销活动（满减、折扣、第二份半价、新客立减）
    3. 路径A（走折扣/第二份半价）：分别计算折扣和第二份半价各一遍，取更优作为路径A结果。折扣商品按discountRate，第二份半价按每2件第2件半价，非活动商品原价
    4. 路径B（走满减）：所有商品原价 → 匹配满减最高可达档位 → 合计
    5. 对比选最优（金额相等默认选B）；无促销时两条路径同价返回原价
    6. 查用户是否为该门店新客（查询该门店已完成订单数=0），新客则叠加新客立减
    7. 如传couponId：检查互斥规则（互斥券不能与满减/折扣叠加，商品券对应商品如在折扣/第二份半价活动中则该券不可用），同享券threshold基于促销后金额判断
    8. **所有金额用BigDecimal计算，最终ROUND_HALF_UP保留2位小数，最低¥0.01**
    9. 返回 availableCoupons（可用券列表）供前端展示
    6. 返回两条路径明细 + optimalPath + 最终金额 + details数组

### Controller

- [x] T041 创建 UserPromotionCouponController `ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCouponController.java` — @RestController @RequestMapping("/app/userPromotionCoupon")。端点: GET /storeCoupons (storeId → selectStoreCouponsForUser), POST /receive (batchId,storeId → receiveCoupon), GET /myCoupons (status?,storeId? → selectMyCoupons)
- [x] T042 创建 UserPromotionCalcController `ruoyi-admin/src/main/java/com/ruoyi/app/user/UserPromotionCalcController.java` — @RestController @RequestMapping("/app/userPromotionCalc")。端点: POST /calculate (RequestBody含storeId+items[{productId,quantity}]+couponId?+forcePath?，**不传price**，通过登录态获取userId → calculate)

### Mapper 补充查询

- [x] T043 [P] 在 PromotionActivityMapper 中添加 selectActiveByStoreId(storeId) — 查询门店所有进行中的促销活动，算价时使用
- [x] T044 [P] 在 PromotionActivityRuleMapper 中添加 selectActiveRulesByStoreId(storeId) — 一次性查出门店所有进行中活动的规则（LEFT JOIN promotion_activity ON status=1），算价时使用
- [x] T045 [P] 在 PromotionCouponBatchMapper 中添加 selectActiveByStoreId(storeId) — 查询门店进行中且有余量的券批次（status=1 AND remain_count>0）

**Checkpoint**: 用户端 API 可用——领券、查券、算价计算接口通过 Postman 测试通过

---

## Phase 5.6: 促销活动修改/删除 (FR-005a, FR-005b)

**Purpose**: 商家能修改未开始的活动、删除未开始的活动，参考美团商家端交互

**Goal**: 活动列表操作列按状态显示不同按钮（未开始=编辑+删除，进行中=结束，已结束=无）

### 后端

- [x] T046 在 IPromotionActivityService 中添加 `updateActivity(activity, rules)` — 校验 status=0 才允许修改；@Transactional 先删除旧规则再重新插入
- [x] T047 在 IPromotionActivityService 中添加 `deleteActivity(id)` — 校验 status=0 才允许删除；@Transactional 删除活动+关联规则
- [x] T048 在 ShPromotionActivityController 中添加 `PUT /` (updateActivity) 和 `DELETE /{id}` (deleteActivity) 端点

### 前端 API

- [x] T049 在 `foodie-store/src/api/promotionActivity.js` 中添加 `updatePromotionActivity(data)` 和 `deletePromotionActivity(id)`

### 前端页面

- [x] T050 修改 `foodie-store/src/views/PromotionActivity.vue`：
  - 操作列按状态显示按钮：未开始(编辑+删除) / 进行中(结束) / 已结束(无)
  - 编辑：点击后调用 getPromotionActivity(id) 获取详情，回填到创建对话框，tijiaobaocun 判断有 id 则调用 updatePromotionActivity
  - 删除：二次确认后调用 deletePromotionActivity(id)
  - 新增 data: `editingId: null`，`tijiaobaocun` 判断 editingId 有值走修改、无值走新增

### i18n

- [x] T051 [P] 在 4 个语言文件(zh/tw/en/vi)的 promoActivity section 中追加：editBtn、deleteBtn、editActivity、deleteConfirm 等新 key

**Checkpoint**: 未开始的活动可编辑（修改名称/时间/规则）、可删除（二次确认后删除），进行中只能结束

---

## Phase 5.7: 优惠券修改/删除 (FR-009a, FR-009b)

**Purpose**: 商家能修改未开始/进行中的优惠券、删除未开始的优惠券

**Goal**: 优惠券列表操作列按状态显示不同按钮（未开始=编辑+删除，进行中=编辑+下架，已结束/已下架=无）

### 后端

- [x] T052 在 IPromotionCouponBatchService 中添加 `updateBatch(batch, rule)` — 校验 status=0或1 才允许修改；@Transactional 先删除旧规则再重新插入
- [x] T053 在 IPromotionCouponBatchService 中添加 `deleteBatch(id)` — 校验 status=0 才允许删除；@Transactional 删除券批次+关联规则
- [x] T054 在 ShPromotionCouponController 中添加 `PUT /` (updateBatch) 和 `DELETE /{id}` (deleteBatch) 端点

### 前端 API

- [x] T055 在 `foodie-store/src/api/promotionCoupon.js` 中添加 `updatePromotionCoupon(data)` 和 `deletePromotionCoupon(id)`

### 前端页面

- [x] T056 修改 `foodie-store/src/views/CouponBatch.vue`：
  - 操作列按状态显示按钮：未开始(编辑+删除) / 进行中(编辑+下架) / 已结束/已下架(无)
  - 编辑：点击后调用 getPromotionCoupon(id) 获取详情，回填到创建对话框，tijiaobaocun 判断有 id 则调用 updatePromotionCoupon
  - 删除：二次确认后调用 deletePromotionCoupon(id)
  - 新增 data: `editingId: null`，`tijiaobaocun` 判断 editingId 有值走修改、无值走新增

### i18n

- [x] T057 [P] 在 4 个语言文件(zh/tw/en/vi)的 couponBatch section 中追加：editBtn、deleteBtn、editCoupon、deleteConfirm 等新 key

---

## Phase 5.8: 免配送费券 (FR-008a)

**Purpose**: 新增 couponType=3 免配送费券，商家可创建减免配送费的优惠券

**Goal**: 创建券对话框新增"免配送费券"选项，配置使用门槛+减免金额+同享/互斥

### 数据库

- [x] T058 在 `updatesql/sql.md` 追加 ALTER 语句更新 promotion_coupon_batch.coupon_type 的 COMMENT 为 '1=满减券 2=商品券 3=免配送费券'（无需实际 ALTER，仅记录）

### 前端页面

- [x] T059 修改 `foodie-store/src/views/CouponBatch.vue` 创建对话框：券类型 radio-group 新增第三个选项"免配送费券"(couponType=3)，选中后显示：使用门槛 el-input-number + 减免金额 el-input-number + 叠加属性 radio（同享/互斥），隐藏商品选择

### i18n

- [x] T060 [P] 在 4 个语言文件(zh/tw/en/vi)的 couponBatch section 中追加：typeFreeDelivery（免配送费券）等 key

**Checkpoint**: 创建券时可选"免配送费券"，提交数据 couponType=3，含 threshold + amount + isMutex

**Checkpoint**: 未开始的券可编辑+删除，进行中的券可编辑+下架，已结束/已下架无操作

---

## Phase 5.9: 折扣商品改为逐商品设折扣 (需求变更)

**Purpose**: 将折扣商品 UI 从「折扣区分组」改为「逐商品设折扣」（参考美团），提升商家体验

**Goal**: 商家创建折扣活动时直接选择商品并逐个设折扣率，不再需要先建折扣区

**背景**: 经美团/饿了么调研，主流外卖平台都是逐商品设折扣，不使用折扣区分组。后端数据模型已支持（`promotion_activity_rule` 每行有独立 `productId` + `discountRate`），改动集中在前端 UI。

### 前端页面

- [x] T061 修改 `foodie-store/src/views/PromotionActivity.vue` 折扣 Tab (Tab2) 的 UI 和逻辑：
  - **移除**：折扣区分组 UI（建区/删区按钮、折扣组 v-for 循环）
  - **改为**扁平商品列表，每行 = 商品名称 + 原价 + 折扣率控件(-/+按钮+输入框，范围10%~99%，步长0.1) + 折后价(自动计算) + 移除按钮
  - **添加商品按钮**：点击打开商品选择弹窗（复用已有弹窗），选中后默认折扣率90%，添加到列表
  - **重复商品限制**：选择商品时如已存在于列表则提示并跳过
  - **折后价实时计算**：原价 × 折扣率，显示在每行
  - **提交逻辑简化**：`tijiaobaocun()` 中折扣 Tab 直接构建 `rules: [{productId, discountRate}, ...]`，无需展开分组
  - **编辑回填**：编辑折扣活动时，从 `rules` 数组直接回填到商品列表，每行设置对应的 discountRate
  - **折扣率校验**：前端提交时校验所有折扣率在 0.10~0.99 范围内，超出范围阻止提交

### i18n

- [x] T062 [P] 更新 4 个语言文件 `foodie-store/src/lang/` 的 zh.js、tw.js、en.js、vi.js：
  - 移除折扣区相关 key（如 addDiscountZone、deleteZone、discountZone 等）
  - 新增/修改：discountRate（折扣率）、discountedPrice（折后价）、originalPrice（原价）、removeProduct（移除）、addDiscountProduct（添加折扣商品）、discountRateRange（范围10%-99%）、productAlreadyAdded（商品已添加）等 key
  - 更新 promoActivity section 中与折扣 Tab 相关的所有文案

### 后端验证

- [x] T063 验证后端接口兼容性：确认 `ShPromotionActivityController` 的 POST/PUT 端点已支持接收扁平的 `rules: [{productId, discountRate}]` 格式（不需要改动），确认编辑回填接口 GET /{id} 返回的 rules 中每条包含 productId 和 discountRate（已有 LEFT JOIN pos_food）

**Checkpoint**: 商家创建折扣活动时 UI 为扁平商品列表，每个商品独立设折扣率，不再有折扣区分组概念。编辑时正确回填。折后价实时计算。后端接口无需改动。

---

## 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**: 端到端验证

- [ ] T036 后端启动验证：用 Postman 测试 4 个促销 API + 4 个优惠券 API（商家端）+ 3 个用户端领券 API + 1 个算价 API 的完整流程
- [ ] T036a 算价接口专项验证：构造测试数据（门店有满减+折扣活动+优惠券），验证两条路径计算正确、互斥规则生效、新客立减可叠加
- [ ] T037 前端启动验证：npm run dev → 侧边栏新菜单 → 创建满减活动(3档位) → 创建折扣活动(选择5个商品逐个设不同折扣率) → 创建第二份半价 → 创建新客立减 → 创建满减券 → 创建商品券 → 列表筛选 → 结束活动 → 下架券 → 编辑折扣活动验证回填
- [ ] T038 [P] 切换4种语言验证所有文案显示正确

---

## Dependencies & Execution Order

### Phase Dependencies

- **Phase 1 (Setup)**: 无依赖，立即开始
- **Phase 2 (Foundational)**: 依赖 Phase 1 的表已创建
- **Phase 3 (US1 促销)**: 依赖 Phase 2 的实体和 Mapper
- **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

- T003-T007a (6个实体) 可并行
- T008-T012a (6个Mapper接口) 可并行
- T013-T017a (6个Mapper XML) 可并行
- Phase 3 和 Phase 4 可并行（不同文件，不同 Controller）
- T039 和 T040 可并行（不同 Service）
- T032-T035 (4个语言文件) 可并行

### Recommended Execution Order

```
T001 → T002(手动)
  → T003-T017a (Phase 2, 大量并行)
  → T018-T023 (Phase 3, 促销活动) + T024-T029 (Phase 4, 优惠券) [可并行]
  → T030-T035 (Phase 5, 菜单路由i18n)
  → T039-T045 (Phase 5.5, 用户端接口)
  → T061-T063 (Phase 5.9, 折扣商品改为逐商品设折扣)
  → T064-T069 (Phase 5.10, 优惠幅度20%限制)
  → T036-T038 (Phase 6, 验证) ← 当前待实施
```

---

## Notes

- [P] tasks = 不同文件，无依赖，可并行
- [Story] label 映射到 spec 中的 User Story (US1=促销, US2=优惠券)
- 每个任务完成后 commit
- 前端文件 CRLF 换行，用 Python 脚本编辑
- 旧代码（SalesPromotion, VipQuanyi）不动
- PromotionUserCoupon 实体和 Mapper 在 Phase 2 创建，Service/Controller 在 Phase 5.5 实现
- PosOrderPromotion 实体和 Mapper 在 Phase 2 创建，算价 Service 在 Phase 5.5 使用
- 用户端前端（小程序界面）不在本范围，只做后端 API
