一句話:自家旅宿(YAMADO:南投露營、長野雪場旅館)的主檔 + 房型 + 每晚庫存 + 日曆價,是訂房的脊椎,也是每晚房型庫存的 SSOT;兩個賣的通路 —— 直接訂房(散客)與 行程配套(團體)—— 都扣同一份每晚庫存以避免超賣。
受 模組化 的「訂房」開關 gate(平台授權
module_lodging+ 物業存在才生效)。關聯:直接訂房(通路 A)· 行程配套住宿(通路 B)· 團控分房(
departure_rooms對齊)· 行程定價(金額 / 付款沿用)。動機來源:2026-05-30 user — 旅行社也賣訂房型產品(自家 YAMADO),ERP 內自管每晚床位。
現行 ERP 全是行程導向(trips → departures → orders,訂單訂「席次」)。但荒野還有自家旅宿要賣:
兩者套不進 departures(梯次 = 固定出發日 + 席次):旅宿的庫存單位是某房型的每一晚,客人選的是入住 / 退房日期區間。現況痛點:沒有旅宿主檔、沒有房況、沒有日曆價,更沒有「行程住宿與散客訂房共用一份床位、避免超賣」的機制(需求文件當初點名的 YAMADO 超賣風險)。
解法:建一份旅宿主檔 + 房型 + 每晚庫存(單一真相)+ 日曆價;兩個賣的通路都扣這份庫存。庫存扣減race-safe,直接沿用既有座位預留模式(conditional UPDATE + CHECK),只是單位從「梯次席次」換成「每晚每房型」。
現況 vs 彈性:目前每間物業只走單一通路(南投只散客、長野只配套),不會混。但共用庫存模型本就涵蓋「只有一個通路在扣」的退化情形 → 架構支援未來混用 / 其他租戶,但現階段不實作混用才會遇到的難題(見 §9)。
properties:名稱、類型(露營 / 旅館)、地點、細節(含設施、照片、說明)。room_types:每旅宿多個房型;庫存單位(整間房 or 床位,一個欄位涵蓋)、單元數、每單元人數、基準晚價。room_night_inventory:每房型每晚的「容量 / 已訂 / 當晚價」單一真相;平日 / 假日 / 旺季靠日曆價覆寫。對齊現行慣例:穩定欄位 / 新表直接進
packages/core/src/schema/business.ts;主鍵 ULIDtext;金額整數 TWD;timestamp 帶 tz。
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 例外)。
facilitiesjsonb 明確存AmenityKey[]。
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。
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 預留邏輯)。
departure_rooms 的劃界departure_rooms(room-assignment)= 梯次內分房(team→床位 + 房費成本)。保留不動。departure_rooms(取代手 key)。劃界細節在配套 spec。訂房成交必須接 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 見 直接訂房。
properties (1) ─── (N) room_types (1) ─── (N) room_night_inventory [每晚 容量/已訂/價]
▲ ▲
直接訂房(散客) ─────────────┘ └───── 行程配套(梯次整塊預留)
lodging_booking_lines departures ← lodging-tour-package
扣同一份 booked_count(race-safe)
跨日期區間 [checkIn, checkOut) 訂 units 單元 → 每一晚都要 booked + units <= capacity,否則整筆失敗(不超賣、不部分成立)。
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 }
CHECK (booked_count <= capacity) 雙保險(同 departures 的 race-safe 慣例)。booked_count - units(不低於 0)。// 區間可訂量(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() 慣例)。放 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 })
走 better-auth
createAccessControl(packages/core/src/permissions-ac.ts)。新增lodgingresource。
lodging: ["read", "manage"], // catalog
| 動作 | permission | 授權角色 |
|---|---|---|
| 看旅宿 / 房況 | lodging.read | admin、op、sales(報價)、accountant |
| 建 / 改旅宿 · 房型 · 日曆價 · 封房 | lodging.manage | admin、op |
module_lodging off → lodging.* 不出現於 nav、頁面 404 / 隱藏(modules §3)。/admin/lodging(旅宿管理):旅宿清單 → 房型 → 房況日曆(每房型每晚 容量 / 已訂 / 價,可改價 / 封房)。詳見 wireframe lodging.html。_shell.js,未授權不顯示)。vitest(repo + race-safe 為主):
reserveNights:足量 → booked 各晚 +units;任一晚不足 → 整筆 rollback、各晚不變;退房日不算一晚。releaseNights:扣回正確、不低於 0。getAvailability:區間 min 可訂量、Σ 日曆價(含覆寫 / fallback base)、封房晚 → available 0。status='closed' → reserve 該晚失敗 CLOSED_NIGHT。| 情境 | 現階段 | 未來 |
|---|---|---|
| 一物業只單一通路(南投只散客 / 長野只配套) | ✅ 共用庫存的退化情形,直接支援 | — |
| 同物業混團體 + 散客 | 模型支援(都扣同一份)但不做進階工具 | 競爭 UI、團體 block 部分釋放、超賣緩衝 |
usage_hint | 只控 UI 顯示(南投標 direct、長野標 package) | 若要硬性限制某通路改這欄語意 |
| 類別 | 內容 |
|---|---|
| 新表 | properties · room_types · room_night_inventory(drizzle) |
| 新 permission | lodging.read / lodging.manage |
| 新 repo | packages/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 由 配套 引用旅宿主檔產生(不重造) |
| 項目 | 預設 | 待釐清 |
|---|---|---|
| 露營賣整棟木屋 vs 單床 | inventory_unit 兩種皆支援;南投實際是哪種待填 | 露營房型清單與單位 |
| 日曆價輸入 UI | 批次區間覆寫(平日/假日/旺季) | 是否要「規則」(每週末 +X)而非逐日 |
| 入住人名單 | 沿用 travelers(綁 lodging order) | 直訂是否一定要逐人資料,或只記訂房人 |
| 封房粒度 | per 房型 per 晚 | 是否要 per 物業整體封 |
room_night_inventory 列爆量 | lazy upsert(只碰到才建) | 長期清理過去日期列的策略 |
| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-30 | 初版草案:旅宿主檔 + 房型 + 每晚共用庫存 + 日曆價 + race-safe 扣庫存 | — |
| 2 | 2026-05-31 | 脊椎落地:property / room type / nightly inventory、race-safe reserve/release/getAvailability、後台房況日曆與訂房模組 gate。訂房下單 flow 見 lodging-direct-booking。 | (本批) |