# 多店结算数据模型(实施版) 更新:2026-10-21。仅适用于服务端新建的多店批次;未关联批次的普通及历史订单保持原语义。金额统一以整数 TWD 存业务快照,渠道需要最小货币单位时只在网关适配层转换,不回写订单金额。 ## 1. 持久化实体 | 实体 | 唯一约束/核心字段 | 职责 | |---|---|---| | `order_checkout_config` | 固定 `id=1`;`enabled`、`fence_meters`、`max_stores`、`confirmation_buffer_minutes`、`tiers_json`、`version` | 平台全局配置专表,固定项用独立列,动态折扣阶梯用 JSON;版本用于报价指纹和后台乐观锁,不接 `sys_config`。 | | `order_checkout_batch` | `id`;`parent_dd_id` 唯一;`(user_id,request_key)` 唯一;专用 `checkout_status`、`version`、`create_time`、`operation_deadline_at`、`final_deadline_at`、`confirmation_buffer_minutes` | 服务端批次标识、幂等创建、15 分钟发起截止和创建时缓冲快照;专用状态只表示统一履约资格,不代表退款到账。 | | `order_checkout_batch` 规则列 | `anchor_store_id`、`address_id`、`same_rider`、`fence_meters`、`max_store_count`、`rule_version`、`matched_min_stores`、`discount_ratio`、`total_amount` | 创建时冻结本次真实参与集合与计价规则;配置变化不追改。 | | `order_checkout_batch` 生效时间 | `activated_at` | 最后有效成员合法就绪(全到付可在建批时就绪)、批次首次原子生效时写入,不能用某子单实收时间或后续 `update_time` 代替;生效后不因部分取消/重试改写。 | | `order_checkout_member` | `id`;`pos_order_id` 唯一;`(batch_id,store_id)` 唯一;`sort_no`、`payment_state`、`pay_type`、`ready_at`、`accepted_payment_attempt_id`、`original_freight`、`discounted_freight`、`food_amount`、`amount`、`coupon_id` | 每店子单金额和付款/到付确认事实。`accepted_payment_attempt_id` 固定首笔获准线上实收,重放同一交易幂等、第二笔实收进入退款;到付确认不算到账。 | | `order_checkout_payment_attempt` | `id`;`(channel,merchant_order_no)` 唯一;`member_id`、`pay_type`、`state`、`requested_at`、`dispatched_at`、`gateway_transaction_id`、`gateway_paid_at`、`verified_at` | 保存第 15 分钟前服务端发起的证据及其后缓冲内可信结果。请求预留不是实际发起;回调、查询及重试按原交易号对账。 | | `order_delivery_group` | `id`;`batch_id` 唯一;`rider_id`、`status`、`version` | 仅同骑手批次创建;组是一个抢单任务,不是合并收款或强制同步取送状态。 | | `order_delivery_group` 开放时间 | `opened_at` | 首次实际开放给骑手时原子写入并更新持久开放事件,不能从批次`ACTIVE`或预约计划时点推算,也不因重试/部分取消改写。即时/已到点批次在激活事务提交后立即尝试开放;分钟任务处理预约到点、失败重试和重启补偿。 | | `order_checkout_outbox` | `id`;`(event_type,aggregate_id)` 唯一;`status`、`next_retry_at`、`attempts` | `PENDING` 待旧时点开放,`OPENED` 已原子开放但尚未入推送队列,`SENDING` 消费者已认领,`SENT` 已提交到既有异步推送通道(不代表设备送达);失败及超时认领可重试,取消后不可再开放。推送载荷带 `eventId/groupId` 供新 App 去重和跳转。 | | `order_checkout_refund_intent` | `id`;`(channel,merchant_order_no)` 唯一;`member_id`、`reason`、`amount`、`gateway_transaction_id`、`status`、`next_retry_at` | 每笔真实渠道交易最多一个退款意图,避免不同取消原因重复执行;意图创建不等于到账。到付未收不建网关退款意图。 | 036 退款意图的 `PENDING` 表示待桥接,`BRIDGED` 只表示已写入原 LINE/OMG 退款队列;渠道的 `REFUNDED`、失败、未知或人工处理以原退款表为准,不能从 036 的 `BRIDGED` 推断到账。两项调度 SQL 默认暂停,整体联调通过后由开发者启用。 不在 `pos_order` 增加多店标志:以 `order_checkout_member.pos_order_id` 的唯一关联作为不可伪造服务端标识;老订单无关联即沿旧流程。容量统计与组列表通过成员表关联 `order_delivery_group`,避免为纯读取再加一列。父单 `order_parent` 沿用现有结构和子单号关联,其 `orderStatus` 不复用为批次生效或实收状态。 时间列已纳入036实体、Mapper与登记的SQL;测试库已迁移,生产库仍需开发者手动执行,本文不代表生产库已有这些列。地址与坐标使用建批时复制到父/子订单的快照;`address_id`只保留原地址关联,不在付款、列表、取送时回读地址簿覆盖快照,旧单笔改址入口也不能改组成员。 ## 2. 状态和不变量 - 批次:`PENDING → ACTIVE` 或 `PENDING → CANCELLED`;`CANCELLED` 永不重开。到最终确认截止仍未全部合法就绪时关批次,截止后收到真实付款也只进入退款/对账,不转 `ACTIVE`。 - 成员:建批时须逐店选择,初态为`ONLINE_PENDING`或`COD_CONFIRMED`,新批次不产生`UNSELECTED`;线上可信实收后`ONLINE_PAID`。历史`UNSELECTED`仅供兼容展示。批次仍`PENDING`、未过操作截止时可安全改选:该成员从未有网关尝试,或在途尝试已逐笔向渠道核实确定未付(同 661 口径,OMG 为 UNPAID/FAILED、LINE 为未捕获);核实已收款或结果未知拒绝,核实后迟到实收由重复实收退款兜底;渠道失败/未知不得标记就绪。`ONLINE_PAID`与`COD_CONFIRMED`均计就绪,但仅前者计已实收。 - 发起线上尝试、重试、方式改选均要求 `now < operation_deadline_at`;到付在建批时确认,合法改选到付亦受该截止限制。已有实际发起且可核实的在线尝试允许在 `now <= final_deadline_at` 完成。缓冲内不新建尝试。 - 同一父批次读写必须以批次行锁或状态版本 CAS 保护;激活/取消必须在同一事务内重新读取全部成员事实。外部网关 HTTP 不持有数据库事务锁。 - 组接单:骑手锁 → 组行锁 → 成员子单按 ID 加锁;资格、现有任务容量、组门店上限和全部成员可接状态在锁内校验;全部分配或全部失败。单笔接单入口发现组成员必须拒绝拆抢。 - 子单创建时写已选支付方式及业务快照,但`isDisplay`不等于批次准入;骑手待抢列表、直接接单、商家操作及新单通知均校验批次已`ACTIVE`、预约旧时点和组`opened_at`等实际开放事实。普通订单不套批次期限。 - 组任务只有全部有效成员都履约结束或合法取消后释放。独立取餐、独立送达、独立结算/通知按子单进行,部分取消不重算余单折扣。 - 已确认接入 037:无人接单基准为 `max(activated_at, opened_at, 预约开始)`(即时忽略预约);部分子单先付款不启动计时,部分取消不重置。超时与接组在统一锁/事务规则下互斥,未承接组取消剩余有效成员并关联现有渠道退款事实;原有效期内且仍绑定本子单的券 CAS 返还,不延长有效期。无组成员使用父批次生效与本人实际开放时间按独立订单判断。 - 尚未生效预约批次在激活时已到/超过原结束时间则转 `CANCELLED` 并退款,不自动改即时;恢复扫描补处理。已经 `ACTIVE` 的任务不因预约结束由此路径取消,继续依履约及 037 无人接单规则处理。 ## 3. 平台配置和金额 独立 `order_checkout_config` 单行保存全局开关、围栏距离米(默认 1000)、最多店数(默认 5)、缓冲分钟(默认 5)及 JSON 动态折扣阶梯(初始 `{2:0.9,3:0.8}`),配置保存时校验:围栏/最多店数/缓冲为正整数,门槛为不小于 2 的唯一整数,系数 `0 < ratio <= 1` 且最多四位小数,与批次快照 `DECIMAL(7,4)` 一致。开关/围栏/门店上限/阶梯缺失或非法时保守关闭新合并报价;缓冲异常回退 5 分钟,不变为无限。配置版本在同一行 CAS 增加并纳入报价指纹;创建时重算与报价版本不一致则要求重报。配置表缺行时读取显示关闭态、保存时提示执行 036 手动迁移,不能偷偷初始化为启用。 2026-10-16 已确认:降低 `max_stores` 前检查未结束、未承接组的有效成员数,存在超出新值者拒绝保存且不增加配置版本,包含尚未生效的已建组;已接组继续履约。检查与建批的配置读取/版本重验以及组取消/承接必须具有一致并发保护,不能检查后又按旧配置建出超限未接组。 首店门店坐标为圆心、对每个后续门店作球面直线距离,米精度比较 `distance <= fence_meters`;不以道路/用户地址距离代替。圈外店不进入本批次父/子单或金额;创建成员集合必须与确认报价一致,否则拒绝。 每店原运费按现有有效规则权威计算;同骑手批次取最高满足门槛档位,对每店分别执行 `original_freight × ratio` 后 `HALF_UP` 到整数 TWD。折后运费为该店骑手毛运费 `R`,再交给现有满额免运算价:商家免运补贴 `F`、客户配送费 `D=R−F`,子单 `amount = 优惠后商品金额 + D`。无免运时 `F=0`、`D=R`;全免时 `F=R`、`D=0`,骑手仍收 `R`。父单总额为子单实付之和。用户到付代收该子单 `amount`,给商家 `amount - R`(商家承担补贴),商家给骑手 `R`;不存在跨商家补差。取消余单金额不重算。036 子单在 `pos_order` 固化 008 的 `promotion_pricing_version=2`、`merchant_delivery_subsidy`、`customer_delivery_fee`,`order_checkout_member.discounted_freight` 始终是 `R`。 ## 4. 迁移与兼容 DDL 只登记到 `updatesql/sql.md`,不自动执行。迁移前不得开启新入口。已有父/子单不回填批次关联;老单店转账、自取、夜市自配送、已有 LINE/OMG/到付均沿旧流程。普通平台配送预约只补旧规则时点扫描,不套批次付款截止。 ## 5. 两端组合只读模型(2026-10-19,未实现) 批次卡与配送组卡是查询投影,不新增“卡片状态表”或“进度计数列”:由 `order_checkout_batch`、成员关联、`order_delivery_group`、各 `pos_order` 的真实支付/履约/取消事实及退款意图计算。一个批次/组是一条分页单位,先按关联聚合和 Tab 过滤,再混入普通订单排序、分页;`cardKey` 使用 `BATCH:{batchId}`、`GROUP:{groupId}`、`ORDER:{orderId}`,避免不同表 ID 碰撞。用户列表基于 token 的本人批次,骑手待抢和已接列表按现有资格/归属脱敏。列表、详情和角标复用相同投影规则,详情可以保留已取消成员历史。 组取餐/送达进度以当前有效成员为分母,`pickedUpCount`、`deliveredCount` 分别基于真实子单事实;已送达子单同时视为已取餐。取消成员移出分母和待收现金,但 `memberCount` 保留创建时总数并另给 `cancelledCount`。存在有效成员未取餐时组仍属待取餐,即使另一成员已送达;全部有效成员已取且仍待送/处置时才属配送中。没有新“整组已取/已送”操作,进度只供展示与任务归类;最后有效成员履约或异常处置结束后才释放组占用的一个任务。 骑手距离以批次创建时首店子单保存的取餐坐标快照判断,`anchor_store_id` 用于确定首店身份;不在骑手接单时重新读取可能已变化的门店坐标。统一应用既有外卖距离字典与位置时效;首店到其他店的合并围栏是独立资格。名单过滤只能优化展示,接组必须在骑手锁内重验。旧 App 升级策略不依赖新增数据库字段,发布前须保证用户端、骑手端兼容版本与服务端旧单笔入口防绕过同时就绪。