線稿索引所有 spec最後更新 2026-10-10
specs/group-code.md

團號系統

一句話:團號是梯次對外識別碼,編碼出團年份、地區、(選填)航空、出發日與同日同地流水序;本檔是 departures.group_code 與地區 / 航空對照表的 SSOT。國內團為主軸(荒野真實主業是國內高山團,無航空),航空碼選填:有航空 → YY+地區+航空+MMDD+序號;無航空(國內線)→ YY+地區+MMDD+序號

1. 問題動機

旅行社以「團號」作為梯次的對外識別碼,貫穿報名、機票開立、估價單、出團通知、財務對帳。團號慣例編碼了出團年份、地區、(海外線才有的)主要航空、出發日期、同日同地的流水序

國內團變體(主軸,無航空段),例如玉山、嘉明湖、奇萊南華等國內高山團:

26 YS 1010 A
│  │  │    └ 序號字母(同地區同出發日的第幾團:A / B / C …)
│  │  └────── 出發月日 MMDD
│  └───────── 地區 / 山域碼(YS = 玉山)
└──────────── 出團年份末兩碼(26 = 2026)

海外團變體(含航空段),例如埃及、土耳其等海外線:

26 EG TK 1010 A
│  │  │  │    └ 序號字母(同地區同出發日的第幾團:A / B / C …)
│  │  │  └────── 出發月日 MMDD
│  │  └───────── 航空碼(TK = 土耳其航空;國內團省略此段)
│  └──────────── 地區碼(EG = 埃及)
└─────────────── 出團年份末兩碼(26 = 2026)

航空碼選填(2026-06-23 D1):荒野真實主業是國內高山團(玉山 / 嘉明湖 / 奇萊…),國內團沒有航空。原 pattern 強制航空碼會讓 bulk-import 對國內團產不出團號。故航空碼改選填,pattern 國內變體省略航空段。地區碼 seed 補 TW 國內山域碼(見 §3.5)。

現況 wildtw-erp 的 departures(梯次)只有 departure_date / price_twd / capacity 等欄位,沒有團號概念。OP 建梯次時無法產生團號,後台也無法用團號回查梯次或關聯訂單。本 spec 補上這塊。

設計目標:

  1. 地區碼、航空碼可由後台自行維護(不寫死,未來新增線路不需改 code)。
  2. 建梯次時下拉選地區 + 航空 + 出發日 → 系統自動拼團號,OP 可覆寫。
  3. 團號在單一 tenant DB 內唯一;同地區同出發日的序號(A/B/C)產生需併發安全(對齊 CLAUDE.md / system-design §13 的 next_order_number() race-safe 慣例)。
  4. 後台可用團號搜尋梯次 / 關聯訂單

非目標(out of scope):

  • 團號格式不做完整 tenant 級自訂 (已翻案,2026-07-03):團號格式改為 per-tenant {token} DSL 樣板(見 §12),內建預設逐位元組重現原兩變體、零回歸;其他旅行社的別種慣例(四位年 / 分隔符 / 無地區航空段)皆可自訂。
  • 不回溯既有梯次補團號(既有 group_code 留 null,可後台手動補;不寫批次回填)。
  • 序號上限超過 26 團(A–Z)後的處置採降級規則(見 §4.3),不做雙字母擴充。

2. 範圍

項目動作
region_codes新增(對照表:碼 + 中文名 + 啟用旗標 + 排序)
airline_codes新增(對照表:碼 + 名 + 啟用旗標 + 排序)
departures.group_code新增欄位(nullable,唯一)+ region_code / airline_code 兩個來源碼欄位
序號併發安全新增 group_code_counter 表 + next_group_code_suffix(prefix) SQL function
團號產生 / 覆寫 server actions設計(不實作)
團號搜尋設計(梯次 + 關聯訂單)
後台對照表維護頁畫面:地區 / 航空對照表維護(見線稿)
行程列表加團號欄 + 團號搜尋框(見線稿)
建梯次團號 UI建梯次的團號產生 / 覆寫畫面(見線稿)

3. 資料模型

承 system-design §7.1 全局原則:主鍵 ULID 存 text;金額為 integer TWD(本 spec 無金額欄位);business table tenant_id;時刻用 timestamptz

3.1 region_codes(地區對照表)

地區碼本身就是穩定且人類可讀的識別(EGTRJP),直接拿碼當主鍵(text PK),省去 join、也讓 departures.region_code 自然成為 FK。

