|
@@ -0,0 +1,178 @@
|
|
|
|
|
+# 满额免运 App 端对接文档(008,2026-10-19)
|
|
|
|
|
+
|
|
|
|
|
+> 后端已上线(分支 `codex/036-multi-store-checkout` 提交 `e907f511`,v2 线 `test-202609v2` 提交 `25d7bcdb`)。
|
|
|
|
|
+> 完整契约见 [contracts/free-delivery.md](./contracts/free-delivery.md);本文面向 App(用户端)开发,只讲怎么接。
|
|
|
|
|
+
|
|
|
|
|
+## 1. 功能与范围
|
|
|
|
|
+
|
|
|
|
|
+- 商家可配置「原价满 X 元免配送费」促销(type=5)。用户**无需领券**,商品原价合计达到门槛自动生效。
|
|
|
|
|
+- 命中后**客户运费实付为 0**,运费由商家承担;骑手配送费照旧按原运费结算,App 不用关心商家与骑手之间的交款。
|
|
|
|
|
+- **报价入口已统一(2026-10-19 追加 FR-037)**:`/deliveryQuote` 支持全部用餐类型——平台骑手外送评估满额免运;商家自配送运费照算但免运不生效;自取/堂食免地址、无运费。闪送、多店合并下单不适用。
|
|
|
|
|
+- 预约单(delryTime 非空)同样支持,配送模式与活动资格都按预约时段判断。
|
|
|
|
|
+
|
|
|
|
|
+## 2. 金额模型(展示口径以此为准)
|
|
|
|
|
+
|
|
|
|
|
+| 符号 | 字段 | 含义 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| O | `originalAmount` | 商品原价合计(含规格加价,不含运费) |
|
|
|
|
|
+| G | `finalAmount` | 商品应付(已扣促销/新客/券)。**沿用旧接口语义,不含运费** |
|
|
|
|
|
+| R | `deliveryFee` | 原配送费(骑手结算基数) |
|
|
|
|
|
+| F | `deliveryDiscountAmount` | 满额免运减免额(命中=R,未命中=0) |
|
|
|
|
|
+| D | `customerDeliveryFee` | **客户实付配送费**(命中=0,未命中=R) |
|
|
|
|
|
+| A | `payableAmount` | **客户整单应付 = G + D**。下单、支付都用这个数 |
|
|
|
|
|
+
|
|
|
|
|
+结算页展示(命中示例:原价 100、运费 30):
|
|
|
|
|
+
|
|
|
|
|
+| 项目 | 金额 |
|
|
|
|
|
+|---|---:|
|
|
|
|
|
+| 商品应付 G | 100 |
|
|
|
|
|
+| 原配送费 R | 30 |
|
|
|
|
|
+| 满额免运优惠 −F | −30 |
|
|
|
|
|
+| **用户实付 A** | **100** |
|
|
|
|
|
+
|
|
|
|
|
+未命中(如原价 99 < 门槛 100):没有免运行,`customerDeliveryFee=30`,实付 = G + 30。
|
|
|
|
|
+
|
|
|
|
|
+## 3. 新接口:完整配送报价
|
|
|
|
|
+
|
|
|
|
|
+**`POST /app/userPromotionCalc/deliveryQuote`**(Header:`token`;与旧 `/calculate` 并存,旧接口行为不变)
|
|
|
|
|
+
|
|
|
|
|
+请求(结算页每次进入/改商品/换券/切路径都调用):
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "storeId": 1,
|
|
|
|
|
+ "orderType": 0,
|
|
|
|
|
+ "addressId": 8,
|
|
|
|
|
+ "delryTime": null,
|
|
|
|
|
+ "items": [{ "productId": 10, "quantity": 2, "specValueIds": [301] }],
|
|
|
|
|
+ "couponId": null,
|
|
|
|
|
+ "forcePath": null
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+各场景返回差异(促销/优惠券/预算规则完全一致,App 无需分支判断,一律读 `payableAmount`):
|
|
|
|
|
+
|
|
|
|
|
+| orderType | addressId | 运费 R | 免运 F | 实付 A |
|
|
|
|
|
+|---|---|---|---|---|
|
|
|
|
|
+| 0 平台骑手外送 | 必填 | 服务端路线跳表算 | 命中门槛则 F=R | G + D |
|
|
|
|
|
+| 0 商家自配送(服务端判定) | 必填 | 同一套跳表算 | 恒为 0 | G + R |
|
|
|
|
|
+| 1 自取 / 2 堂食 | 不需要(传入忽略) | 0 | 0 | = G |
|
|
|
|
|
+
|
|
|
|
|
+要点:
|
|
|
|
|
+- `items` 只传 `productId / quantity(1~99) / specValueIds`。**不传价格、不传运费**——商品价、规格加价、运费全部由服务端按数据库和现行路线/运价计算,传了也不采信。
|
|
|
|
|
+- `couponId` / `forcePath` 语义与旧接口相同;自取/堂食同样会返回促销/券明细与可用券列表。
|
|
|
|
|
+
|
|
|
|
|
+响应 `data` 关键字段(对应上文金额模型):
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "pricingVersion": 2,
|
|
|
|
|
+ "originalAmount": 100,
|
|
|
|
|
+ "finalAmount": 100,
|
|
|
|
|
+ "deliveryFee": 30,
|
|
|
|
|
+ "deliveryDiscountAmount": 30,
|
|
|
|
|
+ "customerDeliveryFee": 0,
|
|
|
|
|
+ "payableAmount": 100,
|
|
|
|
|
+ "freeDeliveryActivityId": 51,
|
|
|
|
|
+ "freeDeliveryThreshold": 100,
|
|
|
|
|
+ "discountBudget": 20,
|
|
|
|
|
+ "remainingDiscountBudget": 0,
|
|
|
|
|
+ "appliedCouponId": null,
|
|
|
|
|
+ "details": [
|
|
|
|
|
+ { "type": "promotion", "subType": 5, "name": "原价满100免配送费", "refId": 51,
|
|
|
|
|
+ "reduce": 30, "originalAmount": 100, "deliveryFee": 30,
|
|
|
|
|
+ "customerDeliveryFee": 0, "threshold": 100 }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "availableCoupons": []
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `details` 里 `subType=5` 的明细就是免运行,直接渲染名称和 `reduce`。
|
|
|
|
|
+- `freeDeliveryActivityId` 非 null 即命中;为 null 未命中(不要伪造免运行)。
|
|
|
|
|
+- **券实际生效以 `appliedCouponId` 为准**(仅实减>0 时返回)。`couponId` 只是用户选择意图,实减为 0 时不会被核销。
|
|
|
|
|
+- 命中免运时 `availableCoupons` 中免配送费券 `usable=false`,`unusableReason` 已本地化("本单已享受满额免运"),直接展示即可。
|
|
|
|
|
+- 路径 A/B 已由服务端按**整单应付 A**(含运费、含预算封顶后)自动选优,App 沿用 `optimalPath` 展示即可;用户手动切换仍传 `forcePath`。
|
|
|
|
|
+
|
|
|
|
|
+## 4. 下单:版本 2 显式接入
|
|
|
|
|
+
|
|
|
|
|
+继续用 **`POST /system/userOrder/createOrder`**,在 `items[0]` 增加 `promotionPricingVersion: 2`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "ddId": "DD10086",
|
|
|
|
|
+ "shdzId": 8,
|
|
|
|
|
+ "type": 0,
|
|
|
|
|
+ "totalAmount": 130,
|
|
|
|
|
+ "actualPayAmount": 100,
|
|
|
|
|
+ "paymentMethod": "2",
|
|
|
|
|
+ "items": [{
|
|
|
|
|
+ "mdId": 1,
|
|
|
|
|
+ "type": 0,
|
|
|
|
|
+ "amount": 100,
|
|
|
|
|
+ "freight": 30,
|
|
|
|
|
+ "foodAmount": 100,
|
|
|
|
|
+ "promotionPricingVersion": 2,
|
|
|
|
|
+ "delryTime": null,
|
|
|
|
|
+ "cartItems": [{ "productId": 10, "quantity": 2, "specValueIds": [301] }],
|
|
|
|
|
+ "couponId": null,
|
|
|
|
|
+ "forcePath": null,
|
|
|
|
|
+ "food": []
|
|
|
|
|
+ }]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**必须遵守:**
|
|
|
|
|
+- `items` 必须只有 1 个、父/子 `type` 都为 0(**版本2下单仍限外送**,含商家自配送门店,其免运快照 F=0);自取/堂食下单继续走旧流程;否则返回 `unsupportedOrder`。
|
|
|
|
|
+- `item.amount` = 报价的 `payableAmount`(A),`item.freight` = 报价的 `deliveryFee`(R)——**免运时 freight 也照传 30,不要改成 0**;父单 `actualPayAmount` = A。服务端会在同一事务内重新报价并逐项核对。
|
|
|
|
|
+- `cartItems` 必传(含 `specValueIds`);`food` 里的价格服务端会忽略并用数据库价格重写。
|
|
|
|
|
+
|
|
|
|
|
+**错误处理:**(业务错误走既有 AjaxResult 错误机制,`msg` 返回**按用户语言本地化后的文案**;下表 key 仅作对照,不要拿 key 字符串做 UI 判断)
|
|
|
|
|
+
|
|
|
|
|
+| key | 场景 | App 处理 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| `promotion.freeDelivery.quoteChanged` | 报价过期/金额被改动(活动结束、改价、运价变化) | 提示"价格已变化",**自动重新调 deliveryQuote 刷新结算页**,让用户确认新金额后再下单 |
|
|
|
|
|
+| `promotion.freeDelivery.duplicateOrder` | ddId 重复提交 | 提示勿重复下单,刷新订单列表 |
|
|
|
|
|
+| `promotion.freeDelivery.unsupportedOrder` | 自取/堂食/多店传了 version=2 下单,或报价时 orderType 不是 0/1/2 | 报价不会遇到(0/1/2 都支持);下单时这些场景不要带 `promotionPricingVersion`,走旧流程 |
|
|
|
|
|
+| `promotion.freeDelivery.freightUnavailable` | 路线/运价暂不可用 | 提示稍后再试,结算页运费显示加载失败,不要默认 0 |
|
|
|
|
|
+| `promotion.freeDelivery.invalidAddress` / `invalidProduct` / `invalidSpec` | 地址不归属/商品下架/规格失效 | 提示并引导重新选择 |
|
|
|
|
|
+
|
|
|
|
|
+## 5. 订单查询侧的新字段
|
|
|
|
|
+
|
|
|
|
|
+订单详情/列表(返回 PosOrder 的接口,如 `getParentOrderDetail`)新增三个**派生交款字段**(仅外送单返回,其余为 null;金额由后端按订单快照和当前支付方式计算,App 只展示、不计算):
|
|
|
|
|
+
|
|
|
|
|
+| 场景(G100/R30/F30/D0/A100) | merchantPayToRider | riderPayToMerchant | customerCollectAmount |
|
|
|
|
|
+|---|---:|---:|---:|
|
|
|
|
|
+| 在线已付 | 30 | 0 | 0 |
|
|
|
|
|
+| 到付(payType=1) | 0 | 70 | 100 |
|
|
|
|
|
+| 到付且 A<R(如 A20/R30) | 10 | 0 | 20 |
|
|
|
|
|
+
|
|
|
|
|
+- 到付场景骑手端向客户收 `customerCollectAmount`;用户切换支付方式后重新查询即返回新方向。
|
|
|
|
|
+- 订单上 `freight` 仍是原运费 R=30、`amount`=A,**不是 bug**,不要在 UI 上把 freight 当客户运费展示;客户运费请用 `customerDeliveryFee`(旧订单该字段为 null 时回退显示 freight)。
|
|
|
|
|
+
|
|
|
|
|
+## 6. 明确不要做的事
|
|
|
|
|
+
|
|
|
|
|
+1. 不要用旧 `/calculate` 的 `finalAmount` 当整单应付——它只是商品应付 G,不含运费。外送结算页请整体切到 `deliveryQuote`。
|
|
|
|
|
+2. 不要客户端自己判断门槛/算免运金额——预算优先级(免运先全额预留、其余优惠用剩余预算、半价豁免)都在服务端,App 展示返回值即可。
|
|
|
|
|
+3. 不要在下单时把 `freight` 清零或传自算运费——服务端核对不一致会拒绝下单。
|
|
|
|
|
+4. 未接入 version=2 前不要在旧下单流程里传免运相关字段——旧契约原样保留,混传会被当普通请求处理或报错。
|
|
|
|
|
+5. 结算页不要按用餐类型分接口——目标态是**所有场景只调 `/deliveryQuote`**;旧 `/calculate` 仅为线上旧版本保留,新版本不要新增调用。
|
|
|
|
|
+
|
|
|
|
|
+## 7. App 验收清单
|
|
|
|
|
+
|
|
|
|
|
+- [ ] 结算页(外送)改调 `deliveryQuote`;展示 G / R / 免运行(−F) / 实付 A
|
|
|
|
|
+- [ ] 门槛边界:原价 100 命中、99 不命中;命中后再加券/促销 G 降低仍免运
|
|
|
|
|
+- [ ] 命中时免配送费券置灰并展示"本单已享受满额免运";实减 0 的券不核销
|
|
|
|
|
+- [ ] forcePath 切换 A/B 后金额正确(服务端已按 A 选优)
|
|
|
|
|
+- [ ] 下单传 `promotionPricingVersion=2`;篡改 amount/freight 触发 quoteChanged 并能自动重新报价
|
|
|
|
|
+- [ ] 到付/在线订单详情三方交款字段正确;切换支付方式后方向更新
|
|
|
|
|
+- [ ] 预约单(delryTime)报价与下单一致
|
|
|
|
|
+- [ ] 自取/堂食结算页调用 /deliveryQuote(A=G,无免运行);下单不带 version=2,行为与旧版一致
|
|
|
|
|
+- [ ] 商家自配送门店结算页:运费正常显示(服务端算),无免运行;version=2 下单成功且 F=0
|
|
|
|
|
+- [ ] 重复点击下单被 duplicateOrder 拦截
|
|
|
|
|
+- [ ] 四语言错误提示正常(错误文案由后端 i18n 返回)
|
|
|
|
|
+
|
|
|
|
|
+## 8. 依赖与未决
|
|
|
|
|
+
|
|
|
|
|
+- 后端依赖数据库执行 `updatesql/sql.md` 末尾 2026-10-19 的 3 条 `ALTER TABLE pos_order`(各环境各自执行后方可下单 version=2)。
|
|
|
|
|
+- 商家端(foodie-store)第五 Tab 已上线,测试商户可自行配置门槛活动。
|
|
|
|
|
+- 骑手端/商家 App 的展示不在本文范围(派生字段已在订单数据中,两端按需取用)。
|