酒店价格线总设计

2026-09-14 · 库库 · 按 Ling 当晚三条:接口按我们未来的假设设计,不拿现在的东西当真正的价格来源;库可以重设计,按开发后台真正需要的接口去匹配;为以后新增价格来源做好提前量。
这份是酒店价格线(酒店主数据、来源与观测、清洗、当前价与日历、可比与趋势、报价、库存、知识、采集运维、依据)的整体设计。工作台怎么用它,见《工作台设计 v0.3》。接口合同在 api/hotel-line/openapi.yaml,按本设计定形。
00

设计立场

  1. 价格是一条链,不是一个数。 观测 → 清洗 → 展示 → 可比 → 报价。前三层保证"看得到",第四层保证"比得了",第五层保证"报得出"。展示不被比价卡住,比价不被来源数量卡住。
  2. 原料永不丢,派生随时重算。 来源看到的原样永远保留;清洗规则有版本;所有"当前价、日历、可比"都是派生表,规则一改就整体重算。这是"库可以改"的底气:改的是派生层和规则,不是历史。
  3. 来源是登记项,不是代码分支。 新来源只做三件事:登记契约、写观测、做一次匹配。其余全部自动流。
  4. 不猜。 单位不明、口径不明、儿童分档不明,都记成一条 gap 挂在价上,照样显示,只是不能复制给客人。
  5. 保留 v3.4 的五条不变量,它们没错:身份自铸、观测只增、金额精确、口径不齐拒绝相减、否定可存。要改的是表的组织方式和缺的层,不是这些原则。
01

五层链与三态

输入保证缺了怎么办
1 观测来源在某时点看到的一条价(来源眼中的酒店与房、方案、住期、人数、金额、币种、含税标志、单位、餐食、取消、可订、原文引用)只增不改,永远能回到原文无观测 = 无价,不猜
2 清洗观测对身份(对不上保留来源名);单位统一成"该人数该住期总价";口径对成含消费税与服务费的 GROSS;做不到的记 gapgap 留在价上
3 展示清洗后的价每个(酒店 × 房 × 住期 × 人数 × 来源)只留最新一条;采信期内 fresh,过期 stale;永远带"来源如此显示"的原样与时点没 fresh 显示 stale 标日期;都没有 none
4 可比同键下 ≥2 来源的清洗价口径齐才比,算不出 NULL 加原因单来源不可比,不影响展示与报价
5 报价一条展示价 + 库存 + 税则quote_ready = fresh 且总价可算;房型身份不是必要条件不 ready 的给销售看,不能复制,卡上写缺什么

UI 三色:绿 = quote_ready;琥珀 = 显示了但有 gap 或 stale;灰 = 无价。

今天的数据填到哪(2026-09-14 库内):转正 69 家,日式旅馆 14 家;当前有效渠道价 1308 条覆盖 43 家,旅馆 10 家 327 条;有房型身份 913,有来源房名 1213;口径已知 809,未知或部分 496;历史观测 18929。展示层今天就能显示全部 1308 条,报价层约 800 条量级,可比层 231 条。旅馆 4 家没价,是采集目标的事。

02

数据模型:按关注点分 schema

同一个库(luxing_kb),新开七个 schema,public 降为只读旧层直到切换完成。派生层可整体 drop 重建。

schema放什么性质
core酒店主数据、房型、目的地层级、别名、关系、媒体、标签身份,人工与判定写
src来源登记、来源契约版本、来源眼中的酒店与房(listing / room)、匹配判定、原始信封来源,采集器写
obs价格观测、可订观测、放房观测(按月分区,只增)原料,函数写
cur当前价、日历格、可比、趋势事件、gap 统计派生,可重算
trade库存批次 / 单位 / 占用、报价、订单、结算、税则交易,函数写
kb文档、片段、断言、断言证据、谓词词表知识,人工与判定写
ops采集目标、采集请求、采集批次、来源健康、新鲜度 SLO、清洗版本运维
wb工作台自持:owner、状态迁移、动作意图、效果回执、路由日志工作台写

2.1 core

2.2 src(来源与适配的提前量在这里)

2.3 obs(只增)

2.4 cur(派生,可重算)

2.5 trade

2.6 kb

2.7 ops

2.8 wb

03

来源适配框架(提前量)

原则:适配器只做"把来源的页面或接口翻成信封",不做清洗、不做匹配、不做判断。

观测信封(RawOffer envelope),所有来源统一:

{
  "source_code": "YAHOO_TRAVEL",
  "contract_version": 3,
  "captured_at": "2026-09-14T10:12:03+09:00",
  "listing": {"source_hotel_id": "00001258", "display_name": "…", "url": "…"},
  "room": {"source_room_code": "…", "source_plan_code": "…", "room_display_name": "…", "plan_display_name": "…"},
  "stay": {"start": "2027-02-14", "nights": 2},
  "occupancy": {"adults": 2, "children_ages": [3], "rooms": 1},
  "price": {"amount": 87600, "currency": "JPY", "unit_declared": "TOTAL_STAY", "basis_declared": "GROSS", "tax_included": true, "service_included": null, "child_breakdown": null},
  "meal": "HB",
  "cancel": {"free_until": "2027-01-20T23:59:00+09:00", "policy_raw": "…"},
  "availability": {"state": "available", "rooms_left": 1},
  "evidence": {"ref": "tx:grok_yahoo_raw/…json", "sha256": "…"},
  "raw": {}
}

未知一律 null,不填默认值。unit_declared / basis_declared 为 null 时由契约兜底,契约也没有就记 gap。