// packages/core/src/schema/business.ts
export const regionCodes = pgTable(
  'region_codes',
  {
    // 地區碼,2–3 大寫英數,e.g. 'EG' / 'TR' / 'JP'. 本身即 PK。
    code: text('code').primaryKey(),
    // 中文名,e.g. '埃及' / '土耳其' / '日本'.
    name: text('name').notNull(),
    // 啟用旗標:停用後不出現在建梯次下拉,但既有團號保留。
    isActive: boolean('is_active').notNull().default(true),
    // 下拉排序(小→大)。常用線排前面。
    displayOrder: integer('display_order').notNull().default(0),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    index('region_codes_active_order_idx').on(t.isActive, t.displayOrder),
    // 碼一律大寫;DB 層也擋一道(與 app 層 .toUpperCase() 雙保險)。
    check('region_codes_code_upper', sql`${t.code} = upper(${t.code})`),
    check('region_codes_code_shape', sql`${t.code} ~ '^[A-Z0-9]{2,3}$'`),
  ],
);

3.2 airline_codes(航空對照表)

同理,IATA 兩碼(TKBRCI)本身穩定可讀,碼當主鍵

export const airlineCodes = pgTable(
  'airline_codes',
  {
    // 航空碼,2–3 大寫英數(IATA 多為 2 碼),e.g. 'TK' / 'BR' / 'CI'.
    code: text('code').primaryKey(),
    // 航空名稱,e.g. '土耳其航空' / '長榮航空' / '中華航空'.
    name: text('name').notNull(),
    isActive: boolean('is_active').notNull().default(true),
    displayOrder: integer('display_order').notNull().default(0),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    index('airline_codes_active_order_idx').on(t.isActive, t.displayOrder),
    check('airline_codes_code_upper', sql`${t.code} = upper(${t.code})`),
    check('airline_codes_code_shape', sql`${t.code} ~ '^[A-Z0-9]{2,3}$'`),
  ],
);

碼當主鍵的後果:改碼(rename)等同刪 + 建。對照表的碼一旦被梯次引用就不該改(會孤兒化既有團號);UI 層對「已被使用」的碼禁用改碼,只允許改名 / 停用。改碼需求極罕見,不為它建代理鍵。

3.3 departures 團號相關新欄位

departures 加三個欄位 + 一個唯一索引。group_code nullable —— 既有梯次與「建了但還沒給團號」的梯次都允許 null;一旦有值就 tenant 內唯一。

// 既有 departures 表追加:
export const departures = pgTable(
  'departures',
  {
    // ... 既有欄位(id / tripId / departureDate / ... )不變 ...

    // ---- 團號 ----
    // 完整團號,e.g. '26EGTK1010A'。NULL = 尚未產生。產生後 tenant 內唯一。
    groupCode: text('group_code'),
    // 產生來源:地區碼。FK → region_codes.code(onDelete: 'restrict' 不讓刪掉被引用的碼)。
    regionCode: text('region_code').references(() => regionCodes.code, {
      onDelete: 'restrict',
    }),
    // 產生來源:航空碼。FK → airline_codes.code。
    airlineCode: text('airline_code').references(() => airlineCodes.code, {
      onDelete: 'restrict',
    }),
  },
  (t) => [
    // ... 既有 check / index 不變 ...
    // 團號唯一(partial:只對 non-null 生效,避免一堆 null 撞唯一)。
    uniqueIndex('departures_group_code_unique')
      .on(t.groupCode)
      .where(sql`${t.groupCode} IS NOT NULL`),
    // 用團號搜尋 → 走唯一索引即可,不另建。
  ],
);

欄位最終命名(見 §11)group_code / region_code / airline_code,皆 text、皆 nullable。

3.4 序號(A/B/C)併發安全

序號語意:同 tenant、同地區碼、同航空碼、同出發日的第幾團 → A(第 1)、B(第 2)…。這不是全 DB 單調序列(那是 order_number 的做法),而是**「按 prefix 分桶」的計數器**。

system-design 的 next_order_number() 用單一 order_number_seq sequence,因為它全 DB 單調。團號序號要 per-prefix 重置,sequence 做不到(sequence 不能依參數分桶)。改用 計數器表 + 原子 UPSERT 取得下一個序號 —— Postgres 的 INSERT ... ON CONFLICT DO UPDATE ... RETURNING 對同一 key 的並發呼叫會被 row lock 序列化,與 conditional UPDATE 同等 race-safe。

