# 满额免运 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`。**不传价格、不传运费**——商品价、规格加价、运费全部由服务端按数据库和现行路线/运价计算,传了也不采信。`specValueIds` 以商品详情返回的 `foodSku` 选项为准(规格唯一来源为关联关系表;商品没返回规格选项就不要传)。报错文案会带商品ID(如“商品[9115]无效或不属于该门店”),便于定位。 - `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`,没有新增下单接口;`promotionPricingVersion` 只是显式开关,不传就完全走旧流程(旧版本 App 不受任何影响)。 各场景下单方式(算价已统一,下单按场景区分): | 场景 | 结算页报价 | 下单方式 | 服务端处理差异 | |---|---|---|---| | 平台骑手外送 | /deliveryQuote | **传 version=2**(推荐) | 同一事务内重新报价并核对客户端 amount/freight/父单应付,不符返回 `quoteChanged`;父单金额、food JSON、优惠快照全部以服务端为准 | | 商家自配送 | /deliveryQuote | **传 version=2**(推荐) | 与上完全同流程,免运快照 F=0;运费结算归商家由后端订单快照 `selfDelivery=1` 处理,App 无感知 | | 自取 / 堂食 | /deliveryQuote(A=G) | **旧流程,不带 version=2** | 下单字段结构与旧版一致(food/amount 原样传);服务端不重算,金额以结算页报价为准;误传 version=2 返回 `unsupportedOrder` | | 旧版本 App | /calculate | 旧流程 | 完全不变 | 版本2请求体(外送示例,自取/堂食不要带 version=2),在 `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": [] }] } ``` ### 入参相对旧契约的变化(版本2) **结构上只多两个可选字段**,其余字段全部沿用: | 新字段 | 位置 | 作用 | 不传的后果 | |---|---|---|---| | `promotionPricingVersion` | `items[0]` | 传 `2` 显式接入新契约:服务端同事务重算并固化快照;不传则完全走旧流程(新旧 App 共存、灰度、回滚都靠它) | 旧流程处理:不算免运、不重算不核对、金额按客户端传入入库 | | `specValueIds` | `items[0].cartItems[m]` | 只报"选了哪些规格值",规格加价由服务端查数据库计算(旧 `specPrice` 是客户端自报金额,不采信) | 无规格商品可不传;有规格商品必传,缺失/伪造返回 `invalidSpec` | 旧流程已有字段在版本2下的**取值来源变化**: | 字段 | 旧流程 | version=2 | |---|---|---| | `items[0].amount` | 客户端自算 | **必须 = 报价 `payableAmount`(A)**,服务端核对 | | `items[0].freight` | 客户端自算 | **必须 = 报价 `deliveryFee`(R)**,免运时也传 R 不传 0 | | `input.actualPayAmount` | 客户端传 | 必须等于 A | | `input.totalAmount` / `totalDiscount` | 客户端传什么存什么 | 传了也被服务端覆盖(O+R / (O+R)−A) | | `items[0].food` / `foodAmount` / `specPrice` | 客户端数据原样入库 | 服务端按数据库价格重写,客户端值不采信 | **报价返回字段的回传规则**:下单只需回传两个数——`payableAmount`(→ `amount` 和父级 `actualPayAmount`)与 `deliveryFee`(→ `freight`)。F/D、freeDeliveryActivityId、appliedCouponId 等其余扩展字段**一律不用传**(服务端重算后自行固化);`merchantPayToRider`/`riderPayToMerchant`/`customerCollectAmount` 是查询侧只读派生值,任何接口都不接收。 **必须遵守:** - `items` 必须只有 1 个、父/子 `type` 都为 0(**版本2下单限外送**:平台骑手与商家自配送门店均可,自配送单免运快照 F=0);自取/堂食下单走旧流程不带 version=2;违反返回 `unsupportedOrder`。 - `item.amount` = 报价的 `payableAmount`(A),`item.freight` = 报价的 `deliveryFee`(R)——**免运时 freight 也照传 30,不要改成 0**;父单 `actualPayAmount` = A。服务端会在同一事务内重新报价并逐项核对。 - `cartItems` 必传(含 `specValueIds`);`food` 里的价格服务端会忽略并用数据库价格重写。 **与计算阶段的衔接:** version=2 下单时服务端会用**下单时刻**的最新报价重算(活动可能已结束/运价变化),所以结算页拿到的金额若已过期,下单会收到 `quoteChanged`——App 重新调 deliveryQuote 刷新后让用户确认即可,不要静默重试提交。 **错误处理:**(业务错误走既有 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