線稿索引所有 spec最後更新 2026-06-01
specs/lodging-inventory.md

旅宿庫存脊椎

一句話:自家旅宿(YAMADO:南投露營、長野雪場旅館)的主檔 + 房型 + 每晚庫存 + 日曆價,是訂房的脊椎,也是每晚房型庫存的 SSOT;兩個賣的通路 —— 直接訂房(散客)與 行程配套(團體)—— 都扣同一份每晚庫存以避免超賣。

模組化 的「訂房」開關 gate(平台授權 module_lodging + 物業存在才生效)。

關聯:直接訂房(通路 A)· 行程配套住宿(通路 B)· 團控分房departure_rooms 對齊)· 行程定價(金額 / 付款沿用)。動機來源:2026-05-30 user — 旅行社也賣訂房型產品(自家 YAMADO),ERP 內自管每晚床位。

1. 問題與動機

現行 ERP 全是行程導向trips → departures → orders,訂單訂「席次」)。但荒野還有自家旅宿要賣:

  • 南投露營 — 目前單獨賣(散客直訂房型)。
  • 長野雪場旅館 — 目前搭滑雪行程配套賣(行程設計時固定指定旅館,旅館有自己的細節資料)。

兩者套不進 departures(梯次 = 固定出發日 + 席次):旅宿的庫存單位是某房型的每一晚,客人選的是入住 / 退房日期區間。現況痛點:沒有旅宿主檔、沒有房況、沒有日曆價,更沒有「行程住宿與散客訂房共用一份床位、避免超賣」的機制(需求文件當初點名的 YAMADO 超賣風險)。

解法:建一份旅宿主檔 + 房型 + 每晚庫存(單一真相)+ 日曆價;兩個賣的通路都扣這份庫存。庫存扣減race-safe,直接沿用既有座位預留模式(conditional UPDATE + CHECK),只是單位從「梯次席次」換成「每晚每房型」。

現況 vs 彈性:目前每間物業只走單一通路(南投只散客、長野只配套),不會混。但共用庫存模型本就涵蓋「只有一個通路在扣」的退化情形 → 架構支援未來混用 / 其他租戶,但現階段不實作混用才會遇到的難題(見 §9)。

2. 範圍

2.1 在範圍內

  • 旅宿主檔 properties:名稱、類型(露營 / 旅館)、地點、細節(含設施、照片、說明)。
  • 房型 room_types:每旅宿多個房型;庫存單位(整間房 or 床位,一個欄位涵蓋)、單元數、每單元人數、基準晚價。
  • 每晚庫存 + 日曆價 room_night_inventory:每房型每晚的「容量 / 已訂 / 當晚價」單一真相;平日 / 假日 / 旺季靠日曆價覆寫。
  • race-safe 扣庫存:跨日期區間一次扣多晚,全有或全無(任一晚不足即整筆失敗,不超賣)。
  • 房況查詢:某房型某日期區間是否足量 + 區間總價;團控 / 後台房況日曆。
  • 角色權限:旅宿主檔維護(op / admin)。
  • 訂房通路(建訂單、扣庫存、付款)由 直接訂房 / 行程配套 定義,本 spec 只提供庫存基礎建設

2.2 明確不做(Future / Out of scope)

  • 外部 PMS 串接(YAMADO 若有自己的 PMS):本 spec 是 ERP 內自管,不串外部(user 已定)。
  • 混用通路的進階工具:團體預留 vs 散客同秒搶同床的競爭 UI、部分釋放團體 block —— 現況不混,留 future(§9)。
  • 動態定價 / yield management:只做「日曆固定覆寫價」,不做依入住率自動調價。
  • 超賣允許 / overbooking buffer:現階段硬上限,不做故意超賣緩衝。
  • 跨旅宿 / OTA 通路(Booking.com 等):只賣自家、自家通路。

3. 資料模型

對齊現行慣例:穩定欄位 / 新表直接進 packages/core/src/schema/business.ts;主鍵 ULID text;金額整數 TWD;timestamp 帶 tz。

3.1 properties(旅宿主檔)

