# Implementation Plan: 促销 + 优惠券系统 > **For agentic workers:** 在现有分支按 tasks.md 的 T074~T078 执行;需要执行工作流时使用 superpowers:executing-plans(如环境提供)。本轮规划由 Codex 完成,代码由 Claude Code 实现,不创建分支或工作树。 **Goal:** 扩展商家自动满额免运,商品原价达标后由商家承担全额配送费,并优先占用优惠预算。 **Architecture:** 复用现有促销表和商家页面;商品促销计算接收可信的内部配送上下文;应用层负责地址、规格及运费,订单固化三方金额后沿用支付与结算。 **Tech Stack:** Java 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Vue 2、Element UI、vue-i18n。 **Spec:** [spec.md](./spec.md) FR-028~FR-036;[contracts/free-delivery.md](./contracts/free-delivery.md)。 **Original Feature**: `008-promotion-coupon` | **Created**: 2026-05-29 | **Plan updated**: 2026-10-19 **Current branch**: `codex/036-multi-store-checkout`(仅沿用工作目录,不恢复036实施;执行时不新建/切换分支)。 **Input**: Feature specification from `/specs/008-promotion-coupon/spec.md` ## Summary 为 foodie 商家端新增促销活动管理(满减/折扣商品/第二份半价/新客立减 4 种类型)和优惠券管理(满减券/商品券/免配送费券),商家可在后台创建和管理促销活动与优惠券。旧促销/优惠券代码(SalesPromotion、VipQuanyi)不动,全部新建文件、新表(promotion_ 前缀)、新接口。前端在商家端侧边栏新增独立的「营销管理」菜单。用户端(App/小程序)页面代码不在本范围;后端用户接口、算价和下单金额属于后端范围。2026-10-19 新增满额免运按文末实施约定扩展,商家 PC 必须同步交付。 **技术方案**:后端跟随现有 CRUD 分层(Entity + MyBatis XML Mapper + Service + Controller),6 张新表 + 2 个商家端 Controller + 2 个用户端 Controller(领券/查券 + 算价计算)。前端新增 2 个 Vue 页面,促销活动创建页面用 el-tabs 切换 4 种活动类型表单,每种类型有不同的表单字段和数据提交结构。折扣类型采用**逐商品设折扣**方式(参考美团),每个商品独立设置折扣率,不使用折扣区分组。 ## Technical Context **Language/Version**: Java 21 (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 参数从未实现),本次不修,由创建侧门槛校验+预算封顶兜住 ## 需求变更 2026-10-19:满额免运实施计划(Claude Code 执行) **状态**(2026-10-19 更新):已由 Claude Code 在现有分支实现(T074~T077 完成并过定向测试与回归;T078 自动化验证完成,商家 PC 浏览器手工验收与 SQL 手动执行待开发者)。详细任务、测试和执行顺序见 [tasks.md](./tasks.md#phase-511-满额免运优先2026-10-19-需求追加),交接入口见 [claude-code-handoff.md](./claude-code-handoff.md)。 ### Global Constraints - 直接沿用当前分支;不创建分支/工作树,不自动 commit/push,不混入已有 `PosFoodController.java` 改动。 - 只实现后端和商家 PC。App、平台 PC、闪送、036多店履约和已废弃订单/支付入口均不修改。 - Controller 新请求使用明确 DTO、`@RequestBody`、token Header;GET 参数显式注解,不使用 Map 请求参数、Bean Validation 或单语言硬编码错误;新文案四语言齐全。 - 依赖保持 ruoyi-admin → ruoyi-system;应用层外部路线调用留在 ruoyi-admin,ruoyi-system 不导入 com.ruoyi.app。 - 本机 JDK21 实际路径 `C:/Users/99029/.jdks/graalvm-jdk-21.0.12+7.1`,仅在当前终端设置环境;前端保留 CRLF。 - SQL 仅由实施者按实际日期追加到 `updatesql/sql.md` 最后,开发者手动执行;本次规划不生成已可执行迁移、不连接数据库。 - 现有金额为新台币元口径、订单整数字段沿用当前取整;不要依据旧注释“分”擅自乘100。服务内使用 BigDecimal,边界转换复用渠道现有规则,并以实际请求金额断言验证。 ### Review Focus | 易出错输入/条件 | 必须满足的行为 | 归属 | |---|---|---| | 原价100、运费30、预算20;互斥券切换;候选券列表 | 全免30,不能释放免运预留;实减0的券不核销 | T075/T076 | | 新活动 status=0 已到开始日、已到期但 status=1;并发创建重叠活动 | 按本地日期与未结束状态判断;同店同类型同日仅一个活动 | T074/T075 | | 跨店商品/规格、别人地址、客户端freight=0、商品价格/amount篡改 | 新契约服务端查价和地址归属;差异拒绝,不只记日志 | T075/T076 | | 第2份半价预算豁免与普通满减并存,普通满减被预算截为0 | 新配送流程按应用预算后实际应付选路径,保留半价豁免和手动forcePath | T075 | | 活动结束后的旧订单、支付方式切换、到付交款为负、发票/退款 | 快照不变;交款方向明确;扣客户实际运费;不多退、不重复入账 | T076/T077 | ### A. 现状与接入位置(已核对源码) | 现状 | 实施要求 | |---|---| | `PromotionActivity.type` 目前1~4,规则已有threshold | 增加type=5,复用单条threshold规则,无需新优惠券表 | | `PromotionActivityMapper.selectActiveByStoreId` 与规则查询仅看status=1 | 新type=5使用独立按日期查询,不依赖不存在的状态刷新任务;不顺手重写旧四种活动 | | `PromotionCalcServiceImpl` 177行起已按促销→新客→券分配预算,半价豁免 | 新可信配送上下文先占F;所有普通/互斥/候选券分支一致 | | `UserPromotionCalcController` 类注释仍写“促销→券→新客” | 实施时同步修正为实际顺序及新增免运优先规则;本轮规划没有修改生产注释 | | `PromotionCalcResponse.finalAmount` 实际只有商品应付,没有运费 | 保留旧语义;新增配送和整单应付字段,禁止把免运从商品再减一次 | | `OrderPromotionHelper` 累加全部promotion,且返回couponId不表示实际用券 | 排除type=5商品汇总;增加appliedCouponId,只核销有真实优惠的券 | | `UserOrderController` 直接用item.amount/item.freight,优惠校验仅警告 | 版本2分支须权威重算、比较、固化;旧分支保持原契约 | | `OrderService.setQishouBilling` 以freight计算佣金、税及钱包记录 | 保留原毛配送费和幂等职责,不新增第二笔骑手运费入账 | | `OrderInvoiceService` 两处使用amount−freight | 版本2改用amount−customerDeliveryFee;历史订单保留原口径 | ### B. 范围与实施选择 1. 本次自动免运是type=5促销,沿用“营销管理 → 促销活动”,不向CouponBatch.vue增加领券型功能。商家PC创建、编辑回填、详情、列表、结束、未开始删除均覆盖。 2. 单店平台骑手外送(type=0、服务端判定selfDelivery=0)接入本次新金额流程。自取/堂食、商家自配送和闪送不计算该优惠;036保持暂停。活动门槛按单个门店商品原价及合法规格加价合计,不计配送费,也不跨店合并。 3. 活动按Asia/Taipei本地日期判断;使用日期范围含首尾日。报价/创建时处于范围即生效,预约单在创建时锁权益;后续活动修改/结束不追改订单。status=2明确结束则不参与。 4. 旧App代码不改。新增完整报价契约,版本2订单使用权威金额;旧请求不自动切入,也不在旧订单上静默降价或改运费。此为兼容设计选择,本轮交付不能宣称旧App已展示/使用新免运规则。 5. 原活动/优惠券CRUD路径不重新设计。新完整报价业务参数全部放body;GET简单参数沿用查询参数。 ### C. 活动管理与权限 - `type=5` 固定仅一条规则 `threshold>0`,按现有整数元展示;productId、reduceAmount、discountRate、minQuantity不适用且不得携入其他优惠。门槛由商家填写,100不是硬编码常量。 - 复用活动名称、门店和日期范围。新增 `PromotionActivitySaveRequest` DTO适配既有POST/PUT字段,不破坏原四种类型的请求格式;新增type=5日期按日期范围归一化到本地首日00:00:00和末日23:59:59,响应保持现有yyyy-MM-dd。 - 新活动创建/编辑/详情/删除/结束必须校验操作者对门店的管理权限;避免依赖浏览器传来的storeId。普通商家、夜市主/摊位按仓库既有归属规则解析,不新增角色模型。 - type=5创建/编辑在同一事务内锁定目标门店行,再检查时间重叠并写规则,避免两个请求同时通过检查;不改变旧类型历史行为。 - type=5列表/详情的展示状态及编辑/删除/结束权限统一使用按日期计算的有效状态:未到开始日=0、生效中=1、明确结束或已过结束日=2。不能只在算价时生效、商家页面却一直显示“未开始”或允许修改进行中活动;状态2的人工结束优先,且不增加刷新定时任务。 - 新增mapper方法 `List selectActiveFreeDeliveryByStoreId(Long storeId, Date now)`,XML条件为type=5、status in(0,1)、start_time<=now、end_time>=now;随后按activityId读已有规则。返回多条重叠存量活动应报配置冲突,不能随机挑一条。 - 优惠比例检查器对type=5只校验门槛合法,不预估或限制实际免运额;实减金额取权威配送费。 ### D. 金额模型与计算 设 `O`=商品原价(含服务端核实的规格加价),`R`=原配送费/骑手毛配送费基数,`F`=本活动免运金额,`D`=客户应付配送费,`G`=优惠后商品应付,`A`=客户应付总额。 - O沿既有商品原价汇总规则HALF_UP到整数元后用于门槛与预算,报价/下单必须一致;不重新设计旧商品取整。 - 命中:`F=R`、`D=0`;未命中:`F=0`、`D=R`;`A=G+D`。无配送费时F=0,不产生虚假金额优惠或用券记录。 - 开启预算时 `B=roundHalfUp(O×比例)`(沿现有整数元规则),供商品优惠使用的初始预算 `max(0,B−F)`。F允许大于B,绝不部分免运。 - 后续按商品促销→新客→券占预算;半价胜出仍豁免。互斥券仅释放商品促销/新客占用,不释放F;候选券也用相同预算。开关关闭不做预算封顶。 - 新配送上下文中默认比较应用预算、用户指定券后的两条路径的A,取低者;相等仍选B。forcePath=A/B尊重用户选择。不自动消费未指定的券,不改legacy两参数calculate的历史选路行为。 - 命中免运时couponType=3不可用,返回本地化原因;selected coupon仍可保留在couponId表示请求意图,`appliedCouponId`只在实际减免>0且校验通过时返回。新订单只按appliedCouponId核销;实减0、预算耗尽、无效/过期券不得被核销。 - 未命中时旧免配送费券的既有偏差(历史从餐费扣减)不在本轮全面重做;保留legacy并明确不宣称已修复。新活动命中时必须阻断该券组合,防止重复补贴。 ### E. 可信配送上下文与报价边界 - 在ruoyi-system新增内部DTO `PromotionDeliveryContext(BigDecimal deliveryFee, boolean eligible)`;`IPromotionCalcService` 保留 `calculate(PromotionCalcRequest, Long)`,新增重载 `calculate(PromotionCalcRequest, Long, PromotionDeliveryContext)`。旧调用不自动注入客户端运费;完整报价适配层构造该可信上下文。 - 在ruoyi-admin新增 `FoodDeliveryPromotionQuoteService.quote(FoodDeliveryPromotionQuoteRequest request, Long userId): PromotionCalcResponse`,由新POST报价方法及版本2下单共用。请求详情见contracts,不能接受客户自行给出的免运额或运费作为权威输入。 - 校验商品属于门店、在售、数量1~99;按数据库pos_food价格与FoodSpecRelation/FoodSpecs/FoodSpecsValue关系读取实际规格加价。商品数量、必选/单选约束和规格归属须验证,不能信CartItem.specPrice。已有036查询代码可读作字段参考,不调用其多店配置或恢复036任务。 - 收货地址按InfoAddress.userId验证;门店/地址坐标必须存在且合法。外送/自配送判定以SelfDeliveryService及实际用餐类型为准,不信客户端selfDelivery。 - 原配送费采用现行路线和运价口径:由服务端门店/地址坐标调用现有Google路线能力读取routes[0].legs距离,不接收客户端quli作为权威距离;沿`SysFileInfoController.getZuiyyfei`现有跳表规则(含半公里与千元舍入),用小范围方法封装到报价Service,保留旧GET入口。新调用不得使用已停用Mapbox,也不把地图响应/密钥输出到日志。 - 运价用PosFreightMapper.getyunjia,缺省用getmiuyunjia;数据库当前时间选择与现有口径一致,路线/运价不可用时返回本地化错误,不回退成免费运费或接受客户端freight。单元测试mock路线,不调用真实地图。 ### F. 持久化与创建 | 位置 | 拟新增/保留字段 | 约束 | |---|---|---| | promotion_activity / rule | type增加5、rule.threshold复用 | 不建免运券或新活动表 | | pos_order.freight | 保留既有Double/原列 | 仍是R,不清零、不改单位 | | pos_order | promotion_pricing_version TINYINT NULL | 2=本次完整配送金额契约;历史NULL保留旧行为 | | pos_order | merchant_delivery_subsidy DECIMAL(10,2) NULL | 新流程F,未命中填0;不从餐费再扣 | | pos_order | customer_delivery_fee DECIMAL(10,2) NULL | 新流程D;旧流程NULL读取时回退freight | | pos_order_promotion | 已有promo_type=1、promo_sub_type=5 | JSON记录门槛、O/R/F/D/B、活动ID和名称;不改旧枚举值 | - `PromotionCalcResponse` 新增字段与单位详见contracts;`OrderCreatItem` 新增promotionPricingVersion及cartItems.specValueIds,保留旧字段。新流程food JSON根据真实商品/规格生成兼容原price/otherPrice/number结构,不信客户端food中的价格。 - `UserOrderController.createOrderChild` 对显式version=2单店请求先调用完整报价,校验item.amount、父单actualPayAmount与服务端A一致;原freight字段应提交R,不是0。差异用错误key `promotion.freeDelivery.quoteChanged` 返回并回滚,不静默采用旧金额。 - 新流程同步父/子订单:子amount=A,foodAmount=O(保持商品原价含规格的约定),freight=R、F/D及version=2固化;父totalAmount=O+R、actualPayAmount=A、totalDiscount=(O+R)−A。单店父子一致后才允许后续支付,不重写旧多店输入流程。 - 预算/活动在报价后变化时创建重算并拒绝不一致;创建成功后的支付重试、切换支付方式、取消退款均用订单快照,不再按当前促销重算。 - `OrderPromotionHelper` 增加新流程回填入口,商品减免汇总排除subType=5,写一条type=5快照;按appliedCouponId和实际减免核销,保留事务中的归属/使用状态检查。失败整笔回滚,不留下可支付父单或已核销券。 - Entity、XML resultMap/select/insert/update(含BaseMapper读取)、DTO和查询响应同步;SQL仅追加,不追溯批量修改旧订单。 ### G. 结算、支付与发票 | 场景 | 取餐交付 | 客户端待收 | 配送基数 | |---|---|---|---| | 在线已付 | 商家给骑手R | 0 | R | | 到付A≥R | 骑手给商家A−R | A | R | | 到付A