prefix 定義為不含序號的團號前段,依航空碼有無切換變體:

  • 海外(有航空):YY + 地區 + 航空 + MMDD,例 26EGTK1010
  • 國內(無航空):YY + 地區 + MMDD,例 26YS1010

航空碼選填(D1)buildGroupCodePrefixairlineCode 為 optional —— 給值即拼入航空段、留空(國內團)即省略。同地區同日有航空 vs 無航空屬不同 prefix,各自從 A 起算(與「不同航空 = 不同團」一致)。已落地(2026-07-02):packages/core/src/catalog/group-codes-repo.tsbuildGroupCodePrefix / generateGroupCodeairlineCode 已改 optional(留空略過航空段、departures.airline_code 對國內團寫 NULL)。

-- scripts/setup-business-extras.ts(與 next_order_number / next_payment_request_number 同檔)

-- 每個 prefix 一列,記錄已發到第幾號(1-based)。
CREATE TABLE IF NOT EXISTS group_code_counter (
  prefix text PRIMARY KEY,            -- '26EGTK1010'
  next_seq integer NOT NULL DEFAULT 1 -- 下一個要發的序號(1 = A)
);

-- 原子取得並遞增某 prefix 的下一個序號,回傳「本次該用的序號整數」。
-- 並發呼叫同一 prefix → ON CONFLICT 的 row lock 序列化,不會發出重號。
CREATE OR REPLACE FUNCTION next_group_code_suffix(p_prefix text)
RETURNS integer LANGUAGE sql VOLATILE AS $func$
  INSERT INTO group_code_counter (prefix, next_seq)
  VALUES (p_prefix, 2)               -- 第一次插入 → 本次用 1,next 預存 2
  ON CONFLICT (prefix) DO UPDATE
    SET next_seq = group_code_counter.next_seq + 1
  RETURNING next_seq - 1;            -- 不論新舊路徑,回傳本次序號(1-based)
$func$;

序號整數 → 字母由 app 層轉(1 → 'A'26 → 'Z')。整支團號產生在一個 transaction 內:

  1. seq = SELECT next_group_code_suffix('26EGTK1010')(原子)
  2. suffix = String.fromCharCode(64 + seq)(1→A)
  3. groupCode = prefix + suffix26EGTK1010A
  4. UPDATE departures SET group_code = ..., region_code = ..., airline_code = ... WHERE id = ...

唯一索引 departures_group_code_unique最後一道防線:即使 OP 手動覆寫成已存在的團號,insert/update 會被唯一索引擋下(與 order_number 同模式)。

計數器與唯一索引的關係:計數器只負責「自動產生」時不撞號;它不是唯一性的真實來源。手動覆寫、刪梯次再建、跨 prefix 等情境一律靠唯一索引兜底。計數器只增不減(刪梯次不回收序號 —— 與 sequence 一樣允許跳號,序號連續性非業務需求)。

3.5 seed 初始碼

scripts/setup-business-extras.ts 順手 seed 幾個常用碼(idempotent,ON CONFLICT DO NOTHING),避免空表讓建梯次卡住:

國內山域碼為主軸(航空碼對國內團不適用),海外地區 / 航空碼次之:

region_codes(國內山域,主軸)region_codes(海外)airline_codes(海外才用)
YS 玉山JM 嘉明湖EG 埃及TK 土耳其航空BR 長榮航空
CL 奇萊(南華)BW 北大武TR 土耳其CI 中華航空CX 國泰航空
SS 雪山TW 台灣其他JP 日本 / NP 尼泊爾JX 星宇航空

國內山域碼可由後台維護(§8.1);上表為 seed 起點,實際碼由荒野依山域命名慣例調整。國內團走無航空變體(產團號時航空欄留空)。

4. 團號產生與覆寫流程

4.1 建梯次(trip-edit)的產生 UI

建梯次表單在「出發日期」旁加三個控制:

  1. 地區 下拉(region_codes where is_active,依 display_order),option 顯示 碼 — 中文名,value = 碼。
  2. 航空 下拉(airline_codes 同上)。
  3. 團號 唯讀預覽輸入框(灰底)+「自動產生」按鈕。

