一句話:把非通用的功能(登山 / 海外 / 訂房 / 抽籤)拆成各自獨立的可選模組、疊在通用核心之上;模組是否開通 = 平台方(control plane)的商業決策(對應 SaaS 方案,租戶不可自行開啟),已開通的前提下登山 / 海外再由租戶逐行程決定要不要套、抽籤由梯次
allocation_mode決定哪梯走(同社混賣登山團 + 一般團)。畫面(UI = wireframe):
settings(方案模組唯讀面板,現階段唯一前端觸點)
| 模組 | 涵蓋功能 | 性質 |
|---|---|---|
| 登山模組 | 登山裝備租借 / 庫存彙總、登山分級審核器、入山證/入園證/山屋/登山險 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(海外登山) | ✓ | ✓ |
綁成單一「進階」開關 → 京都自由行會多出「入山證」欄、玉山團會多出「護照」欄。故必須兩個獨立開關。
改任何 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)
climbing-screening · equipment-inventory(裝備租借 + 庫存彙總)· departure-prep 的登山專屬子集(入山證 / 入園證 / 山屋床位核准證 deadline + 許可證文件 + 登山險 deadline;非整個 departure-prep)· 人頭使用次數限制(國家公園 permit 配額,奇萊南華 / 嘉明湖 / 北大武,以身分證計次;enforcement 隨 climbing-screening / lottery epic)
document-expiry(護照效期) · passport-ocr
lodging-inventory(旅宿主檔 + 房型 + 每晚共用庫存 + 日曆價)· lodging-direct-booking(散客直訂 + orders 重塑)· lodging-tour-package(行程配套住宿)
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-module。internal-external-ledger / control-finance 橫跨邊界(登記核心 / 分析結算模組)。
某模組功能對一條行程生效 ⟺ 平台層授權開(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;平台依方案開通。bun 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_domain 取 db_name + 檢查 tenant.status;一併取模組旗標,經 NextResponse.next({ request: { headers }}) 寫 anti-tamper header x-tenant-modules(同 x-tenant-db 慣例:一律覆寫 client 值)。dev 用 DEV_TENANT_MODULES env fallback。
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),
module_lodging on → 出旅宿 nav / 直訂 / 配套;off → 整組不存在。物業 / 房型「存不存在」就是它的細粒度。// 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 / lottery。lotteryActive(modules)=return modules.lottery;requireLotteryModuleOr404在所有手動裁決 write action 入口 fail-closed(不只藏 nav)。module_lottery欄在 control schema + proxy active list 均已加入。進階會計模組(已落地):
TenantModules含accounting;accountingActive(modules)=return modules.accounting;requireAccountingModule()(off 時 throw'module_disabled',比照requireLodgingModule)+ 頁閘requireAccountingModuleOr404()(off → 404);control schema 已加module_accounting、proxy active 推導已納入accounting。子系統型 gate(無逐行程旗標,比照訂房)。拆分點與涵蓋範圍見 accounting-module。
各模組 spec 的觸發點都先過這個閘:
climbing-screening:runOrderScreening 先判 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-failover:lotteryActive() 為 false 時,梯次不出現 allocation_mode=lottery 選項、抽籤介接 / 轉備案功能整組不存在。climbing-screening 已 null-gated(行程沒評鑑分數 → not_required)。本 spec 升級為明確旗標 is_climbing,UI 可據此整組顯隱。traveler-validation 性別檢查已是條件式。x-tenant-db 機制)。→ 模組化是平台層加授權旗標 + proxy 注入 + 行程層標旗標 + UI 顯隱,不是打掉重練。
| 位置 | 登山未生效(平台未授權 或 行程非登山) | 海外未生效 |
|---|---|---|
| trip-edit | 隱藏「審核評鑑分數 / 高山經驗 / 分級標籤」欄群;平台未授權時連「登山團」勾選都不出現 | 隱藏護照效期欄;平台未授權時「海外團」勾選不出現 |
| orders-new / checkout | 不顯示登山經歷自填 + 資格審核 banner | — |
| orders-review | 隱藏「資格審核」分頁 | — |
| order-detail / control | 不顯示資格審核狀態 / 入山 deadline | 不顯示護照效期色標 |
| 團控梯次動作 bar | 不顯示「裝備成本」「入山文件」按鈕 | — |
| dashboard | 不顯示入山 deadline widget | 不顯示證件效期 widget |
| settings | 唯讀顯示「方案包含的模組」(不可自改) | 同左 |
_shell.js nav 項需依平台授權顯隱。module_lodging on → 出「旅宿管理」nav(_shell.js)+ trip-edit「配套住宿」區 + 直訂建單;off → 整組不存在。物業/房型存不存在是其細粒度(無逐行程旗標)。trip-edit 基本資料頂端加「行程類型」區(checkbox 僅在平台已授權對應模組時出現):
行程類型(決定顯示哪些欄位與流程)
☑ 登山團 ☐ 海外團
| 類別 | 內容 |
|---|---|
| schema(control plane) | control_db.tenant:module_climbing / module_overseas / module_lodging(已落地);module_lottery(已落地,13-task SDD)(drizzle control schema) |
| schema(租戶) | trips:is_climbing / is_overseas(drizzle business,與 trip-pricing/climbing 同 PR) |
| proxy | apps/web/src/proxy.ts 解析租戶時一併取模組旗標 → 注入 x-tenant-modules anti-tamper header |
| provisioning CLI | 新增 tenant set-modules(直連 control_db;與 create/suspend/set-domain 同層) |
| 新 helper | packages/core/src/modules.ts(getTenantModules / 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=true、module_overseas=false(現階段不接海外) |
| 既有行程 backfill | 既有行程預設 is_climbing=false;荒野遷移時把登山行程批次設 true(一次性 script) |
| 項目 | 預設 | 待釐清 |
|---|---|---|
模組授權放 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 慣例對齊 |
| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 9 | 2026-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 介面 + 程式碼塊同步更新。 | — |
| 10 | 2026-07-01 | Code-is-truth 收斂:§3.3「進階會計模組」由「待實作 / 現行 code 尚無」改為「已落地」(module_accounting 欄 + accountingActive + requireAccountingModule + requireAccountingModuleOr404),與 §3.1 及 code 一致。 | — |
| 1 | 2026-05-30 | 初版:登山 / 海外兩獨立模組 + 兩層 gate + 核心模組歸類 | — |
| 2 | 2026-05-30 | 修正分層:模組授權移到 control plane(平台方 / CLI),非 tenant_settings 自助;經 proxy x-tenant-modules header 下放;settings 改唯讀 | — |
| 3 | 2026-05-30 | 加訂房模組(第三個,獨立產品線,無逐行程旗標)+ control_db.tenant.module_lodging + lodgingActive() | — |
| 4 | 2026-05-31 | 實作 gate 基礎建設(control 旗標 + trips 旗標 + proxy header + modules.ts helper + CLI set-modules);UI 顯隱仍待各功能落地 | (plan 2026-05-31-modules-gate-infra) |
| 5 | 2026-06-20 | 加抽籤模組(第四個,子系統 gate):入園抽籤介接 + 未中籤轉備案;control_db.tenant.module_lottery + lotteryActive();per-departure allocation_mode 為 granularity。 | — |
| 6 | 2026-06-20 | 對齊現行 code:§3.3 helper 簽名改 async getTenantModules + active-helper 收 modules 參數(caller 注入)、補 requireLodgingModule;明確標注抽籤模組的 module_lottery / lotteryActive 為待實作(現行 code 僅 climbing/overseas/lodging);proxy 路徑補 apps/web/ 前綴。 | — |
| 7 | 2026-06-23 | 登山模組 scope 收斂(2026-06-23 user 裁決「登山是獨立可拆模組」):登山專屬 = 裝備租借 + 抽籤 + 入山/入園/山屋/登山險 deadline + 登山分級 + 人頭使用次數限制;departure-prep 核心骨幹(對款/派車/餐食/投保/換領隊)移出登山 gate 歸核心(§1 表 + 收斂註 / §2 核心⇄登山重歸類 / §3.3 departure-prep 改「只有登山子集過閘」/ §7 觸發點)。 | — |
| 8 | 2026-06-28 | 加進階會計模組(第 5 個,子系統 gate;經理 + user 帳務拆分點裁決):金流對帳結算損益(多帳戶對帳 / 帳戶結算月結互抵 / 內外帳淨利分析 / 公司損益表 / 成本多幣別精算 / 出款批次匯款檔 / 退款執行)收進 module_accounting,掛帳台帳(訂單應收 + 成本掛帳含代墊/憑證兩金額 + payables 核簽 + 團控毛估 + Excel 匯出)留核心。§1 表第 5 列、§2 加「🟫 進階會計模組」分類、§3.1 schema module_accounting、§3.3 accountingActive / requireAccountingModule(待實作)。拆分點 SSOT = 新 accounting-module;internal-external-ledger / control-finance 橫跨邊界(登記核心 / 分析結算模組)。 | — |