酒店价格线总设计
这份是酒店价格线(酒店主数据、来源与观测、清洗、当前价与日历、可比与趋势、报价、库存、知识、采集运维、依据)的整体设计。工作台怎么用它,见《工作台设计 v0.3》。接口合同在
api/hotel-line/openapi.yaml,按本设计定形。设计立场
- 价格是一条链,不是一个数。 观测 → 清洗 → 展示 → 可比 → 报价。前三层保证"看得到",第四层保证"比得了",第五层保证"报得出"。展示不被比价卡住,比价不被来源数量卡住。
- 原料永不丢,派生随时重算。 来源看到的原样永远保留;清洗规则有版本;所有"当前价、日历、可比"都是派生表,规则一改就整体重算。这是"库可以改"的底气:改的是派生层和规则,不是历史。
- 来源是登记项,不是代码分支。 新来源只做三件事:登记契约、写观测、做一次匹配。其余全部自动流。
- 不猜。 单位不明、口径不明、儿童分档不明,都记成一条 gap 挂在价上,照样显示,只是不能复制给客人。
- 保留 v3.4 的五条不变量,它们没错:身份自铸、观测只增、金额精确、口径不齐拒绝相减、否定可存。要改的是表的组织方式和缺的层,不是这些原则。
五层链与三态
| 层 | 输入 | 保证 | 缺了怎么办 |
|---|---|---|---|
| 1 观测 | 来源在某时点看到的一条价(来源眼中的酒店与房、方案、住期、人数、金额、币种、含税标志、单位、餐食、取消、可订、原文引用) | 只增不改,永远能回到原文 | 无观测 = 无价,不猜 |
| 2 清洗 | 观测 | 对身份(对不上保留来源名);单位统一成"该人数该住期总价";口径对成含消费税与服务费的 GROSS;做不到的记 gap | gap 留在价上 |
| 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 家没价,是采集目标的事。
数据模型:按关注点分 schema
同一个库(luxing_kb),新开七个 schema,public 降为只读旧层直到切换完成。派生层可整体 drop 重建。
| schema | 放什么 | 性质 |
|---|---|---|
core | 酒店主数据、房型、目的地层级、别名、关系、媒体、标签 | 身份,人工与判定写 |
src | 来源登记、来源契约版本、来源眼中的酒店与房(listing / room)、匹配判定、原始信封 | 来源,采集器写 |
obs | 价格观测、可订观测、放房观测(按月分区,只增) | 原料,函数写 |
cur | 当前价、日历格、可比、趋势事件、gap 统计 | 派生,可重算 |
trade | 库存批次 / 单位 / 占用、报价、订单、结算、税则 | 交易,函数写 |
kb | 文档、片段、断言、断言证据、谓词词表 | 知识,人工与判定写 |
ops | 采集目标、采集请求、采集批次、来源健康、新鲜度 SLO、清洗版本 | 运维 |
wb | 工作台自持:owner、状态迁移、动作意图、效果回执、路由日志 | 工作台写 |
2.1 core
core.property:property_id uuid,code,kind(HOTEL / RYOKAN / VILLA / MACHIYA / OTHER),四语名,brand,hotel_group,destination_id,prefecture,city,地址,经纬度,rooms_count,checkin/checkout,adult_only(NULL = 未核实),tier(S/A/B/C),positioning_short,status(CANDIDATE / ACTIVE / PAUSED / CONSULT_ONLY / ARCHIVED),official_url,verified_at。core.destination:层级树country → region → prefecture → area,每级destination_id、parent_id、level、name_cn/ja/en、geo_center、season_profile jsonb(红叶、樱花、雪、花火的典型时段,供日历事件层与"换个角度")。这是 v3.4 缺的一块,世界层的地图和替代推荐都靠它。core.room_type:现有列保留(和洋、面积、床、定员、私汤 / 露天 / 温泉三 bool 允许 NULL、景观、禁烟、状态)。core.property_alias、core.property_relation、core.building、core.media_asset、core.tag*:保留。core.event_calendar:destination_id,code,label,date_range,source,confidence。红叶、花火、连休。日历事件层的真源。
2.2 src(来源与适配的提前量在这里)
src.source:source_code(IKYU、YAHOO_TRAVEL、KNT、NTA、JALAN、RAKUTEN、OFFICIAL_xxx、DIDA、GDS_xxx…),kind(OTA / OFFICIAL / WHOLESALER / GDS / INTERNAL),profit_model,currency_default,status,notes。src.source_contract:版本化的来源契约。source_code,contract_version,valid_from,price_unit_contract(PER_PERSON_STAY / TOTAL_STAY / PER_ROOM_NIGHT / DECLARED_PER_ROW),basis_contract(GROSS / NET / PARTIAL / DECLARED_PER_ROW),ttl_hours,supports_availability,supports_room_level,supports_calendar_bulk,supports_children_breakdown,rate_limit_note,evidence_kind(JSON / HTML / API),contract_evidence,verified_at。清洗永远按观测当时生效的契约版本算,契约改了只重算受影响的观测。src.listing:来源眼中的酒店。listing_id,source_code,source_hotel_id,epoch,display_name,url,raw_profile,lifecycle(ACTIVE / STALE / GONE),property_id,match_state。src.room:来源眼中的房与方案。room_id,listing_id,source_room_code,source_plan_code,room_display_name,plan_display_name,meal_guess,attrs,room_type_id,match_verdict(EXACT / FAMILY / DIFFERENT / UNKNOWN)。src.match_decision:listing 与 room 的判定流水,保留现有 apply 函数思路。src.raw_envelope:每条观测的原始信封(见 §3),envelope_id,source_code,contract_version,captured_at,payload jsonb,evidence_ref,sha256。这是"重新清洗"的起点;体积大就按月分区并冷存到对象存储,库里留指针。
2.3 obs(只增)
obs.price:obs_id,captured_at,envelope_id,source_code,listing_id,room_id,property_id,room_type_id(可空),stay_start,nights,adults,children_ages int[],rooms,meal_code,amount_minor,currency,price_unit_declared,basis_declared,tax_included,service_included,child_breakdown,rate_code,plan_code,member_rate,cancel_free_until,cancel_policy_raw,availability(available / limited / sold_out / unknown),evidence_ref,fingerprint。按captured_at月分区,带默认分区兜底,另有每月预建作业。obs.availability:checked_at,来源,酒店,房,stay_start,nights,人数,result(FOUND / NOT_FOUND / ERROR),rooms_left,price_minor,detail。obs.release:放房观测(今天在 TX 的 JSONL)。observed_at,来源,酒店,stay_month,first_seen_stay_date,slots_before,slots_after,evidence_ref。放房规律观测台的数据从此入库。
2.4 cur(派生,可重算)
cur.price_current:展示层。唯一键(property_id,source_code,room_key,plan_key,stay_start,nights,occupancy_sig),其中room_key= 我们的room_type_id或来源房码。列:最新obs_id,captured_at,display_state(fresh / stale),as_shown(金额、币种、单位、口径、原样文字),normalized_total_minor(可空),per_night_minor,gaps text[],quote_ready bool,ui_state,normalizer_version。cur.calendar_cell:世界层日历的读面。键(property_id,room_type_id可空,date,occupancy_sig)。列:best_price(指向 price_current)、state_counts(green / amber / grey 各几条)、availability、release_flag、stock_rooms、event_codes[]、refreshed_at。夜间全量 + 入库增量。cur.price_comparison:可比层。键(property_id,room_type_id,stay_start,nights,occupancy_sig,currency),列:来源数、各来源 GROSS、最低来源、价差、comparability_key。cur.price_event:趋势事件。PRICE_DROP、PRICE_RISE、NEW_AVAILABILITY、SOLD_OUT、RELEASE_OBSERVED、STALE_NOW。带property_id、stay_start、来源、前后值、at。这是"有理由的追单"和推送条的原料。cur.gap_stats:按来源 × gap 码统计,告诉我们下一个该修的契约或税则是哪个。
2.5 trade
trade.inventory_batch/inventory_unit/inventory_hold:保留 v3.4 的形,加owner_ref(指向 wb.owner)、cost_total_minor、payment_mode(現地 / 事前)、source_code、reservation_ref_masked。状态机:batch ACTIVE / RELEASED / EXPIRED / USED_UP;hold HELD / CONVERTED / RELEASED / EXPIRED。工作台看到的 REGISTERED → QUOTED → SOLD → RELEASED 是投影。trade.tax_rule:保留(税种、算法、费率、分档、collectedIN_PRICE / AT_PROPERTY、来源、verified_at),加applies_to_kind。trade.quotation、trade.booking、trade.settlement:保留快照与锁的设计。trade.v_deadlines、trade.v_inventory_remaining:保留。trade.v_hold_premium:溢价是算出来的:(cur 最优 quote_ready 总价 − 成本) / 成本,无市场价则 NULL。
2.6 kb
kb.predicate:谓词词表先种上:CHILD_POLICY、CHILD_PRICING、AGE_LIMIT、TATTOO_POLICY、SHUTTLE、MEAL_STYLE、PRIVATE_BATH、PAYMENT_ONSITE、CANCEL_LADDER、ACCESSIBILITY、PET。每条带value_schema、customer_safe_template。kb.document、kb.fragment、kb.assertion、kb.assertion_evidence:保留 v3.4 设计(DRAFT / VERIFIED / RETRACTED;AFFIRMS / DENIES / UNKNOWN;冲突不由 AI 选边)。kb.v_citable:只出 VERIFIED 且有支持片段且在有效期的;给客人的话未核实时固定为"我帮您确认"。
2.7 ops
ops.collection_target:酒店 × 来源 × 节奏(24 / 72 / 168 / 720 小时)× 住期窗口 × 人数集合 ×priority。ops.collection_request:"要价"按钮的落点。requested_by,酒店,住期范围,人数,reason(NONE_ON_CALENDAR / STALE / CUSTOMER_ASKED),status(OPEN / SCHEDULED / DONE / FAILED),fulfilled_obs_ids。ops.ingest_batch:保留。ops.source_health:按来源按小时:成功、失败、403、平均耗时、最近成功时刻。ops.freshness_slo:按 tier:S 级 24 小时、A 级 72、B 级 168;违约进健康页。ops.normalizer_version:清洗规则版本与生效时间。
2.8 wb
wb.owner:人(销售、运营)与席位的登记,kind,display_name,role。wb.hold_transition:库存状态迁移流水(hold_id,from,to,actor,customer_ref,at,note)。wb.action_intent/wb.effect_receipt:自持工单轨。wb.route_log:路由表的采纳记录。
来源适配框架(提前量)
原则:适配器只做"把来源的页面或接口翻成信封",不做清洗、不做匹配、不做判断。
观测信封(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.* 不动。
清洗规则(写死,版本化)
- 单位:PER_PERSON_STAY × 总人数 = 总价;TOTAL_STAY 直接;PER_ROOM_NIGHT × 晚数 × 间数;UNKNOWN 记
PRICE_UNIT_UNKNOWN。 - 口径:GROSS 直接;NET 加消费税与服务费,税则缺记
*_RULE_MISSING;PARTIAL / UNKNOWN 记PRICE_BASIS_UNKNOWN,原样金额照显并标"是否含税未核"。 - 到店付的税(宿泊税、入汤税)不进总价,报价卡单列。
- 儿童:有分档才算入,否则记
CHILD_PRICING_UNKNOWN,成人价照显。 - 采信期按契约;过期 stale,不删。
- 房型:对上给
room_type_id;对不上保留来源房名,不是 gap,只在可比层缺席。 - 币种:不跨币种比;报价卡给人民币参考值并标"参考"。
- 每次清洗写
normalizer_version;规则改动 = 新版本 + 全量重算cur.*。
接口(按层)
| 面 | 端点 | 读哪层 |
|---|---|---|
| 酒店库 | GET /properties(q、kind、prefecture、destination、has_stock、adult_only)、GET /properties/{id}、/rooms、/knowledge、GET /destinations | core、kb |
| 价格墙 | GET /prices?property_id&stay&nights&adults&children_ages → 该住期所有来源所有房的展示价(含 as_shown、gaps、quote_ready、comparable) | cur.price_current + comparison |
| 日历 | GET /calendar(五层可叠)、GET /calendar/destination | cur.calendar_cell |
| 报价 | POST /quote/price(prefer_stock)、GET /quote/card/{id} | cur + trade |
| 趋势与事件 | GET /price-events?property_id&since、GET /events?owner | cur.price_event、trade 死线、wb |
| 库存与机会 | GET /holds、GET /holds/{id}、POST /holds/{id}/transitions、GET /opportunities | trade、wb |
| 探索 | POST /explore、GET /alternatives | core、cur、trade |
| 来源与采集 | GET /sources(契约、能力、健康、覆盖)、POST /collection/requests(要价)、GET /collection/requests | src、ops |
| 依据 | GET /evidence?ref= | obs、src.raw_envelope |
| 系统 | /healthz、/meta(新鲜度、规模、SLO 违约) | ops |
价格对象统一为分层结构:source、observed、cleaned{normalized_total_minor, gaps[]}、display{state, as_shown_text, caveats[]}、comparable{is_comparable, gross_minor}、quote_ready、ui_state。前端只认 ui_state 与 quote_ready,其余是"为什么"。
权限:read(销售、运营、经营)、trade(库存迁移,运营与销售 owner)、ops(采集请求、契约)、curate(匹配判定、知识核实)。凭据只存引用。
从今天的 luxing_kb 迁过去
不搬家,就地长:同库新建七个 schema。
core.*、src.*:从public.property / room_type / src_listing / src_room / channel视图化或复制,加destination层级与source_contract(把channel的单位契约、TTL 拆成版本 1)。obs.price:从public.price_observation全量迁(18929 + 分区),加默认分区。obs.release:把 TX 的calendar_obs_*.jsonl回灌。cur.*:第一次全量清洗生成;之后由入库触发增量。public.channel_quote/v_price_comparable退役为对照。trade.*:结构迁,数据从飞书囤房表回灌成 batch / unit / hold(106 行),owner 进wb.owner。kb.*:结构迁,种谓词词表。- 切换判据:
cur.price_current对v_price_comparable的 231 条可比行数值一致;展示层条数 = 1308;旅馆 10 家全部有格。
分期
| 期 | 交付 | 验收 |
|---|---|---|
| P0(本周) | 合同 v0.2 按本设计定形 + 假数据服务 + 本文档 | 星星与 Cursor 能对着合同搭世界层与报价卡 |
| P1 | core / src / obs / cur 建 schema,全量清洗,/prices /calendar /quote/price 接真库;日式旅馆先上 | 10 家旅馆日历有格,1308 条全显示,绿琥珀灰对得上 |
| P2 | 来源适配框架:信封、契约表、金样本;Yahoo 与 KNT 采集器改出信封;/sources 与要价 | 新来源接入清单跑一遍 ≤3 天 |
| P3 | trade / wb:囤房回灌、状态迁移、死线与机会、事件推送 | 飞书 106 行入库且可迁移状态 |
| P4 | 可比与趋势:price_comparison、price_event、放房入库 | 有理由的追单有原料 |
| P5 | 知识:谓词词表、断言、客人安全文案 | 儿童 / 刺青 / 送迎三类断言各 ≥20 家 |
待 Ling 与星星定
- 同库长七个 schema,还是新库。我建议同库,切换成本最低。
- 日式旅馆先上的范围:14 家全部,还是先 10 家有价的。
- 原始信封是否冷存到对象存储(TX 磁盘还是本机)。
- 溢价与成本是否对销售可见(v0.3 的待裁)。
- 采信期是否按 tier 差异化(S 级 24 小时)。