一句話:自家旅宿(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 已刪,引用併回此處;精確落地欄位以
packages/core/src/schema/business.ts的properties現行欄為準)。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 已刪),精確欄位以 code 為準。
room_night_inventory(每晚庫存 + 日曆價 —— 單一真相)這張表是脊椎。每房型每晚一列,記容量覆寫 / 已訂 / 當晚價。兩個通路都扣
booked_count。容量真相 =COALESCE(capacity_override, room_types.units_total)——capacity_overrideNULL 即跟隨房型units_total(改units_total自動生效於所有未覆寫的晚),只有維修/封房等 per-晚下調才落覆寫值,消滅「lazy 快照鎖舊容量」的漂移。
export const roomNightInventory = pgTable(
'room_night_inventory',
{
roomTypeId: text('room_type_id')
.notNull()
.references(() => roomTypes.id, { onDelete: 'cascade' }),
/** 該晚(入住日;區間 [checkIn, checkOut) 的每一天一列)。 */
night: date('night').notNull(),
/** 當晚容量覆寫。NULL = 跟隨 room_type.units_total(有效容量 = COALESCE(capacity_override, units_total))。 */
capacityOverride: integer('capacity_override'),
/** 當晚已訂(兩通路共用;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_nonneg', sql`${t.bookedCount} >= 0`),
index('rni_night_idx').on(t.night),
],
);
CHECK 只擋負值:容量上限是跨表述詞(
COALESCE(capacity_override, rt.units_total)),單表 CHECK 表達不了,故 DB CHECK 退為booked_count >= 0,超賣防線在 CAS 條件式 UPDATE(§4.1)+room_night_holds核對(下)。為何 per-night 一列:race-safety 要的是「每一晚一個可被 conditional UPDATE 的計數器」(同
departures)。列lazy upsert:第一次碰到某房型某晚時建列(capacity_override一律 NULL,跟隨units_total;見 §4 預留邏輯)。
room_night_holds(佔用 ledger,讓 booked_count 可核對)booked_count 不再是無來源的裸計數器:每次 reserveNightsInTx 成功扣一晚,同 tx insert 一列 hold(room_type_id / night / source_type ∈ {'order_line','departure_lodging'} / source_id / units);釋回時標 released_at(不刪列)。不變量:booked_count == SUM(active holds.units)(active = released_at IS NULL),由唯讀稽核 auditNightlyCounters(lodging/inventory-audit.ts)逐晚比對回報 drift(sweepNightlyCounterAudit 掃全租戶),不自動修正——漏放/漏還從「無法偵測」變成「可核對派生」。
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_override = NULL
UPDATE room_night_inventory rni
SET booked_count = booked_count + units, updated_at = now()
FROM room_types rt
WHERE rni.room_type_id = :rt AND rni.night = :n AND rt.id = :rt
AND rni.status = 'open'
AND rni.booked_count + :units
<= COALESCE(rni.capacity_override, rt.units_total) # ← 條件式(容量真相 = COALESCE)
if rowsAffected != 1: ROLLBACK → 回 { ok:false, reason:'NO_AVAILABILITY', night }
INSERT room_night_holds(room_type_id, night, source_type, source_id, units) # 同 tx 佔用 ledger
COMMIT → { ok:true }
CHECK (booked_count >= 0) + holds 核對兜底(§3.3.1)。booked_count - units(GREATEST(0, …) 夾底),同 tx 把對應 source 的 active holds 標 released_at。updateRoomType 全有全無:改 units_total 前先預檢未來晚(capacity_override IS NULL 且 booked_count > 新 units_total 者),任一晚衝突即整 tx rollback 回 CAPACITY_BELOW_BOOKED(帶衝突晚/已訂/新容量),不部分生效;有覆寫值的晚不跟隨、不預檢。成功後無需回寫任何 per-night 列(COALESCE 讀時生效)。// 區間可訂量(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?; capacityOverride?; status? })
// 批次覆寫一段區間的價 / 容量覆寫 / 封房(lazy upsert 列;capacityOverride 清為 NULL = 回復跟隨 units_total)
// 庫存原語(給兩個通路呼叫,不直接對外)
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(只碰到才建) | 長期清理過去日期列的策略 |