互動:

  • 三項(地區、航空、出發日)齊全 → 啟用「自動產生」。按下 → server action 產生團號(含序號),回填預覽框。
  • 預覽框旁有「✎ 覆寫」切換 → 解鎖成可編輯,OP 可手填團號(例外狀況 / 沿用舊系統團號)。覆寫值仍須過唯一性檢查與格式檢查(見 §4.4)。
  • 出發日 / 地區 / 航空 任一變更後若已產生過團號 → 標記「來源已變更,建議重新產生」提示(不自動覆蓋已存值,避免吃掉手動覆寫)。

產生時機:序號在「按下自動產生」當下才向 next_group_code_suffix 取號並落 departures 列(與梯次本體同 transaction)。不在表單空轉時預取,避免使用者放棄表單卻消耗序號(雖然跳號可接受,仍以「落地才取號」為準)。

4.2 序號的「同地區同日不同團」語意

兩個梯次只要 (YY, 地區, 航空, MMDD) 相同就共用同一 prefix、序號遞增:

梯次地區航空出發日prefix序號團號
1YS(無)2026-10-1026YS10101→A26YS1010A(國內)
2YS(無)2026-10-1026YS10102→B26YS1010B(國內)
3EGTK2026-10-1026EGTK10101→A26EGTK1010A(海外)
4EGTK2026-10-1026EGTK10102→B26EGTK1010B(海外)
5EGBR2026-10-1026EGBR10101→A26EGBR1010A(海外)
  • 國內團(無航空)→ prefix 省略航空段,仍以 (YY, 地區, MMDD) 分桶遞增序號。
  • 海外團航空不同 → prefix 不同 → 各自從 A 起算(不同航空是不同團)。
  • 同地區同日「有航空 vs 無航空」屬不同 prefix(極罕見,國內團不會有航空)。

4.3 序號用罄(> 26 團)降級

同 prefix 超過 26 團極罕見(同地區同航空同日 27 團)。降級規則:序號 27 起改用雙字母AAAB…),由 app 層轉換函式處理(27 → 'AA')。唯一索引仍保證不撞。此為防呆,非主路徑;UI 不特別呈現。

4.4 覆寫 / 手填的驗證(zod,server action 入口)

  • 格式(兩變體,航空段選填):^\d{2}[A-Z0-9]{2,3}([A-Z]{2,3})?\d{4}[A-Z]{1,2}$(寬鬆比對 YY 地區 [航空] MMDD 序號;航空段 optional 以支援國內變體 26YS1010A;不強制地區 / 航空碼存在於對照表,因覆寫可能沿用已停用的歷史碼)。因 \d{4} 為 MMDD 錨點,地區段與選填航空段的邊界以「MMDD 前的字母群」界定。
  • 唯一:對照 departures_group_code_unique(DB 兜底,app 先查一次給友善錯誤)。
  • 大寫正規化:一律 .toUpperCase().trim()

5. 團號搜尋

5.1 入口

  1. trips.html topbar 搜尋框:擴充既有「搜尋行程名稱 / slug」→ 同框也吃團號。輸入命中團號格式(regex)時,視為團號精確查詢。
  2. trips.html filter bar:新增「團號」文字輸入(精確 / 前綴皆可),與行程篩選並存。

5.2 查詢行為

  • 精確團號 → 梯次SELECT ... FROM departures WHERE group_code = $1(走唯一索引)。命中 → 導向該梯次所屬行程的 trip-edit(梯次 tab)或團控面板該梯次。
  • 前綴團號 → 多梯次WHERE group_code LIKE $1 || '%'(例輸入 26EGTK 列出該地區該航空所有團)。
  • 團號 → 關聯訂單:團號定位到 departure_id 後,經 order_linestour_booking_lines 列出該梯次所有 tour 訂單(團控面板既有能力,不新增 schema)。

5.3 repo 函式(設計,packages/core/src/catalog/group-codes-repo.ts,不實作)

// 對照表 CRUD
listRegionCodes(db, opts?: { activeOnly?: boolean }): Promise<RegionCodeRow[]>
upsertRegionCode(db, input: { code; name; isActive?; displayOrder? }): Promise<void>
listAirlineCodes(db, opts?): Promise<AirlineCodeRow[]>
upsertAirlineCode(db, input): Promise<void>

// 團號產生(在 departure 建立 / 更新流程內呼叫)
generateGroupCode(db, args: {
  departureId: string;
  regionCode: string;
  airlineCode: string;
  departureDate: string; // 'YYYY-MM-DD'
}): Promise<{ groupCode: string }>   // 內部呼叫 next_group_code_suffix + UPDATE

setGroupCodeManual(db, departureId: string, groupCode: string): Promise<void> // 覆寫

