后端已上线(分支
codex/036-multi-store-checkout提交e907f511,v2 线test-202609v2提交25d7bcdb)。 完整契约见 contracts/free-delivery.md;本文面向 App(用户端)开发,只讲怎么接。
/deliveryQuote 支持全部用餐类型——平台骑手外送评估满额免运;商家自配送运费照算但免运不生效;自取/堂食免地址、无运费。闪送、多店合并下单不适用。| 符号 | 字段 | 含义 |
|---|---|---|
| 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。
POST /app/userPromotionCalc/deliveryQuote(Header:token;与旧 /calculate 并存,旧接口行为不变)
请求(结算页每次进入/改商品/换券/切路径都调用):
{
"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 关键字段(对应上文金额模型):
{
"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 已本地化("本单已享受满额免运"),直接展示即可。optimalPath 展示即可;用户手动切换仍传 forcePath。下单接口不变:仍是 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:
{
"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": []
}]
}
结构上只多两个可选字段,其余字段全部沿用:
| 新字段 | 位置 | 作用 | 不传的后果 |
|---|---|---|---|
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 |
地址不归属/商品下架/规格失效 | 提示并引导重新选择 |
订单详情/列表(返回 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)。/calculate 的 finalAmount 当整单应付——它只是商品应付 G,不含运费。外送结算页请整体切到 deliveryQuote。freight 清零或传自算运费——服务端核对不一致会拒绝下单。/deliveryQuote;旧 /calculate 仅为线上旧版本保留,新版本不要新增调用。deliveryQuote;展示 G / R / 免运行(−F) / 实付 ApromotionPricingVersion=2;篡改 amount/freight 触发 quoteChanged 并能自动重新报价updatesql/sql.md 末尾 2026-10-19 的 3 条 ALTER TABLE pos_order(各环境各自执行后方可下单 version=2)。