線稿索引所有 spec最後更新 2026-07-01
specs/modules.md

模組化

一句話:把非通用的功能(登山 / 海外 / 訂房 / 抽籤)拆成各自獨立的可選模組、疊在通用核心之上;模組是否開通 = 平台方(control plane)的商業決策(對應 SaaS 方案,租戶不可自行開啟),已開通的前提下登山 / 海外再由租戶逐行程決定要不要套、抽籤由梯次 allocation_mode 決定哪梯走(同社混賣登山團 + 一般團)。

畫面(UI = wireframe):settings(方案模組唯讀面板,現階段唯一前端觸點)

  • 關聯:幾乎所有功能 spec(核心/模組歸類見 §2)· tenant-architecture(control_db + proxy)· trip-pricing(行程層欄位同 PR)· settings(唯讀顯示方案模組)
  • 動機來源:2026-05-30 user —「旅行社不一定只賣登山,要適度模組化才能拆開販售」。兩個拆售情境都要:① 平台賣 ERP 給其他旅行社(SaaS,平台決定開哪些模組)② 荒野自己型錄混賣一般旅遊(逐行程套)。

1. 五個可選模組

模組涵蓋功能性質
登山模組登山裝備租借 / 庫存彙總、登山分級審核器、入山證/入園證/山屋/登山險 deadline + 許可證文件、人頭使用次數限制(國家公園 permit 配額,以身分證計次)、登山活動同意書行程的能力層(疊在 tour 上,逐行程套)
海外模組護照效期提醒、護照 OCR、(未來:簽證效期、多幣別地接)行程的能力層(逐行程套)
訂房模組旅宿主檔 + 房型 + 每晚共用庫存 + 日曆價、散客直訂、行程配套住宿獨立產品線(自家旅宿 YAMADO)
抽籤模組入園抽籤介接(推應募名單 / 收中籤結果)、玉山搶候補 / 候補名單遞補、未中籤自動轉備案行程子系統(國家公園抽籤制;per-departure allocation_mode 決定哪梯走抽籤)
進階會計模組多帳戶對帳、帳戶結算 / 月結互抵、內外帳淨利 / 損益分析、公司損益表、成本側多幣別精算、出款批次 / 匯款檔、退款執行子系統(金流對帳結算層;掛帳台帳留核心,見 accounting-module

登山模組是獨立可拆模組(2026-06-23 user 裁決):抽掉登山模組,基礎功能(下單 / 金流 / 出團準備核心 / 對帳 / 分房 / 住宿)照常可用。登山模組專屬 = 裝備租借 + 抽籤(lottery) + 入山/入園/山屋/登山險 deadline + 登山分級審核(climbing-screening) + 人頭使用次數限制(permit 配額)。出團準備的核心骨幹(對款 / 派車 / 餐食 / 投保 / 換領隊)不屬登山模組 —— 那是所有出團團都要的通用流程(見 departure-prep 的核心 vs 登山子集分界、§3.3)。

登山 / 海外是疊在「行程」上的能力層(同一條 tour 加不加);訂房 / 抽籤子系統(訂房賣住宿、抽籤介接國家公園抽籤)—— 四者都由平台授權開關,但訂房 / 抽籤的 gate 只到「整個子系統開不開」(無逐行程旗標,granularity 各是房型存量 / 梯次 allocation_mode,見 §3.2)。

登山 vs 海外為何拆兩個而非一包:兩者各自獨立——一條行程可能只需其一:

行程登山海外
墾丁三日(國內一般)
玉山百岳(國內登山)
京都自由行(海外一般)
尼泊爾 EBC(海外登山)

綁成單一「進階」開關 → 京都自由行會多出「入山證」欄、玉山團會多出「護照」欄。故必須兩個獨立開關。

2. 核心 vs 模組(功能歸類)

改任何 spec 前,先確認它屬核心還是模組:模組功能必須受 §3 的 gate,不可預設啟用。

🟦 核心(任何旅行社都有,與登山/海外無關)

order-assignment · trip-pricing(直客/同業價·訂金/尾款·包團·費用含不含)· control-finance(payables 請款核簽)· dashboard / leaderboard · member-ownership(CRM/同業)· group-code · settings · content-authoring · room-assignment(分房 = 任何含住宿團都需要,含 travelers.gender 硬約束)· traveler-validation(身分證/緊急聯絡人/金額 = 通用 PII)· consent(同意書框架本身通用)· departure-prep 核心骨幹(對款唯讀派生 / 派車 / 餐食 / 投保旅平·旅責 / 換領隊 = 所有出團團都要,不 gate;見 departure-prep

🟩 登山模組(受登山 gate)

climbing-screening · equipment-inventory(裝備租借 + 庫存彙總)· departure-prep 的登山專屬子集(入山證 / 入園證 / 山屋床位核准證 deadline + 許可證文件 + 登山險 deadline;非整個 departure-prep)· 人頭使用次數限制(國家公園 permit 配額,奇萊南華 / 嘉明湖 / 北大武,以身分證計次;enforcement 隨 climbing-screening / lottery epic)

🟧 海外模組(受海外 gate)

document-expiry(護照效期) · passport-ocr

🟪 訂房模組(受訂房 gate)

lodging-inventory(旅宿主檔 + 房型 + 每晚共用庫存 + 日曆價)· lodging-direct-booking(散客直訂 + orders 重塑)· lodging-tour-package(行程配套住宿)

🟥 抽籤模組(受抽籤 gate)

lottery-integration(入園抽籤介接:推應募名單 / 收中籤結果 / 觸發下游)· backup-itinerary-failover(未中籤自動轉備案行程)

🟫 進階會計模組(受 module_accounting gate)

payment-accounts-reconciliation · account-settlement · currency-exchange · operating-expenses · internal-external-ledger淨利 / 分析面 · control-finance出款批次 / 匯款檔(§5.5 / §5.6)。掛帳台帳(訂單應收 + departure_costs 成本掛帳含代墊/憑證兩金額 + payables 請款核簽 + 團控毛估 + Excel 匯出)留核心——拆分點與歸屬 SSOT 見 accounting-moduleinternal-external-ledger / control-finance 橫跨邊界(登記核心 / 分析結算模組)。

3. 兩層 gate 機制

某模組功能對一條行程生效平台層授權開(control plane) 行程層旗標開(租戶設)。

3.1 平台層授權(control plane —— 平台方決定,租戶不可改)

模組是否開通給某租戶 = 平台的商業決策(對應訂閱方案),故放 control_db(平台目錄),由 provisioning CLI 管理。不放 tenant_settings —— 那是租戶自己可編輯的設定,放那裡等於人人自助開通,破壞拆售。

// packages/core/src/schema/control.ts — control_db `tenant` 表 append(平台層)
/** 平台授權:此租戶是否開通各模組(對應訂閱方案)。租戶端唯讀。 */
moduleClimbing: boolean("module_climbing").notNull().default(false),
moduleOverseas: boolean("module_overseas").notNull().default(false),
moduleLodging: boolean("module_lodging").notNull().default(false),
moduleLottery: boolean("module_lottery").notNull().default(false),
// 抽籤模組(13-task SDD 已落地:control_db 欄 + proxy active list + TenantModules.lottery + lotteryActive() + requireLotteryModule)。
// 進階會計模組(F0 已落地此欄 + helper accountingActive / requireAccountingModule;
// F6 掛帳台帳 .xlsx 匯出核心 + 各進階會計頁掛閘)。荒野 = true(DEV_TENANT_MODULES 含 accounting)。
moduleAccounting: boolean("module_accounting").notNull().default(false),
  • 預設 false:新租戶 = 乾淨的通用旅行社 ERP;平台依方案開通。
  • 管理 = provisioning CLIbun run tenant set-modules --slug <s> --enable climbing[,overseas](與既有 tenant create / suspend / set-domain 同層,直連 control_db)。
  • /admin/settings 不可編輯:最多唯讀顯示「你的方案包含:登山模組」(settings)。要開/關走平台 CLI。
  • 未來可由 control_db.subscription 方案等級自動帶(方案→模組 entitlement 映射);本 spec 先用 tenant 上的旗標。

租戶 app 怎麼讀到授權(沿用既有 proxy 解析路徑):apps/web/src/proxy.ts 本來就用 host 反查 control_db.tenant_domaindb_name + 檢查 tenant.status一併取模組旗標,經 NextResponse.next({ request: { headers }}) 寫 anti-tamper header x-tenant-modules(同 x-tenant-db 慣例:一律覆寫 client 值)。dev 用 DEV_TENANT_MODULES env fallback。

3.2 行程層旗標(租戶自己設 —— 已授權前提下逐行程套)

trips 新增兩個 boolean(drizzle schema,與 trip-pricing / climbing-screening 的 trips 欄位同一 PR):

// packages/core/src/schema/business.ts — trips append
/** 此行程是否為登山團(顯示審核分數/入山/裝備等登山欄位與流程)。 */
isClimbing: boolean("is_climbing").notNull().default(false),
/** 此行程是否出國(顯示護照效期/OCR)。 */
isOverseas: boolean("is_overseas").notNull().default(false),
  • 行程旗標只能在平台已授權該模組時於 trip-edit 設為 true(UI:未授權 → checkbox 不出現、視同 false)。
  • 訂房模組無逐行程旗標:它是獨立產品線(賣住宿不是行程),gate 只到「整個訂房子系統開不開」—— module_lodging on → 出旅宿 nav / 直訂 / 配套;off → 整組不存在。物業 / 房型「存不存在」就是它的細粒度。

3.3 生效判定(單一 helper)

// packages/core/src/modules.ts
export interface TenantModules {
  climbing: boolean;
  overseas: boolean;
  lodging: boolean;
  lottery: boolean;
}

/** 平台授權的模組(來自 control_db,經 proxy 注入的 x-tenant-modules header 讀取)。
 *  async + cache(headers()),同 getCurrentTenantDb 慣例(不傳 ctx);header 缺失 → 全 false(fail-closed)。 */
export const getTenantModules: () => Promise<TenantModules>;

/** 此行程是否套登山功能(審核器/入山/裝備)= 平台授權 且 行程是登山團。
 *  caller 先 `const modules = await getTenantModules()` 再傳入(helper 不自呼叫,省去每次 await)。 */
export function climbingActiveFor(trip: { isClimbing: boolean }, modules: TenantModules): boolean {
  return modules.climbing && trip.isClimbing;
}
/** 此行程是否套海外功能(護照)= 平台授權 且 行程出國。 */
export function overseasActiveFor(trip: { isOverseas: boolean }, modules: TenantModules): boolean {
  return modules.overseas && trip.isOverseas;
}
/** 訂房子系統是否啟用(無逐行程旗標,純看平台授權)。 */
export function lodgingActive(modules: TenantModules): boolean {
  return modules.lodging;
}
/** lodging WRITE server action 開頭的模組閘:off 時即使持 lodging.manage 也擋下。 */
export async function requireLodgingModule(): Promise<void>; // throws 'module_disabled' if off

抽籤模組(已落地,13-task SDD)TenantModules 現含 climbing / overseas / lodging / lotterylotteryActive(modules) = return modules.lotteryrequireLotteryModuleOr404 在所有手動裁決 write action 入口 fail-closed(不只藏 nav)。module_lottery 欄在 control schema + proxy active list 均已加入。

進階會計模組(已落地)TenantModulesaccountingaccountingActive(modules) = return modules.accountingrequireAccountingModule()(off 時 throw 'module_disabled',比照 requireLodgingModule)+ 頁閘 requireAccountingModuleOr404()(off → 404);control schema 已加 module_accounting、proxy active 推導已納入 accounting。子系統型 gate(無逐行程旗標,比照訂房)。拆分點與涵蓋範圍見 accounting-module

各模組 spec 的觸發點都先過這個閘:

  • climbing-screeningrunOrderScreening 先判 climbingActiveFor → false 則 verdict not_required、不寫審核(取代原「grade NULL → not_required」隱性判斷)。
  • equipment-inventory:裝備租借 / 需求調查 / 跨梯次彙總 UI 與查詢只對 climbingActiveFor 的梯次跑。
  • departure-prep只有登山專屬子集過閘 —— 入山/入園/山屋/登山險 deadline + 許可證文件區、人頭使用次數限制只對 climbingActiveFor 的梯次顯示;核心骨幹(對款 / 派車 / 餐食 / 投保 / 換領隊)不過閘,任何梯次都有
  • document-expiry / passport-ocr:只對 overseasActiveFor 的行程顯示護照欄與效期警示。
  • lottery-integration / backup-itinerary-failoverlotteryActive() 為 false 時,梯次不出現 allocation_mode=lottery 選項、抽籤介接 / 轉備案功能整組不存在。

4. 既有設計已半支援

  • climbing-screeningnull-gated(行程沒評鑑分數 → not_required)。本 spec 升級為明確旗標 is_climbing,UI 可據此整組顯隱。
  • traveler-validation 性別檢查已是條件式
  • 多租戶 = control_db + DB per tenant + proxy header 注入,天生能讓平台層授權下放到租戶 app(同 x-tenant-db 機制)。

→ 模組化是平台層加授權旗標 + proxy 注入 + 行程層標旗標 + UI 顯隱,不是打掉重練。

5. UI 顯隱規則

位置登山未生效(平台未授權 或 行程非登山)海外未生效
trip-edit隱藏「審核評鑑分數 / 高山經驗 / 分級標籤」欄群;平台未授權時連「登山團」勾選都不出現隱藏護照效期欄;平台未授權時「海外團」勾選不出現
orders-new / checkout不顯示登山經歷自填 + 資格審核 banner
orders-review隱藏「資格審核」分頁
order-detail / control不顯示資格審核狀態 / 入山 deadline不顯示護照效期色標
團控梯次動作 bar不顯示「裝備成本」「入山文件」按鈕
dashboard不顯示入山 deadline widget不顯示證件效期 widget
settings唯讀顯示「方案包含的模組」(不可自改)同左
  • nav(登山/海外):目前無登山/海外專屬 nav 項(審核器在訂單派發頁、入山在團控、護照在團控),故 nav 不需大改;顯隱主要在頁內區塊。未來若加專屬 nav(如「裝備調度」),其 _shell.js nav 項需依平台授權顯隱。
  • 訂房模組(整組顯隱)module_lodging on → 出「旅宿管理」nav(_shell.js)+ trip-edit「配套住宿」區 + 直訂建單;off → 整組不存在。物業/房型存不存在是其細粒度(無逐行程旗標)。

6. 行程類型 UI(trip-edit)

trip-edit 基本資料頂端加「行程類型」區(checkbox 僅在平台已授權對應模組時出現):

行程類型(決定顯示哪些欄位與流程)
☑ 登山團   ☐ 海外團
  • 勾「登山團」→ 顯示審核評鑑分數 / 高山經驗 / 分級標籤欄群(climbing-screening)。
  • 勾「海外團」→ 顯示護照效期相關欄(document-expiry)。
  • 兩個都不勾 = 一般旅遊團,只剩核心欄位(名稱/價格/訂金/含不含/分房)。
  • 平台未授權某模組 → 對應 checkbox 不出現(該租戶根本不做這類團)。

7. 影響範圍

類別內容
schema(control plane)control_db.tenantmodule_climbing / module_overseas / module_lodging(已落地);module_lottery已落地,13-task SDD)(drizzle control schema)
schema(租戶)tripsis_climbing / is_overseas(drizzle business,與 trip-pricing/climbing 同 PR)
proxyapps/web/src/proxy.ts 解析租戶時一併取模組旗標 → 注入 x-tenant-modules anti-tamper header
provisioning CLI新增 tenant set-modules(直連 control_db;與 create/suspend/set-domain 同層)
新 helperpackages/core/src/modules.tsgetTenantModules / climbingActiveFor / overseasActiveFor / lodgingActive / requireLodgingModule / lotteryActive / requireLotteryModuleOr404已落地,13-task SDD))
改既有 spec 觸發點climbing-screening / equipment-inventory / departure-prep 的登山子集(過登山閘;departure-prep 核心骨幹不過閘);document-expiry / passport-ocr(過海外閘);lottery-integration / backup-itinerary-failover(過抽籤閘)
settings唯讀顯示方案模組(不可編輯;settings
trip-edit加「行程類型」區 + 欄群顯隱(依平台授權 + 旗標)
seed / provisioning荒野租戶 control_db 列:module_climbing=truemodule_overseas=false(現階段不接海外)
既有行程 backfill既有行程預設 is_climbing=false;荒野遷移時把登山行程批次設 true(一次性 script)

8. 未決事項

項目預設待釐清
模組授權放 tenant 旗標 vs subscription 方案映射先放 control_db.tenant 旗標接訂閱方案分級時改由 plan → entitlement 自動帶
行程旗標用 boolean ×2 還是單一 trip_type enum兩個 boolean(可組合,對齊四象限)是否未來第三類(郵輪/自由行)需 enum
平台未授權時既有資料保留不刪(只隱藏);重新授權即恢復是否需「停權即封存」
海外模組是否含多幣別多幣別仍 future(control-finance海外模組上線時一起評估
settings 是否顯示方案模組唯讀狀態顯示(讓 owner 知道自己方案範圍)或完全不顯示,純靠功能有無
x-tenant-modules header 格式逗號分隔(如 climbing,overseas)或 JSON與既有 header 慣例對齊

9. 連結

Changelog

版本日期變更Commit
92026-06-30落地後更新(13-task SDD)module_lottery 標為已落地(control schema + proxy active list + TenantModules.lottery + lotteryActive() + requireLotteryModuleOr404 write action fail-closed);§3.1 schema 表 + §3.3 helper 清單 + TenantModules 介面 + 程式碼塊同步更新。
102026-07-01Code-is-truth 收斂:§3.3「進階會計模組」由「待實作 / 現行 code 尚無」改為「已落地」(module_accounting 欄 + accountingActive + requireAccountingModule + requireAccountingModuleOr404),與 §3.1 及 code 一致。
12026-05-30初版:登山 / 海外兩獨立模組 + 兩層 gate + 核心模組歸類
22026-05-30修正分層:模組授權移到 control plane(平台方 / CLI),非 tenant_settings 自助;經 proxy x-tenant-modules header 下放;settings 改唯讀
32026-05-30訂房模組(第三個,獨立產品線,無逐行程旗標)+ control_db.tenant.module_lodging + lodgingActive()
42026-05-31實作 gate 基礎建設(control 旗標 + trips 旗標 + proxy header + modules.ts helper + CLI set-modules);UI 顯隱仍待各功能落地(plan 2026-05-31-modules-gate-infra)
52026-06-20抽籤模組(第四個,子系統 gate):入園抽籤介接 + 未中籤轉備案;control_db.tenant.module_lottery + lotteryActive();per-departure allocation_mode 為 granularity。
62026-06-20對齊現行 code:§3.3 helper 簽名改 async getTenantModules + active-helper 收 modules 參數(caller 注入)、補 requireLodgingModule;明確標注抽籤模組的 module_lottery / lotteryActive待實作(現行 code 僅 climbing/overseas/lodging);proxy 路徑補 apps/web/ 前綴。
72026-06-23登山模組 scope 收斂(2026-06-23 user 裁決「登山是獨立可拆模組」):登山專屬 = 裝備租借 + 抽籤 + 入山/入園/山屋/登山險 deadline + 登山分級 + 人頭使用次數限制;departure-prep 核心骨幹(對款/派車/餐食/投保/換領隊)移出登山 gate 歸核心(§1 表 + 收斂註 / §2 核心⇄登山重歸類 / §3.3 departure-prep 改「只有登山子集過閘」/ §7 觸發點)。
82026-06-28進階會計模組(第 5 個,子系統 gate;經理 + user 帳務拆分點裁決):金流對帳結算損益(多帳戶對帳 / 帳戶結算月結互抵 / 內外帳淨利分析 / 公司損益表 / 成本多幣別精算 / 出款批次匯款檔 / 退款執行)收進 module_accounting掛帳台帳(訂單應收 + 成本掛帳含代墊/憑證兩金額 + payables 核簽 + 團控毛估 + Excel 匯出)留核心。§1 表第 5 列、§2 加「🟫 進階會計模組」分類、§3.1 schema module_accounting、§3.3 accountingActive / requireAccountingModule(待實作)。拆分點 SSOT = 新 accounting-moduleinternal-external-ledger / control-finance 橫跨邊界(登記核心 / 分析結算模組)。