// 搜尋
findDepartureByGroupCode(db, code: string): Promise<DepartureRow | null>
searchDeparturesByGroupCodePrefix(db, prefix: string): Promise<DepartureRow[]>

6. Server actions(設計,不實作)

src/app/(admin)/admin/trips/actions.ts(團號產生附在建梯次流程)與新檔 src/app/(admin)/admin/group-codes/actions.ts(對照表維護)。一律 zod 驗 input、requirePermission 守門、writeAuditLog 記錄。

權限體系(對齊現行 code):權限走 better-auth createAccessControlpackages/core/src/permissions-ac.tsstatement catalog + ac.newRole(...)),閘走 auth.api.userHasPermissionROLE_PERMS 物件requirePermission('resource.action')第一個 . 切 resource / action。本 spec 兩個權限:

  • departure.update:catalog statement.departure 已含 ["read","update"]op / admin 已獲授 → 不用改 catalog(保留)。
  • group_code.manage:碼表維護是 group-code 自己的子功能,用獨立 resource group_code(不塞進泛 tenant.settings,避免與 settings 頁其他租戶設定權限糾纏 / 撞名)。catalog 目前無 group_code resource → 需新增(見 §7 實作步驟)。
Action權限說明audit action
generateDepartureGroupCodeActiondeparture.update(catalog 已有)建/編梯次時產生團號departure.group_code.generate
overrideDepartureGroupCodeActiondeparture.update(catalog 已有)OP 手動覆寫團號(唯一 + 格式驗證)departure.group_code.override
createRegionCodeAction / updateRegionCodeActiongroup_code.managecatalog 需新增,見 §7)維護地區碼region_code.create / region_code.update
createAirlineCodeAction / updateAirlineCodeActiongroup_code.managecatalog 需新增維護航空碼airline_code.create / airline_code.update

對照表沒有 hard delete(碼可能被歷史團號引用,FK onDelete: 'restrict')。停用 = is_active = false,UI 以「停用」呈現。update action 對「已被 departures 引用的碼」禁止改 code(改碼會孤兒化),只允許改 name / isActive / displayOrder

7. 角色權限

沿用 packages/core/src/permissions-ac.tscreateAccessControl 模型(statement catalog + ac.newRole(...),閘走 auth.api.userHasPermissionROLE_PERMS 物件)。

  • 團號產生 / 覆寫:掛在既有 departure.update。catalog statement.departure 已有 "update"opadmin 已獲授;sales 無 —— 符合「線控管梯次、業務不碰」的分工。不用改 catalog
  • 對照表維護:碼表維護是 group-code 自己的子功能,新增獨立 resource 的權限 group_code.manage(不複用泛 tenant.settings,避免與 settings 頁其他租戶設定權限糾纏 / 撞名)。以 first-dot split:resource=group_code、action=manage