export const properties = pgTable(
  'properties',
  {
    id: text('id').primaryKey(), // 'prop_<ulid>'
    slug: text('slug').notNull(),
    name: text('name').notNull(),
    /** 'camp'(露營)| 'hotel'(旅館)| 其他自由值。 */
    kind: text('kind').notNull().default('hotel'),
    /** 國內/海外 + 地點顯示。海外旅宿(長野)受海外模組 gate 才需護照。 */
    isOverseas: boolean('is_overseas').notNull().default(false),
    location: text('location'), // 顯示用地點(南投 / 長野白馬)
    description: text('description'),
    contentMdx: text('content_mdx'), // 細節資料(設施、交通、注意事項)
    facilities: jsonb('facilities'), // [{ icon, label }] 設施清單
    coverImageUrl: text('cover_image_url'),
    /** 輕量用途標記(只控 UI,不限制庫存邏輯):'direct' | 'package' | 'both'。 */
    usageHint: text('usage_hint').notNull().default('both'),
    status: text('status').notNull().default('active'), // 'active' | 'inactive'
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    uniqueIndex('properties_slug_unique').on(t.slug),
    index('properties_status_idx').on(t.status),
  ],
);

內容欄位擴充(OTA 對標 + JSON-LD):入住退房時間 / 結構化地址 + 座標 / 退訂規定 / 入住須知 / 報到交通 / 聯絡 / 寵物吸菸布林 / 設施 catalog / 相簿表,設計集中於 lodging-direct-booking §8(user 2026-06-01 拍板 SSOT 例外)。facilities jsonb 明確存 AmenityKey[]

3.2 room_types(房型)

export const roomTypes = pgTable(
  'room_types',
  {
    id: text('id').primaryKey(), // 'rt_<ulid>'
    propertyId: text('property_id')
      .notNull()
      .references(() => properties.id, { onDelete: 'cascade' }),
    name: text('name').notNull(), // 獨立木屋 / 6 人帳 / 和室雙人
    /**
     * 庫存與販售單位:
     *  'room' = 賣整間(庫存 = 每晚還剩幾間;飯店型,長野)
     *  'bed'  = 賣床位(庫存 = 每晚還剩幾張床;山屋/青旅/露營通舖)
     * 一個欄位涵蓋兩種,不寫死。
     */
    inventoryUnit: text('inventory_unit').notNull().default('room'), // 'room' | 'bed'
    /** 此房型實體單元總數(room 模式=幾間;bed 模式=幾張床)。日曆容量預設值。 */
    unitsTotal: integer('units_total').notNull(),
    /** 每單元可住人數(room 模式 = 房可住幾人;bed 模式通常 1)。入住人數驗證用。 */
    capacityPerUnit: integer('capacity_per_unit').notNull().default(1),
    /** 基準晚價(TWD)。日曆未覆寫時用此價。 */
    basePriceTwd: integer('base_price_twd').notNull(),
    description: text('description'),
    facilities: jsonb('facilities'),
    status: text('status').notNull().default('active'),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    index('room_types_property_idx').on(t.propertyId),
    check('room_types_units_positive', sql`${t.unitsTotal} > 0`),
  ],
);

內容欄位擴充:床型 bed_config / 坪數 floor_size_sqm / 房型設施 / 房型相簿,設計見 lodging-direct-booking §8

3.3 room_night_inventory(每晚庫存 + 日曆價 —— 單一真相)

這張表是脊椎。每房型每晚一列,記容量 / 已訂 / 當晚價。兩個通路都扣 booked_count。CHECK 擋超賣(與 departures.booked_count <= capacity 同手法)。

export const roomNightInventory = pgTable(
  'room_night_inventory',
  {
    roomTypeId: text('room_type_id')
      .notNull()
      .references(() => roomTypes.id, { onDelete: 'cascade' }),
    /** 該晚(入住日;區間 [checkIn, checkOut) 的每一天一列)。 */
    night: date('night').notNull(),
    /** 當晚可售容量(預設 = room_type.units_total;可per晚下調做維修/封房)。 */
    capacity: integer('capacity').notNull(),
    /** 當晚已訂(兩通路共用;race-safe 扣減的對象)。 */
    bookedCount: integer('booked_count').notNull().default(0),
    /** 當晚價覆寫(NULL = 用 room_type.base_price_twd)。平假日/旺季調價放這。 */
    priceTwd: integer('price_twd'),
    /** 'open' | 'closed'(封房;closed 時不可再訂)。 */
    status: text('status').notNull().default('open'),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    primaryKey({ columns: [t.roomTypeId, t.night] }),
    check(
      'rni_booked_within_capacity',
      sql`${t.bookedCount} >= 0 AND ${t.bookedCount} <= ${t.capacity}`,
    ),
    index('rni_night_idx').on(t.night),
  ],
);