适配器契约(每个来源一份,进 src.source_contract):单位契约、口径契约、采信期、能力位(可订、房型级、日历批量、儿童分档)、限频、证据形态、契约证据、核实时间。契约测试:每个来源一组金样本(原始响应 → 期望信封),CI 跑;来源改版就是金样本失败,不是线上静默错价。

接入一个新来源的清单:登记 source 与 contract(十分钟);写适配器输出信封并过金样本(一到三天);跑一次匹配判定把 listing / room 对到我们的身份(半天到一天,视酒店数);采集目标登记节奏。之后价格自动流到展示层;口径齐自动进可比层。DidaTravel、官网引擎(HPDSP / 489pro)、GDS 都走同一条路。

版本与重算:契约升版只影响新观测;若发现旧契约错了,标记受影响的 contract_version 区间,cur.* 对该区间重算,obs.* 不动。

04

清洗规则(写死,版本化)

  1. 单位:PER_PERSON_STAY × 总人数 = 总价;TOTAL_STAY 直接;PER_ROOM_NIGHT × 晚数 × 间数;UNKNOWN 记 PRICE_UNIT_UNKNOWN
  2. 口径:GROSS 直接;NET 加消费税与服务费,税则缺记 *_RULE_MISSING;PARTIAL / UNKNOWN 记 PRICE_BASIS_UNKNOWN,原样金额照显并标"是否含税未核"。
  3. 到店付的税(宿泊税、入汤税)不进总价,报价卡单列。
  4. 儿童:有分档才算入,否则记 CHILD_PRICING_UNKNOWN,成人价照显。
  5. 采信期按契约;过期 stale,不删。
  6. 房型:对上给 room_type_id;对不上保留来源房名,不是 gap,只在可比层缺席。
  7. 币种:不跨币种比;报价卡给人民币参考值并标"参考"。
  8. 每次清洗写 normalizer_version;规则改动 = 新版本 + 全量重算 cur.*
05

接口(按层)

端点读哪层
酒店库GET /properties(q、kind、prefecture、destination、has_stock、adult_only)、GET /properties/{id}/rooms/knowledgeGET /destinationscore、kb
价格墙GET /prices?property_id&stay&nights&adults&children_ages → 该住期所有来源所有房的展示价(含 as_shown、gaps、quote_ready、comparable)cur.price_current + comparison
日历GET /calendar(五层可叠)、GET /calendar/destinationcur.calendar_cell
报价POST /quote/price(prefer_stock)、GET /quote/card/{id}cur + trade
趋势与事件GET /price-events?property_id&sinceGET /events?ownercur.price_event、trade 死线、wb
库存与机会GET /holdsGET /holds/{id}POST /holds/{id}/transitionsGET /opportunitiestrade、wb
探索POST /exploreGET /alternativescore、cur、trade
来源与采集GET /sources(契约、能力、健康、覆盖)、POST /collection/requests(要价)、GET /collection/requestssrc、ops
依据GET /evidence?ref=obs、src.raw_envelope
系统/healthz/meta(新鲜度、规模、SLO 违约)ops

价格对象统一为分层结构:sourceobservedcleaned{normalized_total_minor, gaps[]}display{state, as_shown_text, caveats[]}comparable{is_comparable, gross_minor}quote_readyui_state。前端只认 ui_statequote_ready,其余是"为什么"。

权限:read(销售、运营、经营)、trade(库存迁移,运营与销售 owner)、ops(采集请求、契约)、curate(匹配判定、知识核实)。凭据只存引用。

06

从今天的 luxing_kb 迁过去

不搬家,就地长:同库新建七个 schema。

  1. core.*src.*:从 public.property / room_type / src_listing / src_room / channel 视图化或复制,加 destination 层级与 source_contract(把 channel 的单位契约、TTL 拆成版本 1)。
  2. obs.price:从 public.price_observation 全量迁(18929 + 分区),加默认分区。obs.release:把 TX 的 calendar_obs_*.jsonl 回灌。
  3. cur.*:第一次全量清洗生成;之后由入库触发增量。public.channel_quote / v_price_comparable 退役为对照。
  4. trade.*:结构迁,数据从飞书囤房表回灌成 batch / unit / hold(106 行),owner 进 wb.owner
  5. kb.*:结构迁,种谓词词表。
  6. 切换判据:cur.price_currentv_price_comparable 的 231 条可比行数值一致;展示层条数 = 1308;旅馆 10 家全部有格。
07

分期

交付验收
P0(本周)合同 v0.2 按本设计定形 + 假数据服务 + 本文档星星与 Cursor 能对着合同搭世界层与报价卡
P1core / src / obs / cur 建 schema,全量清洗,/prices /calendar /quote/price 接真库;日式旅馆先上10 家旅馆日历有格,1308 条全显示,绿琥珀灰对得上
P2来源适配框架:信封、契约表、金样本;Yahoo 与 KNT 采集器改出信封;/sources 与要价新来源接入清单跑一遍 ≤3 天
P3trade / wb:囤房回灌、状态迁移、死线与机会、事件推送飞书 106 行入库且可迁移状态
P4可比与趋势:price_comparisonprice_event、放房入库有理由的追单有原料
P5知识:谓词词表、断言、客人安全文案儿童 / 刺青 / 送迎三类断言各 ≥20 家
08

待 Ling 与星星定

  1. 同库长七个 schema,还是新库。我建议同库,切换成本最低。
  2. 日式旅馆先上的范围:14 家全部,还是先 10 家有价的。
  3. 原始信封是否冷存到对象存储(TX 磁盘还是本机)。
  4. 溢价与成本是否对销售可见(v0.3 的待裁)。
  5. 采信期是否按 tier 差异化(S 级 24 小时)。