group_code.manage 實作步驟(packages/core/src/permissions-ac.ts

  1. statement catalog 新增 group_code resource:group_code: ["manage"](catalog 目前 group_code resource,缺則一律 deny)。
  2. 授權給 admin:admin role 由 ac.newRole({ ...statement(攤平), ...adminAc.statements }) 攤平整個 catalog 自動涵蓋——但仍須先完成步驟 1 的 catalog 註冊,admin 才會涵蓋(admin 不是字面 ['*'])。
  3. op 不授予 group_code.manage(避免亂建碼污染團號空間),但 op 能在建梯次時選用既有碼(走 departure.update,與碼表維護權限無關)。

若未來要給 op 維護碼表,再在 op role 顯式加 group_code: ["manage"]。本 spec 設計階段:catalog 新增 group_code resource + manage action 並授權 admin 是實作前必做(不是「無須加」)。

8. UI

8.1 對照表維護頁(/admin/group-codes

  • 路徑語意:/admin/group-codes,歸「系統 / 設定」類(跨梯次、非 per-departure,符合 CLAUDE.md 線控 UX 原則:設定類才開獨立 nav)。
  • 左右兩塊 panel:地區碼航空碼,各自一張表 + 「新增」按鈕。
  • 每列:碼(monospace)、名稱、啟用開關(switch)、排序、編輯。已被梯次引用的碼,碼欄顯示鎖頭 + 「使用中 N」徽章,禁止改碼。
  • 頂部說明條:解釋團號格式與序號規則(YY+地區+航空+MMDD+序號,附 26EGTK1010A 範例)。
  • 畫面:對照表維護頁(見線稿)。

8.2 行程列表

  • 表格新增「團號」欄(行程名稱 / slug 之後):列出該行程近期梯次的代表團號(最新一筆有團號的梯次;無則顯示 )。團號 monospace。
  • topbar 搜尋框 placeholder 改為「搜尋行程名稱 / slug / 團號…」。
  • filter bar 新增「團號」文字輸入(精確 / 前綴)。

8.3 建梯次團號 UI

建梯次處加地區 / 航空下拉 + 團號預覽 + 自動產生 / 覆寫切換。互動規則:

  • 三項(地區、航空、出發日)齊全才啟用「自動產生」。
  • 「覆寫」切換解鎖團號輸入框,OP 可手填(過唯一性 + 格式檢查,見 §4.4)。
  • 地區 / 航空 / 出發日變更後若已有團號 → 顯示「來源已變更」提示重新產生(不自動覆蓋已存值)。
  • 預覽框下方小字說明格式:YY+地區+航空+MMDD+序號(同地區同出發日自動 A/B/C)
  • 梯次列表表格新增「團號」欄,列值以 monospace 顯示團號或
  • 畫面:建梯次的團號欄位群(見線稿)。

9. 測試(vitest,設計)

新檔 tests/group-codes.test.ts

  1. 序號併發安全:並發呼叫 next_group_code_suffix('26EGTK1010') N 次(Promise.all)→ 回傳值為 1..N 無重複(對齊 tryReserveSeats / order_number 的並發測試模式)。
  2. 跨 prefix 獨立:不同 prefix 各自從 1 起算,互不干擾。
  3. 團號組裝generateGroupCode(EG, TK, 2026-10-10) 連兩次 → 26EGTK1010A26EGTK1010B
  4. 唯一性兜底:手動覆寫成既存團號 → 唯一索引拋錯(被 setGroupCodeManual 轉成友善錯誤)。
  5. 格式驗證:非法團號(缺序號 / 小寫 / MMDD 非法)被 zod 拒。
  6. 碼表 FK restrict:刪被梯次引用的 region code → 失敗;停用則成功且既有團號不變。
  7. 序號 > 26 降級:第 27 號 → AA

清理用 TRUNCATE ... CASCADE(含 group_code_counter)。

10. 影響範圍

區塊影響
packages/core/src/schema/business.ts新增 regionCodes / airlineCodesdepartures 加 3 欄 + 1 partial unique index。需 boolean(已 import)。
scripts/setup-business-extras.tsgroup_code_counter 表、next_group_code_suffix() function、seed 常用碼(idempotent)。對齊既有 next_order_number 慣例。
packages/core/src/catalog/trips-repo.tsNewDepartureInput / DepartureRow / TripDetail.departuresgroupCode / regionCode / airlineCodecreateDeparture / getTripById 等帶上。
packages/core/src/catalog/group-codes-repo.ts新檔(對照表 CRUD + 團號產生 + 搜尋)。
src/app/(admin)/admin/group-codes/新頁 + actions(對照表維護)。
src/app/(admin)/admin/trips/actions.ts加團號產生 / 覆寫 action;topbar / filter 搜尋接團號。
packages/core/src/permissions-ac.tsstatement catalog 新增 group_code: ["manage"] resource 並授權 admin(admin 攤平 catalog 自動涵蓋,但須先註冊)。departure.update 已存在,不改。
線稿對照表維護頁(新)、行程列表(改)、建梯次團號欄位群(見 apps/wireframes)。

與相鄰功能的協調點

  • 建梯次區:定價功能與團號都動建梯次區。團號只加團號欄位群(§8.3),不碰價格 / 訂金 / includes,無欄位重疊,整併套用即可。
  • 團控財務:團控面板 per-梯次檢視可額外顯示團號(純 read,無 schema 衝突);本 spec 不主動改團控面板,視需要加。
  • 無 schema 撞名region_codes / airline_codes / group_code_counter 為全新表;departures 新增欄位 group_code / region_code / airline_code 與其他功能(定價的價格、優惠的 promo_price_twd)不重疊。

11. Schema 欄位彙整

新表:

欄位型別備註
region_codescodetext PK地區碼,upper + ^[A-Z0-9]{2,3}$ check
nametext not null中文名
is_activeboolean not null default true
display_orderinteger not null default 0
created_at / updated_attimestamptz
airline_codescodetext PK航空碼,同 region 的 check
nametext not null航空名
is_activeboolean not null default true
display_orderinteger not null default 0
created_at / updated_attimestamptz
group_code_counterprefixtext PKYY+地區+航空+MMDD,序號分桶 key(在 setup-business-extras,不在 drizzle schema)
next_seqinteger not null default 1下一個序號(1-based)

departures 新增欄位:

欄位型別備註
group_codetext nullable完整團號 26EGTK1010A;partial unique index(non-null 唯一)
region_codetext nullableFK → region_codes.codeonDelete: restrict
airline_codetext nullableFK → airline_codes.codeonDelete: restrict

新 index:departures_group_code_unique(partial, where group_code IS NOT NULL)。

SQL function:next_group_code_suffix(text) RETURNS integer(race-safe,原子 UPSERT)。

12. tenant 級團號 pattern(DSL)

動機(多租戶開放性):荒野用「年+地區+航空+月日+序號」,但別家旅行社慣例不同(有的用四位年、有的加分隔符、有的完全不放地區/航空)。故團號格式改為 per-tenant 可設定的 {token} 樣板,不寫死荒野特定 pattern。

12.1 {token} DSL

樣板由 {token} 與 literal 分隔符組成。token 白名單:

token意義渲染
{YY} / {YYYY}出團年(末兩碼 / 四碼)26 / 2026
{MM} / {DD} / {MMDD}出發月 / 日 / 月日10 / 10 / 1010
{region}地區碼([A-Z0-9]{2,3})大寫
{airline} / {airline?}航空碼;加 ?選填段(空值時整段省略)大寫
{seq}同 prefix 流水序 —— 必含恰一次,可在任意位置見 §13.3
其餘字元literal 分隔符(-/ …)原樣
  • region / airline 皆可完全不出現在 pattern(例 {YYYY}{MM}{seq})。只有 region / airline 可加 ? 選填;日期/年一定有值、{seq} 必填不可選。
  • 驗證(parsePattern:括號平衡、token 白名單、恰一 {seq};違反回人話。
  • 內建預設(group_code_pattern = NULL){YY}{region}{airline?}{MMDD}{seq} —— 逐位元組重現現行海外 26EGTK1010A / 國內 26YS1010A(airline 空 → 省略段),零回歸。

12.2 bucket 語意(沿用不動)

bucket key = pattern 渲染時把 {seq} 換成空字串renderPrefix)。沿用既有 group_code_counter 分桶 + next_group_code_suffix(prefix)(race-safe 不動)。同一 tenant 同一 prefix 的序號單調遞增;不同 pattern 渲染出不同 prefix → 各自從頭起算(互不干擾)。

12.3 序號樣式(group_code_seq_style / group_code_seq_width

計數器永遠存整數序號;樣式只在渲染時套用(seqToStyled):

style規則例(seq 1 / 27)
alpha(預設)A–Z bijective base-26A / AA
alpha_no_io同 bijective 但字母表跳過 I / O(24 字母)A / AA(9→J
numeric補零至 group_code_seq_width(超寬不截斷)01 / 27(width 2)

12.4 驗證(覆寫 / 手填)

  • pattern=NULL → 維持現行寬鬆 GROUP_CODE_RE(容歷史碼,不強制對照表碼存在)。
  • pattern 自訂 → 對 deriveRegex(pattern, seqStyle) 衍生的 regex 驗(seq 樣式感知:numeric → \d+、alpha* → [A-Z]+)。
  • GROUP_CODE_RE 是 sync 常數、pattern 是 async 取得,故驗證下沉到 server action / repo 層overrideDepartureGroupCodeAction)。

12.5 設定 UI + 落地

  • 後台 /admin/settings 「團號格式」tab(admin-only,閘 group_code.manage):pattern 輸入 + 序號樣式 Select + 補零寬度 + live preview(純函式引擎即時渲染假資料)+「改樣板不回寫既有團號」提示。updateGroupCodePatternAction(requirePermission + parsePattern 驗 + 前/後值 audit group_code.pattern.update)。
  • 改 pattern 不回寫既有團號(唯一索引續守),只影響之後新產號。
  • 純函式引擎 packages/core/src/catalog/group-code-pattern.ts(零 DB / 零 server-only)供 server repo 與 client live-preview 共用同一份真相。generateGroupCode / bulk-import 讀 loadGroupCodePatternConfig(db)renderPrefix 取 bucket → 計數器 → seqToStyledrenderGroupCode。bulk-import「缺地區→不產號」改 pattern-driven(pattern 含 {region} 才要求地區碼)。

13. 未決

  • 團號 pattern 是否要 tenant 級自訂 (已決 + 已落地,2026-07-03):改為 {token} DSL 的 per-tenant 樣板(見 §13)。tenant_settings.group_code_pattern(NULL = 內建預設,逐位元組重現現行兩變體,零回歸)+ group_code_seq_style + group_code_seq_width。純函式引擎 packages/core/src/catalog/group-code-pattern.tsparsePattern / renderPrefix / renderGroupCode / deriveRegex / seqToStyled)統一取代「buildGroupCodePrefix 與 GROUP_CODE_RE 兩份寫死真相」。後台 /admin/settings 團號格式 tab(group_code.manage)自填 + live preview。
  • group_code_counter 是否扶正進 drizzle schema:目前比照 created_by_user_id 等 8 個 ALTER 放在 setup-business-extras.ts(避免 schema-diff churn)。region_codes / airline_codes 為正式業務表,放 drizzle schema;group_code_counter 是 race-safe 機制的內部表,跟 sequence 一樣放 setup-extras 較一致 —— 何時扶正待決(同 CLAUDE.md 既有 gotcha)。
  • 國內線團號是否省略航空碼 (已決 + 已落地,2026-06-23 D1 / 2026-07-02 實作):國內團(玉山 / 嘉明湖 / 奇萊…)為主軸、無航空。航空碼改選填,國內變體 pattern 省略航空段(YY+地區+MMDD+序號),不用 sentinel 碼。departures.airline_code 對國內團寫 NULL。buildGroupCodePrefix / generateGroupCode / GROUP_CODE_RE / bulk-import / CLI 全放寬,airlineCode 已改 optional。
  • 既有梯次回填:是否要批次補團號工具。目前留 null + 手動覆寫,不寫回填。
  • 序號字母是否跳過易混淆字(I / O) (已決 + 已落地,2026-07-03):改為 per-tenant group_code_seq_style 設定(alpha A–Z 預設 / alpha_no_io 跳過 I·O / numeric 數字補零)。seqToStyled 純函式吸收;不影響 schema / 計數器(計數器仍存整數序號,樣式只在渲染時套用)。

Changelog

版本日期變更Commit
12026-05-29初版
22026-05-30整理:對齊現行 RBAC/欄位慣例 + 路徑搬移
32026-06-20碼表維護權限由 tenant.settings 改為獨立 resource group_code.manage(避免與 settings 頁其他租戶設定權限糾纏/撞名)。
42026-06-23D1 航空碼改選填 + 國內變體:國內團為主軸(荒野主業國內高山團,無航空),pattern 兩變體(國內 YY+地區+MMDD+序號 / 海外 YY+地區+航空+MMDD+序號,由航空碼有無自動切換);§1 標題 / 範例 / 非目標、§3.4 prefix(+code follow-up 對齊註:airlineCode 需改 optional)、§3.5 seed 補國內山域碼(YS/JM/CL/BW/SS)、§4.2 國內範例列、§4.4 regex 航空段選填、§12 未決定案。
52026-07-02airlineCode 改 optional 落地buildGroupCodePrefix / generateGroupCode / GROUP_CODE_RE / bulk-import / CLI 全放寬,移除 sentinel XX,國內團省略航空段、departures.airline_code 寫 NULL;國內高山團 bulk-import 可正常產號(37 測試)。§3.4 對齊註與 §12 未決同步翻正為已落地。
62026-07-03tenant 級團號 pattern({token} DSL)落地:新增 §12「tenant 級團號 pattern(DSL)」(token 表 / bucket 語意 / 序號樣式 / 衍生 regex 驗證 / 設定 UI);純函式引擎 packages/core/src/catalog/group-code-pattern.tsparsePattern / renderPrefix / renderGroupCode / deriveRegex / seqToStyled)取代兩份寫死真相;tenant_settingsgroup_code_pattern(NULL=內建預設,零回歸)/ group_code_seq_style(alpha / alpha_no_io / numeric)/ group_code_seq_width/admin/settings 團號格式 tab(group_code.manage + live preview);bulk-import「缺地區→不產號」改 pattern-driven;覆寫驗證下沉 server action(NULL 走寬鬆 GROUP_CODE_RE、自訂走 deriveRegex)。§1 非目標翻案、§12 / §13 兩未決定案。