research.md 9.6 KB

Research: 商家自配送 (029)

Date: 2026-09-08 | Status: Complete(全部 NEEDS CLARIFICATION 已解决,无遗留)

代码现状核查于 2026-09-08,分支 test-202609v2(commit af8cc41 附近)。

现状事实(设计依据)

# 事实 出处
F1 骑手为抢单模式:newTask 池过滤 type=0 AND delivery_status=0 AND state=0 AND pay_status=1 AND after_sale_status=0 AND qs_id IS NULL PosOrderQsOprateController.orderList
F2 商家接单/出餐对外送单强制 deliveryStatus=1 AND qsId!=null(骑手已接单才能出餐)——自配送单必须放宽,否则商家无法流转订单 PosOrderShOprateController.requireRiderAssigned (L495)
F3 商家端订单可见性:userType=1/5 走 merchantStoreAccessService(022 多门店);userType=3(夜市主)按 shId=loginUserId;摊位主按 mdId=user.storeId → 摊位订单的 shId 即夜市主 userId PosOrderShOprateController.orderList (L615-636)
F4 摊位订单分成对象已是夜市主(按 nightMarketId 取用户、抽其 commission),但钱包入摊位余额 addStallBalance OrderService.setSanghuBilling (L54-114)
F5 骑手运费分成:freight × 骑手 commission,余额入骑手钱包,含到付 behalfAmount;UserBilling type="0",dedup 键 (userId,type,ddId) OrderService.setQishouBilling (L121-159)
F6 送达完成副作用统一挂 completeSideEffects:setSanghuBilling 恒调 + type=0 && qsId!=null 才 setQishouBilling;管理端 018 状态机与骑手送达都走它 OrderLifecycleService (L491-496)
F7 管理端状态机不变量:非外送不能有配送状态;delivery=3 ⇒ state=3;完成单 payStatus=1 且无售后 OrderLifecycleService.validateResultInvariants (L433-444)
F8 营业时段按星期几存(OperatingHours: mdId/startTime/endTime/dayOfWeek/isOpen,HH:mm 字符串);判定工具 OperatingUtil.storeIsOperatingOfDay 用 LocalDate.now().getDayOfWeek().getValue()(ISO 1=周一..7=周日)+ DateUtil.isLegalTime OperatingHours.java, OperatingUtil.java
F9 isLegalTime 边界=含首含尾(==start 或 ==end 返回 true)——与已确认的 [开始,结束) 语义冲突,不能直接复用 DateUtil.isLegalTime (L134-146)
F10 门店信息接口在 /chanting/store,营业时段已有读写接口对 getMdBusinessHours / saveMdBusinessHours——自配送时段镜像该模式 PosStoreController (L407/L428)
F11 多店结账=OrderParent(ddId) + 每门店一条 PosOrder(parentDdId 关联,子单号 ddId+001…);两处下单入口:UserOrderController.createOrder(用户端)与 PosOrderShOprateController.createOrder(商家端 POS) UserOrderController.createOrderChild (L214-279)
F12 骑手接单守卫禁止骑手持未完成单再接单(no.exist.undelivered.order)→ "同一骑手多单"机制现状不存在(本功能范围外,另立需求) PosOrderQsOprateController (L188-197)
F13 到付账单:用户 UserBilling type="3",骑手送达时 paymentId=qsId PosOrderQsOprateController.updateUserBill (L301-314)
F14 商家出餐推送 chuCan 给用户+骑手双推送;骑手送达推送按 deliveryStatus 1/2/3 取文案 key PosOrderShOprateController.chuCan、QsOprate.setOrderQsState
F15 PosStore 已有 isStall/isNightMarket/nightMarketId;摊位建店时 nightMarketId=夜市主 userId PosStore.java、StallController (L88)
F16 订单接口普遍全字段序列化 PosOrder("新增字段无需手动添加")→ 新增订单字段自动透出给客户端 QsOprate.orderList (L404)

设计决策

D1: 配置存储——pos_store 两列 + 时段子表(镜像营业时段)

Decision: pos_store 加 self_delivery_enabled、self_delivery_mode(1=全天/2=自定义);新表 pos_store_self_delivery_hours(md_id, day_of_week, start_time, end_time),结构与 OperatingHours 对齐。配置主体:普通门店=自身行;夜市=夜市主自己的门店行(isNightMarket=1),摊位判定时经 store.nightMarketId 解析到夜市主门店行读配置。

Rationale: 完全复用营业时段的 UI 交互、数据形态与团队心智;夜市级单一事实源,天然保证同一夜市统一模式(spec US2)。

Alternatives: ①每摊位各存配置再同步——分裂配送风险+同步复杂,弃;②独立无门店关联的配置表——偏离现有模式,弃。

待实现验证: 夜市主门店行存在性(isNightMarket=1 且 userId=夜市主)需在实现时用测试库数据确认;若个别夜市无门店行,判定时视为"未配置自配送"回落骑手池。

D2: 订单标记——pos_order.self_delivery 快照列

Decision: pos_order 加 self_delivery TINYINT(1) DEFAULT 0,下单判定时写入,终身不变。