為何 per-night 一列而非「holds 推算」:race-safety 要的是「每一晚一個可被 conditional UPDATE 的計數器」(同 departures)。per-night 列 + CHECK 是最直接、與既有座位模式一致的做法。列lazy upsert:第一次碰到某房型某晚時,以 room_type.units_total 為 capacity 建列(見 §4 預留邏輯)。

3.4 與既有 departure_rooms 的劃界

  • 既有 departure_roomsroom-assignment)= 梯次內分房(team→床位 + 房費成本)。保留不動
  • 本 spec 的旅宿庫存是可重複售賣的資源池行程配套 會讓梯次引用 property / room_type + 對庫存「整塊預留」,並據此產生 / 對齊 departure_rooms(取代手 key)。劃界細節在配套 spec。

3.5 訂單接點:order line model

訂房成交必須接 orders 的 line model:

  • orders 只當 operational shell,保存 kind 與三軸狀態快取。
  • order_lines(line_kind='lodging') 表示買了一個 lodging 商品。
  • lodging_booking_lines 保存房型、入住退房區間、間數與入住人數。
  • lodging_nightly_snapshots 保存逐晚價格與稽核快照。
  • order_charge_lines(type='lodging_night') 保存每晚應收;折扣、補費、退款接 order-money-model

旅宿庫存 spec 只擁有 property / room_type / room_night_inventory;建單 flow 見 直接訂房

3.6 ER 關係

properties (1) ─── (N) room_types (1) ─── (N) room_night_inventory   [每晚 容量/已訂/價]
                                                      ▲          ▲
                          直接訂房(散客) ─────────────┘          └───── 行程配套(梯次整塊預留)
                          lodging_booking_lines                  departures ← lodging-tour-package
                                  扣同一份 booked_count(race-safe)

4. Race-safe 庫存扣減

跨日期區間 [checkIn, checkOut)units 單元 → 每一晚都要 booked + units <= capacity,否則整筆失敗(不超賣、不部分成立)。

4.1 演算法(在 transaction 內,沿用座位預留手法)

reserveNights(roomTypeId, checkIn, checkOut, units):
  在一個 transaction:
  for night in [checkIn .. checkOut):                 # 不含退房日
    ensureInventoryRow(roomTypeId, night)             # lazy upsert,capacity = units_total
    UPDATE room_night_inventory
      SET booked_count = booked_count + units, updated_at = now()
      WHERE room_type_id = :rt AND night = :n
        AND status = 'open'
        AND booked_count + :units <= capacity         # ← 條件式,CHECK 再兜底
    if rowsAffected != 1: ROLLBACK → 回 { ok:false, reason:'NO_AVAILABILITY', night }
  COMMIT → { ok:true }
  • 條件式 UPDATE + CHECK (booked_count <= capacity) 雙保險(同 departures 的 race-safe 慣例)。
  • 為避免跨晚部分成立,整段在同一 transaction;任一晚失敗即 rollback 全部。
  • 釋回(取消):對同區間每晚 booked_count - units(不低於 0)。
  • 為避免高併發下的死結,建議對該 room_type 的區間以固定順序(日期升冪)更新。

4.2 房況 / 價格查詢

// 區間可訂量(min over nights)+ 區間總價(sum nightly price)
getAvailability(db, { roomTypeId, checkIn, checkOut }): Promise<{
  available: number;            // = min(capacity - booked) over nights(0 表不可訂)
  nights: number;
  totalPriceTwd: number;        // Σ (priceTwd ?? room_type.base_price_twd) per night per unit
  perNight: Array<{ night: string; remaining: number; priceTwd: number; status: string }>;
}>;
  • 時區:night 是純 date(無 tz);「今天 / 可訂起始日」判定用 Asia/Taipei(對齊既有 next_order_number() 慣例)。

5. Server / Repo

packages/core/src/lodging/inventory-repo.ts;first arg db: ScopedDb;result-object;transaction + writeAuditLog

// 主檔 CRUD(admin)
createPropertyAction / updatePropertyAction / archivePropertyAction
createRoomTypeAction / updateRoomTypeAction / archiveRoomTypeAction

