# 多店合并下单(036)App 接入文档 本文档供用户端 App 和骑手端 App 接入多店合并下单功能(036)使用,以当前后端实现为准。多店功能的全部业务规则均写在本文档内,接入不需要再查阅其他契约文件。文档只描述接口契约和业务规则,不包含 App 前端实现代码;本仓库不修改 App 源码。平台后台、商家端的 036 管理接口不在本文范围。 > 更新时间:2026-10-21。036 数据库迁移已在测试库执行并核对表结构;生产库尚未执行。平台多店开关仍关闭、6个相关定时任务保持暂停:接口已通过本地测试,真实支付/退款与App端到端联调尚未验证。测试库无需重复迁移;后续按联调计划部署兼容版本并开启配置和相应任务,生产库须另行执行迁移。 核心概念(先读这段): - 用户一次选购**至少 2 家门店**、由**同一骑手**合并配送:报价 → 逐店选择支付方式并创建批次 → 完成线上支付(到付已在创建时确认)→ 全部子单就绪后批次生效 → 骑手**整组接单**、**逐店**取餐送达。 - 每家店是一个独立**子单**(有自己的 orderId/orderNo 和支付方式);**批次(batch)**是收银单位,**配送组(group)**是履约单位。 - App 一律按服务端返回渲染状态、金额和可操作按钮;不在本地推算倒计时、重算费用或推断可操作性。 配送距离和费用由服务端统一计算,App直接展示报价结果,不自行计算道路距离或运费;创建批次时服务端仍会重新校验。2026-10-20后端批量路线优化不改变本文请求参数、响应字段或调用顺序。 父单号和子单号沿用`99`开头的纯数字格式(父15位、子18位),接口字段类型仍为字符串;App必须原样展示和回传,禁止转为JavaScript Number或用前缀判断多店身份,多店身份以服务端卡片/批次字段为准。 ## 1. 功能范围 当前支持: - 用户端:多店报价、逐店选择到付/线上支付(LINE Pay、OMG 信用卡、OMG Apple Pay)并创建批次、安全改选、批次详情、组合订单列表(普通单卡与批次卡同列返回)、整批取消、单店取消。 - 骑手端:组合任务列表(普通单卡与组卡同列返回)、角标、组详情、整组接单;逐店取餐/送达复用现有单笔接口。 - 即时配送与预约配送(全批次同一预约时段)。 当前不包含(继续走原流程,036 不改动): - 普通单店外卖、闪送、自取、堂食现金、夜市自配送、单店转账的下单与取消。 - 在线支付新渠道:建批时逐店提交 `payType`,线上付款仍调用现有 LINE/OMG 创建接口;首次付款不需要另调 5.3 的改选接口。 - 多店改址:批次创建后收货地址冻结,换地址须取消后重新报价下单。 ## 2. 通用约定 ### 2.1 基础地址和请求头 接口路径均为服务端相对路径,实际请求地址为 `{baseUrl}{接口路径}`;`/prod-api` 等网关前缀由 App 现有请求层继续添加。 | 请求头 | 必填 | 说明 | |---|---:|---| | `token` | 是 | 登录 JWT。用户/骑手身份均由 token 解析,请求体与查询参数中不提交 userId/riderId | | `X-Multi-Store-Capability` | 是 | 固定值 `batch-cards-v1`。本文全部 `/system/multiStoreCheckout` 接口,以及 036 子单调用原 `POST /pay/line/create`、`POST /pay/omg/create`、`POST /pay/omg/retry` 时都必须携带 | | `Content-Type` | POST 必填 | `application/json` | 能力头规则: - 能力头缺失或值不是 `batch-cards-v1` 时,返回 `code=500` 且带 `errorCode=UPGRADE_REQUIRED`,msg 为"请更新应用后使用多店结算和配送组功能"。App 收到后应引导升级。 - 普通单调用原 LINE/OMG 接口不要求此头;旧版 App 不能通过原网关入口绕过逐店收银(服务端已对 036 子单强制校验)。 ### 2.2 统一响应 ```json { "code": 200, "msg": "操作成功", "data": {} } ``` - 以响应体 `code=200` 判断业务成功,不能只看 HTTP 状态码;`msg` 已由后端国际化,可直接展示。 - 除 `UPGRADE_REQUIRED` 外,其他业务失败没有稳定的 `errorCode`:展示 `msg` 并刷新详情,不要按 errorCode 写分支逻辑。 ### 2.3 分页响应 列表接口统一返回: ```json { "records": [], "total": 100, "current": 1, "size": 10 } ``` - `page` 从 1 起;`size` 为 1–50 的正整数,超过 50 报错。 - `total` 是**卡片数**(一个批次/一个组各算 1 张),不是子单数。 ### 2.4 时间与金额 - 金额单位为新台币**元**(整数),不是分。 - 时间为服务端时间字符串(形如 `2026-10-21T18:00:00`),直接展示,不在 App 重算付款截止或倒计时。 ## 3. 业务模型与状态枚举 ### 3.1 生命周期 ```text 报价(quote) → 建批并提交逐店 payType(配送组创建为 WAITING)→ 线上子单逐店支付(如有)→ 全部就绪 ⇒ ACTIVE └→ 超时/整批取消/预约过期 ⇒ CANCELLED 开放:ACTIVE 且即时/已到预约开放时点 → openedAt 有值、子单可见 → 骑手可抢 配送组:WAITING(建批时即存在,openedAt 有值后可抢)→ ACCEPTED(已接,逐店取送)→ FINISHED / CANCELLED ``` ### 3.2 批次状态 `checkoutStatus` | 值 | 含义 | |---|---| | `PENDING` | 未生效:处于逐店收银与缓冲核实期,订单不可履约、不可抢 | | `ACTIVE` | 全部子单合法就绪;配送组在建批时已创建,须等 `openedAt` 有值才进入骑手待抢列表 | | `CANCELLED` | 已关闭:未全部就绪超时、用户整批取消或预约过期关闭 | `PENDING` 可能已有部分子单实际付款,须逐店看 `paymentState`。`ACTIVE` 只表示批次就绪,不保证已经开放或推送:即时单/已到开放时点的预约单在事务提交后立即尝试开放;未到时点的预约单等待任务扫描。骑手端以组卡的 `openedAt` 和任务列表为准,不能仅凭 `checkoutStatus=ACTIVE` 显示可抢。 ### 3.3 子单付款状态 `paymentState` | 值 | 含义 | |---|---| | `UNSELECTED` | 旧批次尚未选择支付方式;新建批次不会产生此状态 | | `ONLINE_PENDING` | 已选线上方式(LINE/OMG),等待用户发起并完成支付 | | `ONLINE_PAID` | 服务端收到渠道可信回调,已实际收款 | | `COD_CONFIRMED` | 用户已确认到付,交餐时向骑手付款 | `ONLINE_PENDING` 仅表示"已选方式",不是实收;支付回跳后必须刷新批次详情,以服务端状态为准。 ### 3.4 支付方式 `payType` | 值 | 含义 | 说明 | |---|---|---| | `"1"` | 到付 | 建批时即确认,无需网关 | | `"2"` | OMG 信用卡 | 建批返回 ONLINE_PENDING 后调 `/pay/omg/create` | | `"3"` | LINE Pay | 建批返回 ONLINE_PENDING 后调 `/pay/line/create` | | `"5"` | OMG Apple Pay | 建批返回 ONLINE_PENDING 后调 `/pay/omg/create` | | `"6"` | 线下转账 | **新批次不支持**,提交即被拒 | `availablePayTypes` 由服务端按门店返回(可能不含某些方式,如商家不可用到付),App 只按返回渲染。 ### 3.5 卡片 `cardType` / `cardKey` | cardType | cardKey | 出现在 | |---|---|---| | `ORDER` | `ORDER:{orderId}` | 用户订单列表、骑手任务列表(普通单,字段在 `legacyOrder`,点击走原普通单详情) | | `BATCH` | `BATCH:{batchId}` | 用户订单列表(多店批次) | | `GROUP` | `GROUP:{groupId}` | 骑手任务列表(同骑手配送组,一组一卡) | `cardKey` 是列表唯一键,分页排序平局时也以它稳定打破。`primaryTab` 是服务端判定的主页签,App 不自行推导。 ### 3.6 页签 `tab` | 端 | 取值 | 说明 | |---|---|---| | 用户端 | `all` / `pending` / `active` / `completed` / `cancelled` / `refund` | `refund` 是补充筛选:同一张卡可同时出现在 `active` 和 `refund`,但在 `all` 只出现一次 | | 骑手端 | `newTask` / `toPickup` / `delivering` / `completed` / `cancelled` / `refund` | 只要有有效子单未取完,组始终在 `toPickup`(即使另一子单已送达);有效子单全部取完且仍有待送才进 `delivering` | 不要先对子单分页再在 App 拼卡;服务端已先聚合后分页。 ### 3.7 `allowedActions`(仅用于展示按钮) | 动作 | 出现在 | 含义 | |---|---|---| | `SELECT_PAYMENT` | BATCH 卡 | PENDING 且未过 `operationDeadlineAt`,可继续逐店收银 | | `CANCEL_BATCH` | BATCH 卡 | PENDING,可尝试整批取消 | | `CANCEL_MEMBER` | BATCH 卡 | ACTIVE 且配送组待接,可尝试单店取消 | | `ACCEPT_GROUP` | GROUP 卡 | 组待抢,可整组接单 | | `PICKUP_MEMBER` / `DELIVER_MEMBER` | GROUP 卡 members[] | 本人已接组后,该店当前可取餐/可送达的提示 | 按钮提交时服务端会重新校验(骑手抢单、状态变化都可能拒绝),失败后刷新详情,不在本地重算余店运费。 ### 3.8 金额与运费口径 逐店金额字段(报价 `stores[]` 与卡片 `members[]` 同名同义): | 字段 | 说明 | |---|---| | `originalFoodAmount` / `foodAmount` | 商品原价合计 / 优惠后商品金额 | | `originalFreight` / `discountedFreight` | 原运费 / 多店折扣后运费(骑手毛运费) | | `customerDeliveryFee` | 客户支付的运费 | | `merchantDeliverySubsidy` | 商家满额免运承担的补贴 | | `amount` | 用户应付 = `foodAmount + customerDeliveryFee` | | `codToMerchantAmount` | 到付子单给商家的金额 = `orderAmount - discountedFreight` | 商家满额免运以**多店折后运费**为基数。例:原运费 40、八折后 32、全免时客户运费 0、商家补贴 32、骑手仍收 32。普通单的免运口径不变。 报价是短期快照,服务端建批时会重算并比对;App 不提交自算金额。 ## 4. 推荐调用流程 ### 4.1 用户端 1. 按顺序选择至少两家店与商品 → `POST /system/multiStoreCheckout/quote`。 2. 展示参与店金额、各店 `availablePayTypes` 与 `excludedStores[]`;用户为**每家参与店**选择付款方式,按 `storeId` 组成 `paymentMethods`。发票选择沿普通订单整批只选一份,服务端只给可开票参与店子单保存意图;然后一次提交 `POST /system/multiStoreCheckout/batches`,保存 `batchId` 与各店 `orderId/orderNo`。圈外店不创建订单,引导剔除或另行单店下单。 3. 建批返回的到付子单已是 `COD_CONFIRMED`,无需再次确认;线上子单为 `ONLINE_PENDING`,用该店 `orderNo` 调原渠道创建接口发起付款。仅在用户随后改选方式时调用 `POST /system/multiStoreCheckout/batches/members/payment`(见 5.3)。 4. 支付回跳后刷新 `GET /system/multiStoreCheckout/batches/{batchId}`,以服务端状态为准。 5. 订单列表用 `GET /user/cards` + `GET /user/cardCount`;不要调旧列表接口看多店单(旧接口不返回 036 子单/批次)。 ### 4.2 骑手端 1. 保持现有定位上报(`POST /system/position/setRiderposition`):待抢组资格用服务端最近位置与首店距离判断。 2. 列表 `GET /rider/tasks`、角标 `GET /rider/taskCount`;组卡一张 = 一组。 3. 点组卡 → `GET /groups/{groupId}` → 确认接单 `POST /groups/accept`(带 `version`),成功后刷新组详情。 4. 按 `members[].orderNo` 调原单笔接口逐店取餐/送达(见 6.5);不要按成员逐单抢、也没有"整组送达"按钮。 ## 5. 用户端接口 以下接口均需 `token` + `X-Multi-Store-Capability: batch-cards-v1`。 ### 5.1 多店报价 ```http POST /system/multiStoreCheckout/quote ``` 请求体示例(ID 须替换为真实值): ```json { "addressId": 123, "sameRider": true, "stores": [ { "storeId": 101, "deliveryWindow": null, "items": [{ "productId": 1001, "quantity": 1, "specValueIds": [] }], "couponId": null, "forcePath": null, "remarks": null }, { "storeId": 102, "deliveryWindow": null, "items": [{ "productId": 2001, "quantity": 2, "specValueIds": [] }], "couponId": null, "forcePath": null, "remarks": null } ] } ``` | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `addressId` | long | 是 | 收货地址 ID,须属于当前用户 | | `sameRider` | boolean | 是 | 当前版本必须传 `true`(仅支持同骑手合并配送),传 false 报错 | | `stores` | array | 是 | 按用户选择顺序排列,至少 2 家;**首店是合并围栏与骑手接单距离的固定锚点** | | `stores[].deliveryWindow` | string | 否 | `null`=即时配送;预约传 `"yyyy-MM-dd HH:mm-HH:mm"`(22 字符,如 `2026-10-21 18:00-18:30`)。**所有店必须完全相同**,不得混合即时与预约 | | `stores[].items[].productId` | long | 是 | 商品 ID;只提交商品/规格 ID,客户端价格不作为计价依据 | | `stores[].items[].quantity` | int | 是 | 数量 | | `stores[].items[].specValueIds` | long[] | 是 | 规格 ID 列表,无规格传 `[]` | | `stores[].couponId` | long | 否 | 门店优惠券 ID,无传 `null` | | `stores[].forcePath` | string | 否 | 预留字段,正常流程传 `null` | | `stores[].remarks` | string | 否 | 逐店订单备注,随报价快照固化到对应子单 `remarks` 字段(口径同普通单逐店备注),不参与计价;无备注传 `null` | 建批后的子单 `food` 商品快照由服务端按 `specValueIds` 查数据库生成:有规格时同时返回 `ask`(规格名称数组,兼容原订单详情展示)和 `specValueIds`(规格值 ID 数组)。免费规格的 `otherPrice` 可以为 0,但 `ask` 仍应展示;不要只传客户端的 `ask` 文本代替规格 ID。此修复只影响新创建的版本 2 快照,不回写历史订单。 响应 `data`: | 字段 | 类型 | 说明 | |---|---|---| | `quoteToken` | string | 报价令牌,建批时提交 | | `expiresAtEpochMillis` | long | 报价过期时间(毫秒时间戳) | | `ruleVersion` | long | 规则版本;平台改折扣配置后旧报价不可用 | | `anchorStoreId` | long | 锚点门店 ID(首店) | | `fenceMeters` | int | 合并围栏半径(米),首店到其他店的距离上限 | | `participatingStoreIds` | long[] | 可参与的门店 ID 列表 | | `stores[]` | array | 逐店报价,字段见 3.8,另有 `availablePayTypes` | | `stores[].routeDistanceMeters` | number | 服务端矩阵计算的该店到收货地址道路米数;建批时重新报价,并固化为子单 `jvli`(公里) | | `excludedStores[]` | array | 圈外/不兼容门店:`{storeId, reason}`。**不自动创建订单**,须剔除或另行结算 | | `freightRatio` | number | 当前折扣系数 | | `fingerprint` | string | 报价指纹(服务端内部使用,App 原样忽略即可) | 规则: - 店/商品/配送资格不合格、支付方式仅剩转账的店会进 `excludedStores` 并给 `reason`;被排除到参与店不足 2 家时报价报错,错误 msg 会点名被排除门店与原因(如"以下门店无法参与多店配送:快食到家(超出与首店的配送范围)。参与门店不足2家,请调整门店后重试")。 - 报价短期有效,建批时服务端重算比对,商品/价格/规则变化会拒绝建批(msg="商品、规格或优惠信息已变化,请重新结算")。 ### 5.2 创建批次 ```http POST /system/multiStoreCheckout/batches ``` 请求体: ```json { "requestKey": "本次提交固定唯一键", "quoteToken": "报价返回值", "participatingStoreIds": [101, 102], "paymentMethods": [ { "storeId": 101, "payType": "3" }, { "storeId": 102, "payType": "1" } ], "invoiceChoice": "COMPANY", "buyerUbn": "12345678", "buyerName": "甲公司", "buyerEmail": "invoice@example.com", "saveAsDefault": false } ``` | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `requestKey` | string | 是 | 幂等键:网络重试须沿用同一键、同一报价与逐店支付选择,才返回原批次;同键改动载荷会被拒,换新键可能创建新批次 | | `quoteToken` | string | 是 | 5.1 返回的报价令牌;过期报"报价已过期,请重新获取" | | `participatingStoreIds` | long[] | 是 | 用户明确确认参与的门店,须是报价参与店的子集且 ≥2 家;夹带圈外店会被拒 | | `paymentMethods` | array | 是 | 与参与店一一对应的 `{storeId,payType}`;每店恰好一条,`payType` 是字符串且须在该店报价的 `availablePayTypes` 内;不支持转账 `"6"` | | `invoiceChoice` | string | 否 | 与普通订单相同:`PAPER`、`PHONE_BARCODE`、`CITIZEN`、`MEMBER`、`LOVE_CODE`、`COMPANY`;不传则不保存发票意图。整批只选一次,不是逐店数组 | | `carrierType` / `carrierNum` | string | 按选择 | 手机条码/自然人凭证使用普通订单同名字段与校验;服务端按选择规范化载具类型 | | `buyerUbn` / `buyerName` / `buyerEmail` | string | `COMPANY` 时 | 公司统编、名称及邮箱,沿普通订单校验;每个可开票子单各自开票 | | `loveCode` | string | `LOVE_CODE` 时 | 捐赠码,沿普通订单联网校验 | | `saveAsDefault` | boolean | 否 | 与普通订单相同;仅支持保存载具/公司抬头,纸本、会员及捐赠不保存 | 不要只传普通单店请求的顶层 `paymentMethod` 或顶层 `payType`:多店建批只读取逐店的 `paymentMethods`,缺少、重复、夹带非参与店或选了该店不可用的方式都会被拒。电子发票至少需要一个参与店可开票;混合门店时不可开票的子单不生成发票意图,纸本选择按普通订单处理。建批只是保存意图,出餐后由各子单原开票流程处理,不代表提交时已经开票。网络重试须保持同一个 `requestKey`、`quoteToken`、参与店、逐店方式和发票字段;**创建成功后要改方式,调用 5.3,不要用同一个 `requestKey` 重发不同的付款或发票资料**。 响应 `data`: | 字段 | 类型 | 说明 | |---|---|---| | `batchId` | long | 批次 ID,后续接口都用它 | | `parentOrderNo` | string | 父批次号 | | `checkoutStatus` | string | 含未付款线上子单时为 `PENDING`;全到付时创建即为 `ACTIVE`。`ACTIVE` 不等于骑手已可抢,实际开放看组 `openedAt` 与骑手列表 | | `totalAmount` | int | 批次总金额(TWD 元) | | `operationDeadlineAt` | string | 操作截止(创建后 15 分钟,见 8.1) | | `finalDeadlineAt` | string | 最终截止(创建后 20 分钟,见 8.1) | | `members[]` | array | `{orderId, orderNo, storeId, amount, payType, paymentState, availablePayTypes[]}`,逐店保存好 orderNo | 注意:幂等重试返回的 `availablePayTypes` 可能为空数组,**不能**当作门店永久不支持付款;以最新报价和付款接口校验为准。 建批响应中的各店 `payType` / `paymentState` 是本次已保存的选择。全到付批次会在建批事务提交后立即尝试开放即时单;若是未到开放时点的预约单,仍保持 `ACTIVE`、等待开放,App 不要因 `ACTIVE` 提前展示可抢或已推送。 以上示例中,101 店返回 `payType="3"`、`paymentState="ONLINE_PENDING"`,须继续发起 LINE Pay;102 店返回 `payType="1"`、`paymentState="COD_CONFIRMED"`,不再调 5.3 确认到付。只有两店均选到付时,建批结果才可直接为 `ACTIVE`;此时不能再改选支付方式。 ### 5.3 逐店安全改选支付方式 ```http POST /system/multiStoreCheckout/batches/members/payment ``` 请求体: ```json { "batchId": 7, "orderId": 701, "payType": "3", "clientRequestId": "本次操作唯一键" } ``` | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `batchId` / `orderId` | long | 是 | 批次与该店子单 | | `payType` | string | 是 | `"1"` 到付 / `"2"` `"3"` `"5"` 线上(见 3.4);`"6"` 转账不支持 | | `clientRequestId` | string | 是 | 本次操作唯一键,非空且 ≤64 字符 | 响应 `data`(字符串): | 场景 | 返回 | |---|---| | `payType="1"` 改选到付 | 该店改选后的批次状态:`"PENDING"`(还有店没就绪)/ `"ACTIVE"`(全部就绪已生效)/ `"CANCELLED"`(预约已过期被关闭) | | `payType="2"/"3"/"5"` 线上选择 | 固定 `"ONLINE_PENDING"`,随后调 5.4 发起真实付款 | 规则: - 新批次已在 5.2 保存逐店方式,首次支付无需调用本接口;本接口仅用于用户修改方式。批次须 `PENDING` 且未过 `operationDeadlineAt`,`ACTIVE` 后不可再改;改选成功后刷新 5.5 批次详情。 - 线上→线上、线上→到付、到付→线上只在该店**从未发起网关交易**时允许;一旦有网关尝试记录,含结果未明的尝试,拒绝改选,先沿原渠道查询或完成支付。已真实付款不可改。相同方式重试保持幂等。 - 该方式必须在门店 `availablePayTypes` 内;不可用会被拒。 ### 5.4 发起线上支付(原渠道接口) 拿到 `ONLINE_PENDING` 后,用该店 `orderNo` 调原创建接口: | payType | 接口 | 请求体 | |---|---|---| | `"3"` LINE Pay | `POST /pay/line/create` | `{ "ddId": "该店 orderNo" }` | | `"2"` OMG 信用卡 | `POST /pay/omg/create` | `{ "orderId": "该店 orderNo", "paymentMethod": "CREDIT" }` | | `"5"` OMG Apple Pay | `POST /pay/omg/create` | `{ "orderId": "该店 orderNo", "paymentMethod": "APPLE_PAY" }` | - 036 子单调这些接口(含 OMG 重试 `POST /pay/omg/retry`)同样必须带能力头。 - 5.3 只登记选择,不替代网关创建,也不返回跳转凭证。 - 实收以服务端可信回调为准;支付回跳/ONLINE_PENDING 都不代表实收,回跳后刷新批次详情。 ### 5.5 批次详情 ```http GET /system/multiStoreCheckout/batches/{batchId} ``` 路径参数 `batchId`。响应 `data` 为批次卡对象(字段见 7.1/7.2):逐店支付选择、真实实收、到付确认、退款与履约状态、两个截止时间、`activatedAt`、`allowedActions`。非本人批次拒绝,不暴露其他用户的父/子/组。 ### 5.6 用户卡片列表 ```http GET /system/multiStoreCheckout/user/cards?page=1&size=10&tab=all ``` | 参数 | 必填 | 说明 | |---|---:|---| | `page` | 否 | 默认 1 | | `size` | 否 | 默认 10,上限 50 | | `tab` | 否 | 默认 `all`,取值见 3.6 | 响应 `data` 为分页结构(见 2.3),`records[]` 为卡片对象(见 7.1): - `ORDER` 卡:原订单快照在 `legacyOrder`,展示与点击继续走原普通单详情。 - `BATCH` 卡:通过 `batchId` 打开 5.5 批次详情。 ### 5.7 各页签卡片数 ```http GET /system/multiStoreCheckout/user/cardCount ``` 无查询参数。响应 `data`: ```json { "all": 12, "pending": 1, "active": 2, "completed": 8, "cancelled": 3, "refund": 1 } ``` ### 5.8 整批取消(仅未生效) ```http POST /system/multiStoreCheckout/batches/cancel ``` ```json { "batchId": 7, "reason": "USER_CANCEL", "clientRequestId": "本次操作唯一键" } ``` - `reason` 固定传 `"USER_CANCEL"`;`clientRequestId` 非空且 ≤64 字符。 - 仅批次 `PENDING` 可整批取消;已 `ACTIVE` 须走 5.9 单店取消。 - 成功 `data=true` **只表示取消已受理**,不表示渠道退款到账;退款进度看 5.5 详情。 ### 5.9 单店取消(仅已生效且骑手未接组) ```http POST /system/multiStoreCheckout/batches/members/cancel ``` ```json { "batchId": 7, "orderId": 701, "reason": "USER_CANCEL", "clientRequestId": "本次操作唯一键" } ``` - 仅批次 `ACTIVE` 且配送组仍待接(`WAITING`)时允许;骑手已接组后用户不能主动取消。 - 成功 `data=true` 只表示取消已受理;其他成员费用快照不变、不追补余店运费;退款进度看详情。 ## 6. 骑手端接口 以下接口均需骑手 `token` + `X-Multi-Store-Capability: batch-cards-v1`。定位上报继续用现有 `POST /system/position/setRiderposition`。 ### 6.1 任务列表 ```http GET /system/multiStoreCheckout/rider/tasks?page=1&size=10&tab=newTask&longitude=121.5&latitude=25.03 ``` | 参数 | 必填 | 说明 | |---|---:|---| | `page` / `size` | 否 | 默认 1/10,size 上限 50 | | `tab` | 否 | 默认 `newTask`,取值见 3.6 | | `longitude` / `latitude` | 否 | 骑手坐标:计算 `juli` 距离(列表与组详情同口径)并用于普通单排序。**不能替代服务端位置**:待抢组资格仍以服务端最近上报位置与首店距离判定,二者须同时传或同时不传 | 响应 `data` 为分页结构,`records[]` 中 `GROUP` 卡一张 = 一组(角标与列表同口径,组只计一次)。 普通单卡与组卡 `members[]` 均附带与旧骑手列表逐单一致的信息:`legacyOrder`(订单全量快照)、 `store`(门店对象)、`user`(用户对象)、`goodsCount`(food 快照件数合计,口径同统一列表: 普通单卡为本单件数,批次/组卡为有效成员合计,food 缺失或非法为 null)。 距离字段统一口径(列表、组详情一致): | 字段 | 出现位置 | 含义 | 计算前提 | |---|---|---|---| | `juli` | 普通单卡、组卡 `members[]` | 骑手请求定位 → 订单坐标的直线距离(公里,两位小数) | 请求带 `longitude`/`latitude`,不传为 null | | `anchorDistanceMeters` | GROUP 卡 | 服务端最近骑手位置 → 首店取餐坐标(米),待抢排序也用它 | 服务端定位上报;待抢资格要求 5 分钟内新鲜 | | `jvli` | 组卡 `members[]` | 门店 → 收货地址的配送路线距离(公里,订单快照) | 无需参数,取自 `legacyOrder.jvli`,可能为空 | 待抢组只在批次已生效且达到原预约开放时点、组 `openedAt` 已写入后可见;新任务有有效坐标时按首店距离升序,其余按创建时间方向。最后成员就绪并提交后,服务端立即尝试开放即时/已到点批次;若失败,由原分钟任务重试,预约未到也由该任务到点开放。App 不依赖固定一分钟延迟,付款后可刷新列表,未出现时以服务端状态为准;对外 URL 保持不变。 ### 6.2 各页签任务数(角标) ```http GET /system/multiStoreCheckout/rider/taskCount?longitude=121.5&latitude=25.03 ``` 参数同 6.1:`longitude`/`latitude` 可选、成对传入;待抢(newTask)计数按服务端最近位置与距离上限字典判定,不依赖请求坐标。 响应 `data`: ```json { "newTask": 3, "toPickup": 1, "delivering": 2, "completed": 9, "cancelled": 1, "refund": 0 } ``` ### 6.3 组详情 ```http GET /system/multiStoreCheckout/groups/{groupId}?longitude=121.5&latitude=25.03 ``` 路径参数 `groupId`;可选 `longitude`/`latitude`(骑手定位,传入后 `members[].juli` 按定位计算,不传为 null)。响应 `data` 为组卡对象(见 7.1/7.2),关键字段: - `groupStatus`:`WAITING` 待抢 / `ACCEPTED` 已接 / `FINISHED` 已结束 / `CANCELLED` 已取消。 - `version`:整组接单的乐观锁版本。 - `openedAt`:组实际开放时点;`anchorDistanceMeters`:距首店距离。 - `allowedActions`:待抢时 `ACCEPT_GROUP`;本人已接组后 `members[]` 内出现 `PICKUP_MEMBER` / `DELIVER_MEMBER`。 - `codToCollectAmount`:有效到付子单的待收总额。 成员摘要(含待抢阶段)附带 `legacyOrder`/`store`/`user`/`juli`/`jvli`/`goodsCount`, 其中 `juli` 为骑手定位到该店订单坐标的直线距离(公里,两位小数,口径同 6.1 距离字段表):传入 `longitude`/`latitude` 时计算,不传或订单无坐标时为 `null`;`jvli` 是该店到收货地址的道路距离(公里),与 `legacyOrder.jvli` 一致。组内每店距离不同,组卡顶层不提供单一 `jvli`。历史子单若建单时未保存距离则保持 `null`,不把直线距离伪装成原道路距离。 其他订单字段与旧骑手列表逐单口径一致, 可直接复用旧列表项渲染;已接单后逐店履约仍按子单详情读取(见 6.5)。如产品后续要求待抢阶段 脱敏联系人与地址,须另行变更口径并同步两端。 ### 6.4 整组接单 ```http POST /system/multiStoreCheckout/groups/accept ``` ```json { "groupId": 9, "version": 0, "clientRequestId": "本次接单唯一键" } ``` | 字段 | 类型 | 必填 | 说明 | |---|---|---:|---| | `groupId` | long | 是 | 组 ID | | `version` | long | 是 | 列表/详情返回的版本号;不符返回"配送组状态已变化,请刷新后重试" | | `clientRequestId` | string | 是 | 本次接单唯一键,非空且 ≤64 字符 | - 一次调用**原子承接全部有效成员**;不要按成员逐单抢,旧单笔接单 API 对组成员拒绝拆抢(msg="多店配送组必须整组接单")。 - 成功 `data` 返回组 ID;服务端在同一骑手锁内复核资格、位置时效、首店距离、容量与成员状态,并发接组只能一方成功。 - 位置类失败:位置过期 →"骑手位置已过期,请更新定位";距首店过远 →"距离首店过远,无法接单"。接单距离上限沿用平台字典,缺失时待抢组不可见。 ### 6.5 逐店取餐 / 送达(复用原接口) 接组后按 `members[].orderNo` 调原单笔接口: ```http GET /system/orderQsOprate/getOrderDetial?ddId={该店 orderNo} -- 取已归属骑手的完整订单/地址 POST /system/orderQsOprate/pickupOrder -- body: { "id": 701, "qsImg": "现有图片值" } POST /system/orderQsOprate/deliverOrder -- body: { "id": 701, "qsImg": "现有图片值" } ``` - `id` 是该店子单的 orderId;`qsImg` 语义与普通单一致。 - 没有"全部取齐"/"整组送达"按钮,取餐顺序由骑手自行决定;每次操作服务端仍重新校验状态与骑手身份。 - 组进度:只要有有效子单未取完,组停在 `toPickup`;全部取完才进 `delivering`(见 3.6)。 ### 6.6 到付金额口径 | 字段 | 位置 | 含义 | |---|---|---| | `codToCollectAmount` | 组卡 | 有效到付子单向用户待收的现金总额 | | `codToCollectAmount` | members[] | 该店向用户收的金额 | | `codToMerchantAmount` | members[] | 交给商家的金额 = `orderAmount - discountedFreight` | 线上已付子单不计入现金待收;已取消成员从现金待收与有效进度中剔除(详情保留历史行)。 ## 7. 公共数据对象 ### 7.1 卡片对象(列表 records[] / 批次详情 / 组详情共用) | 字段 | 类型 | 适用 | 说明 | |---|---|---|---| | `cardType` | string | 全部 | `ORDER` / `BATCH` / `GROUP` | | `cardKey` | string | 全部 | 唯一键:`ORDER:{orderId}` / `BATCH:{batchId}` / `GROUP:{groupId}` | | `orderId` | long | ORDER | 普通单订单 ID | | `legacyOrder` | object | ORDER | 原订单完整快照(PosOrder 原字段),旧内容展示用 | | `batchId` | long | BATCH/GROUP | 批次 ID | | `groupId` / `version` | long | GROUP | 组 ID 与接单乐观锁版本 | | `parentOrderNo` | string | BATCH | 父批次号 | | `orderNo` / `storeId` / `storeName` / `payType` / `orderState` / `deliveryStatus` | — | ORDER | 沿用普通单字段语义 | | `checkoutStatus` | string | BATCH | 见 3.2 | | `groupStatus` | string | GROUP | `WAITING` / `ACCEPTED` / `FINISHED` / `CANCELLED` | | `primaryTab` | string | 全部 | 服务端判定的主页签 | | `refundSummary` | string | BATCH | 退款汇总:`REFUNDED` 已有渠道证实到账;`PENDING` 等表示仍在处理;null 无退款 | | `memberCount` / `activeCount` | int | BATCH/GROUP | 创建时成员总数(不下降)/ 当前有效成员数(取消后下降) | | `readyCount` | int | BATCH | 已就绪成员数(ONLINE_PAID + COD_CONFIRMED) | | `pickedUpCount` / `deliveredCount` / `cancelledCount` | int | GROUP | 未取消且已取(deliveryStatus≥2)/ 已送达(=3)/ 已取消 | | `totalAmount` | int | BATCH | 批次总金额快照 | | `onlinePaidAmount` / `codPromisedAmount` | int | BATCH | 线上实收合计 / 到付承诺合计 | | `refundPendingAmount` / `refundedAmount` | int | BATCH | 退款处理中 / 已到账金额 | | `discountedFreightTotal` | int | GROUP | 有效成员折后运费合计(骑手配送毛收入) | | `codToCollectAmount` | int | GROUP | 见 6.6 | | `createdAt` | string | 全部 | 创建时间 | | `operationDeadlineAt` / `finalDeadlineAt` | string | BATCH | 两个截止时间,见 8.1 | | `activatedAt` | string | BATCH | 批次实际生效时间 | | `openedAt` | string | GROUP | 组实际开放(进入待抢)时间 | | `anchorDistanceMeters` | number | GROUP | 距首店距离(米) | | `members[]` | array | BATCH/GROUP | 逐店摘要,见 7.2 | | `allowedActions` | string[] | 全部 | 见 3.7 | ### 7.2 成员对象 `members[]` | 字段 | 类型 | 说明 | |---|---|---| | `orderId` / `orderNo` | long/string | 子单 ID / 单号 | | `storeId` / `storeName` | long/string | 门店 | | `payType` | string | 该店已选支付方式;新建批次必有值,旧批次未选时可能为 null | | `paymentState` | string | 见 3.3 | | `orderAmount` | int | 该店订单金额 | | `discountedFreight` | int | 该店折后运费 | | `deliveryStatus` | long | 沿用普通单:1 已接单待取餐 / 2 已取餐 / 3 已送达 | | `orderState` | long | 沿用普通单订单状态 | | `cancelStatus` | string | `ACTIVE` 有效 / `CANCELLED` 已取消 | | `refundStatus` | string | `NONE` 无退款记录;其他为退款处理状态,仅渠道证实的 `REFUNDED` 表示已到账 | | `codToCollectAmount` / `codToMerchantAmount` | int | 仅到付已确认且未取消的成员返回,见 6.6 | | `allowedActions` | string[] | 仅本人已接组时出现 `PICKUP_MEMBER` / `DELIVER_MEMBER` | 客户端不得根据某个子单 `payStatus`/`paymentState` 推断批次状态,也不在本地重算费用、进度或可操作性。 ## 8. 业务规则汇总 ### 8.1 批次生效与两个截止时间 批次创建时快照两个截止时间(默认创建 +15 分钟 / +20 分钟,以服务端返回为准),不因配置变更或重试延长: - 14:00 创建:建批时已逐店选定方式,到付店当场确认;14:15 前可对未发起过网关交易的子单改选方式或发起线上交易。14:14 已实际发起的线上尝试可在 14:20 前完成付款及可信确认;14:16 起不能新发起、换方式或重试;14:20 仍未全部就绪则批次关闭(`CANCELLED`),迟到实收只退款不激活。 - **全部店合法就绪才变 `ACTIVE`**(线上须 ONLINE_PAID、到付须 COD_CONFIRMED);部分就绪不开放履约。 - 全到付在建批时所有成员已确认,可直接返回 `ACTIVE`;仍需按 5.2 的开放规则判断何时可抢。`ACTIVE` 后不可用 5.3 改方式。 - 未生效预约批次在激活时点预约已到/超过结束时间则取消,返回"原预约已过期"业务结果与真实退款进度,不自动改即时;已生效任务不由此取消。 ### 8.2 取消规则 | 批次状态 | 用户可做的取消 | 接口 | |---|---|---| | `PENDING` | 整批取消 | `POST /batches/cancel` | | `ACTIVE` 且组 `WAITING` | 单店取消 | `POST /batches/members/cancel` | | `ACTIVE` 且组 `ACCEPTED` | 不能主动取消 | — | - 所有取消成功只表示**受理**,不表示渠道退款到账;退款进度看批次详情。 - 取消店保留历史行但不计入有效分母与现金待收;余店费用快照不变,不追补运费。 ### 8.3 退款展示 - 退款进度由服务端关联原 LINE/OMG 退款记录投影:只有渠道事实 `REFUNDED` 才算到账(计入 `refundedAmount`);`BRIDGED` 等中间状态仅表示已登记,仍在 `refundPendingAmount` 处理中。 - 已实收后批次关闭/取消的,引导用户在详情页跟踪退款,App 不伪称"已退款"。 ### 8.4 预约与无人接单 - 全批次同一预约时段(报价时强制相同);批次生效时间按父批次统一判定,接入 037 无人接单规则,**不以逐子单支付时间起算**。 - `activatedAt`(批次生效)/`openedAt`(组开放)由服务端返回,App 不从付款时间推导无人接单倒计时。 - 无人接单自动取消:整组从待抢列表消失,详情保留逐店取消与退款进度,迟到实收不重开。 ### 8.5 地址冻结 建批后收货地址/坐标固定,地址簿变化不影响订单;首版无多店改址。用户换地址须按 8.2 取消后重新报价下单。 ### 8.6 旧接口隔离与升级门禁 - 旧用户列表 `/system/order/getorderlist`、旧骑手单笔列表/角标均不返回 036 子单/批次/组;旧父单详情 `/system/userOrder/getParentOrderDetail` 对真实 036 父单返回 `UPGRADE_REQUIRED`,新 App 一律调 5.5。 - 旧单笔支付/取消/接单入口对 036 子单与组成员实施服务端拒绝(msg="请通过多店批次流程取消订单"等),不存在绕过。 - 旧版 App 必须强升后才能使用多店;平台多店开关在两端兼容版本验收前保持关闭。普通单、历史单、自取、夜市自配送、单店转账不受影响。 ### 8.7 推送 - 开放和推送是两个阶段:批次变为 `ACTIVE` 时登记持久事件,组真正开放后事件转为 `OPENED`,独立通知任务随后处理;不保证支付回调返回时设备已收到推送。预约未到开放时点、部分店未就绪时不推送。骑手端以 6.1 列表/6.2 角标判断可抢任务,不以推送到达时间判断。 - 036 组新任务推送额外携带 `groupId`、`eventId`:用 `groupId` 打开组详情,按 `eventId` 对重试推送去重。 - 后端推送状态 `SENT` 只表示已提交到现有异步通道,不等于设备已送达。 ## 9. 常见业务错误与处理建议 msg 文案随 App 语言本地化,下表按简体中文文案识别(按语义匹配即可): | `msg` | 常见原因 | App 处理建议 | |---|---|---| | 请更新应用后使用多店结算和配送组功能(`errorCode=UPGRADE_REQUIRED`) | 能力头缺失/错误,或旧版 App 调了旧父单详情 | 引导升级;新接口补齐 `X-Multi-Store-Capability: batch-cards-v1` | | 多店结算暂未开放 | 平台开关未开或 036 迁移未执行 | 提示暂不可用,隐藏多店入口 | | 多店结算请求无效或参与店铺不足 | 参数缺失/少于 2 店/状态不允许/幂等键冲突 | 检查参数后重试;仍失败刷新详情 | | 该店已发起OMG/LINE Pay支付请求,完成前不能更换支付方式;请返回完成支付,或取消本次多店结算重新下单 | 在途收银台会话经渠道核实未通过或结果未知(换方式前服务端逐笔查询渠道状态) | 引导返回原收银台完成支付;或走 5.8/5.9 取消批次后重新下单 | | 订单已支付,无法更换支付方式 | 该店在途支付经渠道核实已收款 | 引导刷新支付结果,进批次详情查看就绪进度 | | 收货地址不存在或不属于当前用户 | addressId 无效或非本人 | 让用户重新选择地址 | | 门店不可参与当前多店配送 | 店在围栏外或不支持合并配送 | 按 excludedStores 剔除该店 | | 商品、规格或优惠信息已变化,请重新结算 | 建批时服务端重算与报价不一致 | 回到购物车重新报价 | | 当前配送费配置不可用,请稍后重试 | 运费配置缺失 | 稍后重试报价 | | 报价已过期,请重新获取 | quoteToken 超时 | 重新调 5.1 | | 以下门店无法参与多店配送:…(超出与首店的配送范围/暂无可用的支付方式) | 参与店被围栏或支付方式筛到不足 2 家 | 按点名门店调整选择或换店 | | 请通过多店批次流程取消订单 | 对 036 子单调了旧取消入口 | 改用 5.8/5.9 | | 多店配送组必须整组接单 | 对组成员调了旧单笔接单 API | 改用 6.4 | | 配送组状态已变化,请刷新后重试 | 接单 version 过期或已被他人接走 | 刷新列表/详情 | | 骑手位置已过期,请更新定位 | 接单时服务端位置过旧 | 先上报定位再重试 | | 距离首店过远,无法接单 | 骑手当前位置距首店超限 | 移动后再试或放弃该组 | | 接单距离配置不可用,请联系平台 | 平台距离字典缺失 | 提示联系平台,等待配置恢复 | ## 10. 接入验收清单 - 两店线上/到付混合、均线上、均到付:建批时每店必须选方式,到付当场确认、全到付立即 `ACTIVE`;有线上未付时不开放履约,全部就绪才生效;付款回跳后刷新真实状态而非本地置为已付。 - 建批参数:遗漏某店、重复 `storeId`、夹带非参与店、传 `"6"` 或该店不可用方式均被拒;用相同 `requestKey` 改传不同方式不能覆盖原批次,创建后改选走 5.3。 - 时间窗:14:15/14:20 两截止行为符合 8.1;过期后所有付款入口置灰。 - 即时/预约批次:最后成员就绪后即时单可立即开放,未到时点的预约单即使 `ACTIVE` 也不可抢、不推送;预约过期不激活、只退实收;生效与组开放分别以 `activatedAt`/`openedAt` 展示。 - 无人接单超时:列表和角标只在实际开放后展示,取消后不重新可抢;迟到实收只跟踪退款。 - 组进度:A 已送达、B 未取餐时组只在 toPickup,双进度与组容量正确;部分取消不追补余店运费。 - 到付展示:组/成员 `codToCollectAmount`、`codToMerchantAmount` 按 6.6 展示,线上单不计现金待收。 - 列表:用户/骑手组合列表一次返回,`total` 为卡片数;`cardKey` 作唯一键;旧列表接口确认看不到 036 单。 - 升级门禁:旧版两端调 036 接口均收到 UPGRADE_REQUIRED 并提示升级;普通外卖、闪送、自取、堂食现金、夜市自配送、单店转账回归原流程无变化。 - 所有失败以 `msg` 展示并刷新详情,除 UPGRADE_REQUIRED 外不依赖 errorCode。 - 联调前确认:036 SQL 已人工执行、平台多店配置已开启、真实支付渠道与推送可达。