線稿索引所有 spec最後更新 2026-06-20
specs/lodging-tour-package.md

行程配套住宿

一句話:行程(滑雪團)設計時固定指定自家旅宿的房型與入住晚數,出團對那幾晚整塊預留團體床位(扣 旅宿庫存 同一份),旅館細節資料顯示在前台行程頁;受 模組化 的「訂房」+「海外」開關 gate。

  • 關聯旅宿庫存(共用庫存 / 整塊預留)· 團控分房departure_rooms 從 block 產生)· 行程定價(房費 / 升等)· 模組化
  • 對應:user 2026-05-30 —「長野旅館搭滑雪行程配套賣,行程設計要可以選旅館,旅館資訊也要有對應細節資料」。

1. 問題與動機

長野雪場旅館不單獨賣,而是綁在滑雪行程裡:報滑雪團就含這間旅館的住宿。需要:

  1. 行程 / 梯次能指定一間自家旅宿 + 房型 + 住哪幾晚(OP 設計時固定,客人不選)。
  2. 旅館細節資料(設施、房型、照片)顯示在前台行程頁。
  3. 出團時對旅宿庫存整塊預留團體床位 —— 且跟(未來可能的)散客訂房共用同一份庫存,不超賣。
  4. 分房(room-assignment)接在預留之後:把團員分到預留到的房 / 床。

現況 departure_rooms 是 OP 手 key 每梯次房間(房名 / 床數 / 房費)—— 與旅宿主檔脫節、重複輸入、也不扣任何共用庫存。

2. 範圍

2.1 在範圍內

  • 梯次配套設定 departure_lodging:梯次 → 指定 property + room_type + 入住區間 + 團體 block 大小。
  • 整塊預留:設定 / 出團確認時對 旅宿庫存 reserveNights(block units);取消整梯 → 釋回。
  • 前台旅館細節:行程頁顯示所指定旅宿的細節(讀 properties / room_types)。
  • departure_rooms 對齊:block 預留到的房 / 床產生 departure_rooms,與外部山屋 / 民宿的手 key 房並存(分房在其上運作)。

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

  • 客人報名時選旅館 / 升等:現況 OP 固定(user 已定);客人選房型升等留 future(接 trip-pricing room upgrade)。
  • 散客 + 團體混用同物業的進階管理:現況長野只配套,留 future(inventory §9)。
  • 多旅宿行程(一個行程住多間不同旅宿不同晚):現行支援「一梯次多筆 departure_lodging」即可涵蓋多晚多房型;跨旅宿複雜排程留 future。

3. 資料模型

3.1 departure_lodging(梯次配套住宿)

export const departureLodging = pgTable(
  'departure_lodging',
  {
    id: text('id').primaryKey(), // 'dl_<ulid>'
    departureId: text('departure_id')
      .notNull()
      .references(() => departures.id, { onDelete: 'cascade' }),
    propertyId: text('property_id')
      .notNull()
      .references(() => properties.id),
    roomTypeId: text('room_type_id')
      .notNull()
      .references(() => roomTypes.id),
    checkIn: date('check_in').notNull(),
    checkOut: date('check_out').notNull(), // 不含當晚
    /** 團體預留單元數(間 / 床)。預設依梯次容量推算,可 OP 覆寫。 */
    blockUnits: integer('block_units').notNull(),
    /** 是否已對庫存預留(避免重複扣 / 重複釋放)。 */
    reserved: boolean('reserved').notNull().default(false),
    notes: text('notes'),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    index('departure_lodging_departure_idx').on(t.departureId),
    index('departure_lodging_roomtype_idx').on(t.roomTypeId, t.checkIn),
    check('departure_lodging_dates', sql`${t.checkOut} > ${t.checkIn}`),
  ],
);

一梯次可有多筆(多晚 / 多房型 / 含多間旅宿)。

3.2 與 departure_rooms(分房)的對齊 —— 跨 spec

不重造departure_roomsroom-assignment)保留為「團員 → 床位 + 房費成本」的分房層;其資料departure_lodging 預留到的 block 產生,與手 key 房並存:

  • 預留成功後,依 block(property/room_type/間數/床數)自動生成對應 departure_roomsaccommodation_name = property.name、room_type = room_type.name、bed_count = room_type.capacity_per_unit、base_cost_twd = 0)。
  • 分房(travelers.room_id)照舊在這些列上運作。
  • 房費成本:派生房 base_cost_twd = 0(自家旅宿無對外供應商成本,與手 key 山屋 / 民宿房不同);房升補款另循 order_charge_lines(type='room_upgrade')(分房時依床位分攤),不從 room_type 帶房費成本

已落地:派生 departure_rooms 與外部山屋 / 民宿手 key 房並存(非淘汰)—— 派生房帶 departure_lodging_id 標記,releaseDepartureLodging 只刪派生房、手 key 房留存(見 Changelog v2 / v3)。

3.3 共用庫存(重申)

departure_lodging 的 block 與 直接訂房 的 lodging order lines 扣同一份 room_night_inventory.booked_countinventory §3.3)。現況長野只配套(無散客),但共用模型天生支援未來混用。

