data-model.md 12 KB

多店结算数据模型(实施版)

更新: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 升级策略不依赖新增数据库字段,发布前须保证用户端、骑手端兼容版本与服务端旧单笔入口防绕过同时就绪。