车队包(vehicle)设计 —— 游览车 / 摆渡车「随上随走」· 波波 2026-09-24

第四个行业增量包(对应 084 feature_catalog 的 vehicle)。
业务模型由义父 2026-09-24 16:3x 定调,推翻了我最初的班次预售方案。

一、义父三点定调(本次设计的前提)

#定调对设计的影响
①游览车摆渡车没有班次,随上随走弃用 transport_slots 的 capacity/booked 座位预售模型;改为「登记即收款」
②装备租赁是一个单独的模块/rental-workbench、/rental-orders 从 vehicle 包移出转 DEFERRED_ROUTES
③加模拟测试数据造专属测试身份 + 全链路造数验证(见 §六)

权威蓝图印证(docs/B端业态全景设计方案-v1.1.md)

蓝图里本就是三个独立条目,我最初混为一谈是错的:

⇒ 本次做的是 ④ 里的「镇内接驳」+ 游览车/摆渡车这一支:随上随走,不按驮位、不排班次。


二、开工前事实核查(以库/代码为证)

核查项结论依据
复用 transport_slots?弃用字段 capacity/booked/unit_price_ref 是座位预售模型,与「随上随走」冲突;实测 0 行、全仓零代码引用(建了表从没人写过)
复用 transport.py?不复用(保留)它是 C端只读内容展示(transport_routes 3 条平台内容:机场接送/包车/拼车),返回 {code:0,data} 形状;B端 Transport.tsx 是 WIP-190 真只读页,保留不动
复用 verifications 核销?不复用全仓无独立 vouchers 表(只有 quote_vouchers),核销链路不适合游览车现场记人次
/rental-* 现状移出门禁rental.ts 是 WIP-272 装备租赁借还域(RENTAL_READY=false 降级),后端 /merchant-console/rentals 零实现;被顺手挂进 vehicle 包属语义错位
/vehicle-package保留不动页面注明「机动车整包端点由小齐负责」,属长途包车按趟计价,与随上随走不同业务流;避免撞车
vehicle 包 4 路由现状实为 3 个不同域整包(小齐) + 装备租赁(借还) + 运输线路展示(只读),没有一个是游览车/摆渡车
车队商户0 家真实prod business_type='transport' 真实商户为 0;测试商户 8021(business_type=vehicle, is_test=t)
SQL 编号用 092091 已被零售包占用;migrate.sh 以 filename 为 PK

三、数据模型(SQL 092,全幂等 IF NOT EXISTS)

价格沿用全仓 *_fen;所有表带 is_test(继承商户,否则 C端过滤形同虚设);merchant_id 参数化不写死。

1. fleet_vehicles — 车辆档案(含合规台账)

字段说明
id / merchant_id FK CASCADE
plate_noVARCHAR(16),唯一 (merchant_id, plate_no)
vehicle_typeCHECK:小型车/中巴/大巴/游览车/摆渡车/其他
brand_model品牌型号(如 丰田考斯特)
seat_capacityINTEGER ≥1 核载人数(登记时校验人数不得超核载)
license_no道路运输证号 —— 合规红线字段,仅 B端可见
license_expires_atDATE 营运证有效期 —— 到期预警依据
driver_name / driver_phone司机信息 —— 仅 B端可见,C端不出
statusactive / maintenance / retired
note / sort_order / is_test / created_at / updated_at

索引:(merchant_id, status);部分索引 (merchant_id, license_expires_at) WHERE status <> 'retired'(报废车不预警,无需扫)。

2. fleet_routes — 运营线路(定票价,不排班次)

字段说明
name线路名,唯一 (merchant_id, name)
route_typeshuttle 摆渡 / sightseeing 游览 / transfer 接驳
origin / destination起点 / 终点
price_fen票价(按人次,非按趟)
service_hours服务时段文本(如 08:00-18:00)
departure_modeloop 循环发车 / on_demand 随叫随走 —— 两者都非班次制
interval_minutes循环发车间隔(loop 时填,可空;CHECK >0)
statusactive / paused / offline
description / sort_order / is_test / 时间戳
刻意不建:班次表、座位表、预订表。随上随走 ⇒ 没有"某趟车还剩几个座"这个概念。

3. fleet_rides — 乘车登记流水(登记即收款)

字段说明
ride_noF{YYYYMMDD}{6位hex},唯一 (merchant_id, ride_no);餐饮 R / 零售 T / 车队 F 区分域
route_id / vehicle_id软引用(车辆可空=不指定车)
route_name / plate_no快照,线路改名/车辆报废不影响历史
passengersINTEGER ≥1 人数
unit_price_fen单价快照
total_fen / paid_fen登记时即 paid = total(无待支付态)
pay_methodcash/wechat/alipay/other
statusrecorded(已登记=完成)/ refunded / cancelled —— 无 created/pending
operator_id / note
boarded_at上车时间(默认 now)
is_test / 时间戳