// 日曆 / 房況維護
setNightlyOverrideAction(input: { roomTypeId; from; to; priceTwd?; capacity?; status? })
//   批次覆寫一段區間的價 / 容量 / 封房(lazy upsert 列)

// 庫存原語(給兩個通路呼叫,不直接對外)
reserveNights(db, { roomTypeId, checkIn, checkOut, units })
  : { ok: true } | { ok: false; reason: 'NO_AVAILABILITY' | 'CLOSED_NIGHT'; night?: string }
releaseNights(db, { roomTypeId, checkIn, checkOut, units }): void
getAvailability(db, { roomTypeId, checkIn, checkOut })

6. 角色與權限

走 better-auth createAccessControlpackages/core/src/permissions-ac.ts)。新增 lodging resource。

lodging: ["read", "manage"],   // catalog
動作permission授權角色
看旅宿 / 房況lodging.readadmin、op、sales(報價)、accountant
建 / 改旅宿 · 房型 · 日曆價 · 封房lodging.manageadmin、op
  • 全部受訂房模組授權前提:module_lodging off → lodging.* 不出現於 nav、頁面 404 / 隱藏(modules §3)。

7. UI(接點)

  • 新 nav + 頁 /admin/lodging(旅宿管理):旅宿清單 → 房型 → 房況日曆(每房型每晚 容量 / 已訂 / 價,可改價 / 封房)。詳見 wireframe lodging.html
  • nav 項受訂房模組 gate(_shell.js,未授權不顯示)。
  • 房況日曆同時顯示兩通路的佔用來源(散客訂房 / 某梯次配套)以利人工判讀。
  • 主入口是獨立 nav(旅宿是跨日期的資源主檔,非 per-departure 批次 → 對齊 CLAUDE.md 線控 UX:跨梯次功能才開獨立 nav)。

8. 測試

vitest(repo + race-safe 為主):

  • reserveNights:足量 → booked 各晚 +units;任一晚不足 → 整筆 rollback、各晚不變;退房日不算一晚。
  • 併發:兩筆同時訂最後一單元 → 只一筆成功(conditional UPDATE 驗證,仿既有座位測試)。
  • releaseNights:扣回正確、不低於 0。
  • getAvailability:區間 min 可訂量、Σ 日曆價(含覆寫 / fallback base)、封房晚 → available 0。
  • 日曆覆寫:平假日不同價套用正確;封房 status='closed' → reserve 該晚失敗 CLOSED_NIGHT
  • 時區:跨台北日界線的 night 邊界。

9. 現況彈性邊界(MVP 不做的混用難題)

情境現階段未來
一物業只單一通路(南投只散客 / 長野只配套)✅ 共用庫存的退化情形,直接支援
同物業混團體 + 散客模型支援(都扣同一份)但不做進階工具競爭 UI、團體 block 部分釋放、超賣緩衝
usage_hint只控 UI 顯示(南投標 direct、長野標 package)若要硬性限制某通路改這欄語意

10. 影響範圍

類別內容
新表properties · room_types · room_night_inventory(drizzle)
新 permissionlodging.read / lodging.manage
新 repopackages/core/src/lodging/inventory-repo.ts(含 race-safe 原語)
新 nav + page/admin/lodging(受訂房模組 gate)
模組modules 加「訂房模組」+ control_db.tenant.module_lodging
核心接觸點orders 一般化(departure_id nullable + kind)→ 由 直接訂房 處理
既有對齊departure_rooms配套 引用旅宿主檔產生(不重造)

11. 未決事項

項目預設待釐清
露營賣整棟木屋 vs 單床inventory_unit 兩種皆支援;南投實際是哪種待填露營房型清單與單位
日曆價輸入 UI批次區間覆寫(平日/假日/旺季)是否要「規則」(每週末 +X)而非逐日
入住人名單沿用 travelers(綁 lodging order)直訂是否一定要逐人資料,或只記訂房人
封房粒度per 房型 per 晚是否要 per 物業整體封
room_night_inventory 列爆量lazy upsert(只碰到才建)長期清理過去日期列的策略

Changelog

版本日期變更Commit
12026-05-30初版草案:旅宿主檔 + 房型 + 每晚共用庫存 + 日曆價 + race-safe 扣庫存
22026-05-31脊椎落地:property / room type / nightly inventory、race-safe reserve/release/getAvailability、後台房況日曆與訂房模組 gate。訂房下單 flow 見 lodging-direct-booking(本批)