4. 流程

  1. 行程 / 梯次設計(trip-edit):勾「海外團」+ 指定配套旅宿 → 選 property / room_type / 入住區間 / block 大小。
  2. 預留:儲存(或梯次轉「銷售中」)時 reserveNights(roomTypeId, checkIn, checkOut, blockUnits);成功 → reserved=true + 生成 departure_rooms。庫存不足 → 擋下並提示(改房型 / 改旅宿 / 調 block)。
  3. 報名:客人報滑雪團(既有 tour 下單,kind='tour');住宿由 block 涵蓋,報名不再各自扣旅宿庫存(block 已預留整段)。
  4. 分房:出團前 OP 在 room-assignment 把團員分到 block 生成的房 / 床。
  5. 前台:行程頁讀 departure_lodgingproperties/room_types 顯示旅館細節(設施 / 房型 / 照片)。
  6. 取消整梯:釋回 block(releaseNights)+ 清 reserved

5. Server / Repo

packages/core/src/lodging/package-repo.ts

setDepartureLodgingAction(input: {
  departureId; propertyId; roomTypeId; checkIn; checkOut; blockUnits;
}): Promise<{ ok: true } | { ok: false; reason: 'NO_AVAILABILITY' | 'CLOSED_NIGHT'; night?: string }>
//  reserveNights(block) → upsert departure_lodging(reserved=true) → 生成/對齊 departure_rooms → audit

releaseDepartureLodgingAction(departureLodgingId): Promise<{ ok: true }>
//  releaseNights + reserved=false + 處理對應 departure_rooms

getDepartureLodging(db, departureId)   // 團控 / 行程頁顯示

6. 角色權限

動作permission角色
設定 / 改梯次配套旅宿lodging.manage + trip.updateop、admin
前台看旅館細節公開(行程頁)

訂房模組(+ 海外旅宿時海外模組)授權前提。

7. UI(接點)

  • trip-edit(梯次設定):加「配套住宿」區 —— 選旅宿 / 房型 / 入住區間 / block,顯示即時可訂量。詳見 wireframe delta(trip-edit.html)。
  • 前台 /tour/[slug]:加「住宿」區塊讀 departure_lodging → 旅館細節(沿用 MDX / 結構化資料)。
  • 團控:梯次顯示配套旅宿 + 分房沿用 room-assignment。

8. 測試

  • setDepartureLodgingAction:足量 → block 預留各晚 +blockUnits、生成 departure_rooms、reserved=true;不足 → 擋、不留半成品。
  • 共用庫存:同 room_type 同晚,先 block 預留,再散客直訂 → 散客可訂量 = 容量 − block(驗證共用扣減)。
  • releaseDepartureLodgingAction:釋回、reserved=false。
  • 重複預留:reserved=true 再呼叫不重複扣(idempotent)。
  • 取消整梯 → block 釋回。

9. 影響範圍

類別內容
新表departure_lodging(drizzle)
改既有departure_rooms 新增 block 派生來源(departure_lodging_id 標記;與手 key 房並存)
新 repopackages/core/src/lodging/package-repo.ts
UItrip-edit 配套住宿區 · 前台行程頁住宿區塊 · 團控顯示
模組訂房模組(+ 海外旅宿時海外模組)

10. 未決事項

項目預設待釐清
block 大小推算預設 = 梯次容量 / room_type.capacity_per_unit;OP 可覆寫是否要精準到實際報名數動態調
客人選房型升等不做(OP 固定)未來接 trip-pricing room upgrade
多旅宿 / 跨夜不同房型一梯次多筆 departure_lodging 涵蓋複雜排程 future
房費成本來源派生房 base_cost_twd=0(自家無供應商成本);房升走 order_charge_lines對外供應旅宿(非自家)配套時的成本記法

Changelog

版本日期變更Commit
12026-05-30初版草案:行程配套住宿(梯次固定指定旅宿 + 團體整塊預留 + departure_rooms 派生)
22026-05-31backend 已落地departure_lodging 表 + departure_rooms.departure_lodging_id 並存標記欄(partial unique index WHERE reserved=true 擋並發重複 block)+ packages/core/src/lodging/package-repo.tssetDepartureLodging 單一 tx:共用庫存 reserveNightsInTx 整塊預留 + 派生 departure_rooms(bedCount=capacity_per_unit、baseCostTwd=0 自家無供應商成本)+ audit;releaseDepartureLodging 釋回 + 只刪派生房(手 key 房留存)+ idempotent;getDepartureLodging)。決議:派生與外部山屋/民宿手 key 並存(非淘汰)。模組 gate / trip-edit·前台·團控 UI 延後(UI 切片)。plan docs/superpowers/plans/2026-05-31-lodging-tour-package-backend.md(本批)
32026-05-31UI 已落地getTripDepartureLodging(撈整行程跨梯次 block)+ getTripLodgingForDisplay(前台顯示 distinct reserved 旅宿)+ trip-edit「梯次」tab 內「配套住宿」設定區(per-departure set/release,setDepartureLodgingAction/releaseDepartureLodgingAction gate trip.read,旅宿選單 usage_hint ∈ {package,both},gated on lodging 模組)+ 前台 /tour/[slug] 住宿配套 Section(純資訊:旅宿名/地點/晚數/簡述,無價無下單鈕)+ 團控分房 room-card 派生房「配套」badge(RoomRow.departureLodgingId 區分手 key 房)。plan docs/superpowers/plans/2026-05-31-lodging-tour-package-ui.md、design docs/superpowers/specs/2026-05-31-lodging-tour-package-ui-design.md(本批)
42026-06-20修正 §3.2 房費成本描述:派生房 base_cost_twd = 0(自家旅宿無供應商成本,對齊 code package-repo.ts:101 與 Changelog v2),移除「base_cost_twd 從 room_type 帶」「block 預留成本餵 base_cost」的相反敘述;§10 未決同步收斂。