附带修正

UPDATE feature_catalog SET description=... WHERE feature_key='vehicle' AND description='租赁 / 接送 / 车次管理'

→ 改为「游览车/摆渡车/接驳:车辆档案、线路票价、现场登记人次、营运证到期预警」。

原描述把装备租赁混进车队包(正是义父第②点的根源),且「车次管理」与随上随走冲突。带旧值条件 ⇒ 幂等且不误改他人后续修订。


四、接口清单

B端 console_router = /api/miniapp/merchant-console/fleet

鉴权:_staff() → 委托 merchant_scope.resolve_merchant(禁无 ORDER BY 的 LIMIT 1,P0 教训)。

车辆:GET/POST /vehicles、PUT/DELETE /vehicles/{id}(DELETE = 软删 retired)

线路:GET/POST /routes、PUT/DELETE /routes/{id}(DELETE = 软删 offline)

乘车:GET /rides、GET /rides/{id}、POST /rides(登记即收款)、PUT /rides/{id}/status(退款/作废)

概览:GET /dashboard

C端 miniapp_router = /api/miniapp

GET /fleet/routes?merchant_id=&route_type= —— 游客查在营线路与票价。

只出 active 且 is_test=FALSE;不暴露 driver_phone / license_no(隐私 + 合规内情)。

与零售/餐饮的关键差异(勿套用)

零售收银车队登记
状态机created → paid(需 settle)recorded 一步到位
收款开单后单独结账登记瞬间即收款
库存扣减 + 流水对账无库存概念(车辆不是消耗品)
逆向退款回滚库存退款仅把 paid_fen 归零
校验库存充足人数 ≤ 核载 + 线路/车辆均须 active

错误码

400 格式 / 401 未认证 / 403 非商户成员 / 404 不存在或跨商户 / 409 状态冲突·超核载 / 422 业务校验。零 mock、零占位。


五、合规设计(蓝图红线:营运资质 + >9 座重罚 3-10 万)

三层防护,事前拦优于事后告警:

  1. 录入即拦(前端 FleetVehicles):核载 >9 座且未填道路运输证号 → 直接拒绝保存并说明罚则
  2. 状态派生(后端 _license_state):expired 已过期 / expiring 30 天内到期 / valid / none;报废车不预警
  3. 首屏告警(dashboard):license_expired + license_expiring 计数;概览页红色 banner「请立即停运并换证」

六、测试身份(义父第③点:加模拟测试数据)

遇到的障碍

user 9101 挂 8001/8011/8021 三商户,而我修的 resolve_merchant 按 merchant_id ASC 确定性取值 ⇒ 永远解析到 8001,够不着 8021 车队商户。

(这正是我上报的「P0-A 止血但未终解」的直接后果:多商户用户无法切换商户。)

解法

造只属于 8021 的专属测试用户 9121(openid=test_fleet_owner_021,沿用项目既有 test_seed_owner_* 命名惯例,复用 9101 密码哈希),建 merchant_staff 关系 + 开通 vehicle 授权。


七、新增门禁:scripts/check_pack_wiring.py

为什么必须新增:本轮实测 tsc 零错误却漏了两处接线——

两处都是「数组少一项」,TS 类型层完全合法 ⇒ tsc 结构上查不出来。与上轮「curl 打后端绿 ≠ 前端能用」同源。

判据:对 hotel/restaurant/retail/vehicle 四包每条门禁路由,校验三方一致——

① App.tsx 有 <Route path> ② business-pack.ts 在包白名单 ③ Workbench.tsx 有菜单入口;

并反向校验 App.tsx 中带业态前缀的路由是否都进了某包或 DEFERRED_ROUTES(防孤儿路由落 * 兜底)。

已反证有效:故意从 vehicle 门禁删掉 /fleet-dashboard → 脚本点名报红「1 条路由未挂门禁」+ EXIT≠0;还原后 EXIT=0、diff 确认与备份一致。

当前四包 27 条路由三方一致、零孤儿。


八、验证结果

项结果
SQL 092 临时库3 表建成 + UPDATE 1 命中 feature_catalog 描述修正 + 幂等复跑 0 ERROR
fleet.py 语法py_compile ✅ + AST 查 import 完整性 ✅(py_compile 对缺 import 静默通过,已反证)
未使用 import清掉 timezone(照抄零售范式带过来的)
services/fleet.ts一开始就用 asItems 运行时形状校验,不重蹈餐饮白屏覆辙
tsc零错误
vite build成功(bundle index-Mf5jrlJv.js)
check_pack_wiring.py四包 27 路由三方一致、零孤儿;已反证有效
测试身份staging + prod 双库 9121→8021 就绪、vehicle 授权开通、幂等复跑 no-op

已完成(2026-09-24 17:5x–18:0x)