Rationale: 独立标志字段(spec 决策④);F16 保证自动透出客户端;对存量数据默认 0 无影响。

Alternatives: 复用 qsId=商家ID——污染骑手链路(骑手列表按 qsId 聚合、COD 归因、接单守卫均按 qsId+userType=2),弃。

D3: 时段判定——新工具方法,不复用 isLegalTime

Decision: 新增 SelfDeliveryUtil.matchesSelfDelivery(config, target):按 target 的 ISO dayOfWeek 过滤时段段,HH:mm 字符串比较,区间含开始、不含结束 [start, end)。判定基准时间:预约单(delryTime 非空)用预约时间,即时单用当前时间。判定时机:两个下单入口(F11)创建 PosOrder 时。

Rationale: F9 边界语义冲突必须新写;预约单按预约时间是 spec 已确认决策②。

Alternatives: 改 isLegalTime 全局边界——影响营业时段存量行为,风险大,弃。

D4: 状态机——跳过 deliveryStatus=1,送达原子完成

Decision: 自配送外送单流转:state 0→1(商家接单)→2(出餐)→3(确认送达);deliveryStatus 0→(2 开始配送,可选)→3;"确认送达"端点原子置 deliveryStatus=3 + state=3 + sdTime。requireRiderAssigned 对 self_delivery=1 放行(F2)。

Rationale: deliveryStatus=1 语义是"骑手已接单",自配送无此环节;delivery=3⇒state=3 恰好满足管理端不变量(F7);开始配送可选是 clarify Q3 结论。

Alternatives: 自配送也走 deliveryStatus=1(语义撒谎,骑手列表查询会误捞),弃。

D5: 运费结算——UserBilling 新 type,user_id=sh_id 统一

Decision: completeSideEffects 增分支:type=0 && self_delivery=1 && qsId==null → 生成自配送运费入账:UserBilling type="5"(实现时 grep 确认未占用)、user_id=sh_id、amount=freight 全额、divvy=0/divvy_rate=0、user_type="1"、钱包 addBalance(sh_id)。不抽成(clarify Q1)。到付单(collectPayment=1)送达时用户账单 type="3" 的 paymentId=sh_id(镜像 F13)。夜市摊位单 shId=夜市主(F3)→ 运费自然归夜市主,与摊主的分摊线下处理(spec 决策③)。

Rationale: F3+F5 使 user_id=sh_id 对普通商家/夜市主统一成立,零分支;新 type 避开 setSanghuBilling 的 (userId,"0",ddId) dedup 冲突。

Alternatives: 复用 type="0"——与商品分成 dedup 键冲突(互斥掉一条账单),必须弃;freight 并入商品分成金额——账目不可区分,弃。

D6: 骑手侧隔离——池过滤 + 操作直调拒绝

Decision: newTask 池查询追加 self_delivery=0(含 NULL 兼容存量);QsOprate acceptOrder/pickupOrder/deliverOrder 对 self_delivery=1 的单抛错(镜像 027 线下转账单防直调模式)。

Rationale: spec FR-004 接口级隔离;直调防御有先例(F1 转账单校验)。

D7: 推送——骑手链路全跳过,用户推送文案区分

Decision: chuCan 出餐推送:self_delivery=1 时跳过骑手推送、保留用户推送;自配送单"开始配送/已送达"推送用户(新 i18n key,文案区分商家自配送);不触发任何骑手推送。

Rationale: spec FR-009;复用 PayPush/MerchantNotificationRouter 现有通道。

D8: 配置接口——镜像营业时段接口对

Decision: /chanting/store/getMdSelfDeliveryHours(GET)+ /chanting/store/saveMdSelfDeliveryHours(POST,DTO 承载)。权限:普通商家 merchantStoreAccessService.requireStoreAccess;夜市主要求门店行归属(store.userId=登录用户且 isNightMarket=1);摊位门店(isStall=1)拒绝(错误走 MessageUtils)。校验:自定义模式至少一段、每段 end>start、HH:mm 格式。

Rationale: F10 镜像现有接口对;spec FR-002 摊位无配置权。

D9: 商家端 PC 范围——配置+标识必做,配送操作按钮独立任务

Decision: foodie-store:门店设置自配送区块(复用营业时段星期编辑交互)+ 订单列表/详情"自配送"标识,必做。PC 配送操作按钮(开始配送/确认送达)单列为独立任务,依赖 clarify Q2(端覆盖暂缓决策);后端操作接口先行交付,App/PC 界面后接入同一接口。

Rationale: Q2 用户明确暂缓;接口与界面解耦后无论 Q2 结论如何都不返工。

D10: 平台管理端不在本功能范围

Decision: 不改 foodie-admin-vue;订单接口全字段序列化已自动带 self_delivery(F16),管理端未来需要时直接用。

Rationale: spec 范围声明(后端+商家端 PC);避免扩散。

涉及的废弃代码

无。本功能不触碰 CLAUDE.md 废弃清单内代码(ZaloPay/PayController/TestTask/OrderAppeal 等);结算挂在在用的 OrderLifecycleService/OrderService 链路。