改为「游览车/摆渡车/接驳:车辆档案、线路票价、现场登记人次、营运证到期预警」;

3 表建成;既有酒店数据未受损(room_types 1→3 / 10→4 / 8001→3,与部署前基线逐项一致)

登记即收款(status=recorded 且 paid=total=120,无需 settle)、超核载 409(9座车登10人)、

暂停线路登记 409、同名线路 409、退款 paid→0、终态约束 409、非法 status 422(证 body 传参生效)、

证照派生 valid/expiring、C端 is_test 隔离 + 反证置 f 后可见且不泄 driver_phone/license_no、

概览口径 trips=1/pax=2/rev=160(退款单不计营收)、跨商户读改删三向 404、软删留痕

四页 4/4 通过、条目数硬门槛达标(概览 6 stat / 车辆 2 卡 / 线路 2 卡 / 乘车 1 卡)、

证照告警 badge、菜单四项入口、点击跳转非死链、零控制台错误

(造数据后验列表,因白屏只在有数据时暴露;金额 90=30×3 正确)

nginx -t + reload;线上 bundle index-Mf5jrlJv.js 含车队 4 个特征串、新 CSS index-CVQ-3pue.css 200、

b 域与 doc 域 /workbench/ 均 200

测试身份 9121 与 vehicle 授权保留供后续回归复用

84 文件零风险,均已反证)+ 既有 4 个 v2 门禁复跑 EXIT=0


九、遗留 / 待裁定

  1. /vehicle-package 机动车整包:端点归小齐、后端零实现,本次未碰。是否要与他确认归属后再建?(长途包车按趟计价,与随上随走不同流)
  2. 装备租赁独立立项:/rental-* 已转 DEFERRED_ROUTES(门禁对所有包不可达)。后端 WIP-272 域零实现、前端 RENTAL_READY=false。何时立项建包?
  3. rental.ts 契约不一致:用 .list + {code:0} 形状,与我后端 {ok,data:{items}} 不同(有 ||[] 兜底不白屏但取不到数据)。属车队/租赁域外,未改。
  4. C端 category 参数失效:category-transport / category-vehicle 传 ?category=transport|vehicle,但后端 list_products 签名只有 limit ⇒ 参数被完全忽略,两个分类页显示的其实是全量套餐。属 C端+miniapp_products.py 域,未改,待裁定。
  5. P0-A 终解:B端 无「当前商户」概念、workbench_tickets 全仓零消费点 ⇒ 多商户用户无法切换商户(本次靠造单商户用户 9121 绕过)。需刘总令 + 前端配合。
  6. transport_slots 空表处置:座位预售模型对游览车不适用,但驮运(蓝图③)确实需要按驮位。建议保留给未来驮运域,本次不动。

十、冒烟抓到的两个真 bug(均已修复并立门禁)

两处都是 asyncpg 传参无类型标注 导致 PG 无法确定 $N 类型 → 接口 500。

py_compile / tsc / 路由挂载检查全部放过,只有真实调用才暴露。

bug① GET /fleet/dashboard 500

asyncpg.exceptions.AmbiguousFunctionError: operator is not unique: date + unknown
HINT: Could not choose a best candidate operator.

无法在 (date+integer) 与 (date+interval) 间抉择

bug② PUT /fleet/rides/{id}/status 500

asyncpg.exceptions.AmbiguousParameterError: inconsistent types deduced for parameter $1

把同一 $1 用两次 —— status=$1 推导 varchar,$1 IN (字面量) 推导 unknown

立门禁固化经验:scripts/check_asyncpg_param_ambiguity.py

两类判据:① 未 cast 参数参与算术且紧邻操作数是 date/timestamp 列(或 $N±$M 与 date 列比较)

② 同一 $N 出现 ≥2 次且其中一次处于 IN(…) 或与引号字面量比较。

判据经三轮反证收紧(每轮都靠反证抓出自身缺陷,不是一次写对):

轮次缺陷反证发现方式
一不限定 date 列 → 全仓 25 处几乎全是 numeric 误报(balance-$2 PG 可推断)人工过输出发现噪音
二只看「窗口内是否出现 date 列」→ 同条 UPDATE 里的 updated_at=now() 就误报(26 处)反证 4 个已知误报源全命中
三判据② 正则方向写反(要求 IN ($N,真错例是 $N IN (…))→ 漏报喂修复前真 SQL 未命中

最终反证 10/10:2 类真风险必命中;8 类误报源(numeric 三种、make_interval、

单次使用、$1 重复但类型一致等)全不命中。当前全仓 84 文件零风险。

门禁设计教训:判据太宽 = 噪音 = 等于没门禁;但「全绿」必须先反证判据能抓已知错例,
否则是假绿。本 session 已三次遇到「顺证通过但反证失败」(py_compile 放过缺 import、
第一版 asyncpg 判据漏报、接线门禁需故意删项才验出有效)。