一句話:團號是梯次對外識別碼,編碼出團年份、地區、(選填)航空、出發日與同日同地流水序;本檔是
departures.group_code與地區 / 航空對照表的 SSOT。國內團為主軸(荒野真實主業是國內高山團,無航空),航空碼選填:有航空 →YY+地區+航空+MMDD+序號;無航空(國內線)→YY+地區+MMDD+序號。
旅行社以「團號」作為梯次的對外識別碼,貫穿報名、機票開立、估價單、出團通知、財務對帳。團號慣例編碼了出團年份、地區、(海外線才有的)主要航空、出發日期、同日同地的流水序。
國內團變體(主軸,無航空段),例如玉山、嘉明湖、奇萊南華等國內高山團:
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 補上這塊。
設計目標:
next_order_number() race-safe 慣例)。非目標(out of scope):
{token} DSL 樣板(見 §12),內建預設逐位元組重現原兩變體、零回歸;其他旅行社的別種慣例(四位年 / 分隔符 / 無地區航空段)皆可自訂。group_code 留 null,可後台手動補;不寫批次回填)。| 項目 | 動作 |
|---|---|
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 | 建梯次的團號產生 / 覆寫畫面(見線稿) |
承 system-design §7.1 全局原則:主鍵 ULID 存 text;金額為 integer TWD(本 spec 無金額欄位);business table 無 tenant_id;時刻用 timestamptz。
region_codes(地區對照表)地區碼本身就是穩定且人類可讀的識別(EG、TR、JP),直接拿碼當主鍵(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}$'`),
],
);
airline_codes(航空對照表)同理,IATA 兩碼(TK、BR、CI)本身穩定可讀,碼當主鍵。
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 層對「已被使用」的碼禁用改碼,只允許改名 / 停用。改碼需求極罕見,不為它建代理鍵。
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。
序號語意:同 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):
buildGroupCodePrefix的airlineCode為 optional —— 給值即拼入航空段、留空(國內團)即省略。同地區同日有航空 vs 無航空屬不同 prefix,各自從 A 起算(與「不同航空 = 不同團」一致)。已落地(2026-07-02):packages/core/src/catalog/group-codes-repo.ts的buildGroupCodePrefix/generateGroupCode的airlineCode已改 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 內:
seq = SELECT next_group_code_suffix('26EGTK1010')(原子)suffix = String.fromCharCode(64 + seq)(1→A)groupCode = prefix + suffix(26EGTK1010A)UPDATE departures SET group_code = ..., region_code = ..., airline_code = ... WHERE id = ...唯一索引 departures_group_code_unique 是最後一道防線:即使 OP 手動覆寫成已存在的團號,insert/update 會被唯一索引擋下(與 order_number 同模式)。
計數器與唯一索引的關係:計數器只負責「自動產生」時不撞號;它不是唯一性的真實來源。手動覆寫、刪梯次再建、跨 prefix 等情境一律靠唯一索引兜底。計數器只增不減(刪梯次不回收序號 —— 與 sequence 一樣允許跳號,序號連續性非業務需求)。
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 起點,實際碼由荒野依山域命名慣例調整。國內團走無航空變體(產團號時航空欄留空)。
建梯次表單在「出發日期」旁加三個控制:
region_codes where is_active,依 display_order),option 顯示 碼 — 中文名,value = 碼。airline_codes 同上)。互動:
產生時機:序號在「按下自動產生」當下才向
next_group_code_suffix取號並落departures列(與梯次本體同 transaction)。不在表單空轉時預取,避免使用者放棄表單卻消耗序號(雖然跳號可接受,仍以「落地才取號」為準)。
兩個梯次只要 (YY, 地區, 航空, MMDD) 相同就共用同一 prefix、序號遞增:
| 梯次 | 地區 | 航空 | 出發日 | prefix | 序號 | 團號 |
|---|---|---|---|---|---|---|
| 1 | YS | (無) | 2026-10-10 | 26YS1010 | 1→A | 26YS1010A(國內) |
| 2 | YS | (無) | 2026-10-10 | 26YS1010 | 2→B | 26YS1010B(國內) |
| 3 | EG | TK | 2026-10-10 | 26EGTK1010 | 1→A | 26EGTK1010A(海外) |
| 4 | EG | TK | 2026-10-10 | 26EGTK1010 | 2→B | 26EGTK1010B(海外) |
| 5 | EG | BR | 2026-10-10 | 26EGBR1010 | 1→A | 26EGBR1010A(海外) |
(YY, 地區, MMDD) 分桶遞增序號。同 prefix 超過 26 團極罕見(同地區同航空同日 27 團)。降級規則:序號 27 起改用雙字母(AA、AB…),由 app 層轉換函式處理(27 → 'AA')。唯一索引仍保證不撞。此為防呆,非主路徑;UI 不特別呈現。
^\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()。SELECT ... FROM departures WHERE group_code = $1(走唯一索引)。命中 → 導向該梯次所屬行程的 trip-edit(梯次 tab)或團控面板該梯次。WHERE group_code LIKE $1 || '%'(例輸入 26EGTK 列出該地區該航空所有團)。departure_id 後,經 order_lines → tour_booking_lines 列出該梯次所有 tour 訂單(團控面板既有能力,不新增 schema)。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[]>
放 src/app/(admin)/admin/trips/actions.ts(團號產生附在建梯次流程)與新檔 src/app/(admin)/admin/group-codes/actions.ts(對照表維護)。一律 zod 驗 input、requirePermission 守門、writeAuditLog 記錄。
權限體系(對齊現行 code):權限走 better-auth
createAccessControl(packages/core/src/permissions-ac.ts的statementcatalog +ac.newRole(...)),閘走auth.api.userHasPermission,無ROLE_PERMS物件。requirePermission('resource.action')以第一個.切 resource / action。本 spec 兩個權限:
departure.update:catalogstatement.departure已含["read","update"],op/admin已獲授 → 不用改 catalog(保留)。group_code.manage:碼表維護是 group-code 自己的子功能,用獨立 resourcegroup_code(不塞進泛tenant.settings,避免與 settings 頁其他租戶設定權限糾纏 / 撞名)。catalog 目前無group_coderesource → 需新增(見 §7 實作步驟)。
| Action | 權限 | 說明 | audit action |
|---|---|---|---|
generateDepartureGroupCodeAction | departure.update(catalog 已有) | 建/編梯次時產生團號 | departure.group_code.generate |
overrideDepartureGroupCodeAction | departure.update(catalog 已有) | OP 手動覆寫團號(唯一 + 格式驗證) | departure.group_code.override |
createRegionCodeAction / updateRegionCodeAction | group_code.manage(catalog 需新增,見 §7) | 維護地區碼 | region_code.create / region_code.update |
createAirlineCodeAction / updateAirlineCodeAction | group_code.manage(catalog 需新增) | 維護航空碼 | airline_code.create / airline_code.update |
對照表沒有 hard delete(碼可能被歷史團號引用,FK
onDelete: 'restrict')。停用 =is_active = false,UI 以「停用」呈現。updateaction 對「已被 departures 引用的碼」禁止改code(改碼會孤兒化),只允許改name/isActive/displayOrder。
沿用 packages/core/src/permissions-ac.ts 的 createAccessControl 模型(statement catalog + ac.newRole(...),閘走 auth.api.userHasPermission;無 ROLE_PERMS 物件)。
departure.update。catalog statement.departure 已有 "update",op 與 admin 已獲授;sales 無 —— 符合「線控管梯次、業務不碰」的分工。不用改 catalog。group_code.manage(不複用泛 tenant.settings,避免與 settings 頁其他租戶設定權限糾纏 / 撞名)。以 first-dot split:resource=group_code、action=manage。group_code.manage 實作步驟(packages/core/src/permissions-ac.ts):
statement catalog 新增 group_code resource:group_code: ["manage"](catalog 目前無 group_code resource,缺則一律 deny)。admin:admin role 由 ac.newRole({ ...statement(攤平), ...adminAc.statements }) 攤平整個 catalog 自動涵蓋——但仍須先完成步驟 1 的 catalog 註冊,admin 才會涵蓋(admin 不是字面 ['*'])。op 不授予 group_code.manage(避免亂建碼污染團號空間),但 op 能在建梯次時選用既有碼(走 departure.update,與碼表維護權限無關)。若未來要給
op維護碼表,再在oprole 顯式加group_code: ["manage"]。本 spec 設計階段:catalog 新增group_coderesource +manageaction 並授權 admin 是實作前必做(不是「無須加」)。
/admin/group-codes)/admin/group-codes,歸「系統 / 設定」類(跨梯次、非 per-departure,符合 CLAUDE.md 線控 UX 原則:設定類才開獨立 nav)。YY+地區+航空+MMDD+序號,附 26EGTK1010A 範例)。—)。團號 monospace。建梯次處加地區 / 航空下拉 + 團號預覽 + 自動產生 / 覆寫切換。互動規則:
YY+地區+航空+MMDD+序號(同地區同出發日自動 A/B/C)。—。新檔 tests/group-codes.test.ts:
next_group_code_suffix('26EGTK1010') N 次(Promise.all)→ 回傳值為 1..N 無重複(對齊 tryReserveSeats / order_number 的並發測試模式)。generateGroupCode 對 (EG, TK, 2026-10-10) 連兩次 → 26EGTK1010A、26EGTK1010B。setGroupCodeManual 轉成友善錯誤)。AA。清理用 TRUNCATE ... CASCADE(含 group_code_counter)。
| 區塊 | 影響 |
|---|---|
packages/core/src/schema/business.ts | 新增 regionCodes / airlineCodes;departures 加 3 欄 + 1 partial unique index。需 boolean(已 import)。 |
scripts/setup-business-extras.ts | 加 group_code_counter 表、next_group_code_suffix() function、seed 常用碼(idempotent)。對齊既有 next_order_number 慣例。 |
packages/core/src/catalog/trips-repo.ts | NewDepartureInput / DepartureRow / TripDetail.departures 加 groupCode / regionCode / airlineCode;createDeparture / 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.ts | 在 statement catalog 新增 group_code: ["manage"] resource 並授權 admin(admin 攤平 catalog 自動涵蓋,但須先註冊)。departure.update 已存在,不改。 |
| 線稿 | 對照表維護頁(新)、行程列表(改)、建梯次團號欄位群(見 apps/wireframes)。 |
與相鄰功能的協調點:
region_codes / airline_codes / group_code_counter 為全新表;departures 新增欄位 group_code / region_code / airline_code 與其他功能(定價的價格、優惠的 promo_price_twd)不重疊。新表:
| 表 | 欄位 | 型別 | 備註 |
|---|---|---|---|
region_codes | code | text PK | 地區碼,upper + ^[A-Z0-9]{2,3}$ check |
name | text not null | 中文名 | |
is_active | boolean not null default true | ||
display_order | integer not null default 0 | ||
created_at / updated_at | timestamptz | ||
airline_codes | code | text PK | 航空碼,同 region 的 check |
name | text not null | 航空名 | |
is_active | boolean not null default true | ||
display_order | integer not null default 0 | ||
created_at / updated_at | timestamptz | ||
group_code_counter | prefix | text PK | YY+地區+航空+MMDD,序號分桶 key(在 setup-business-extras,不在 drizzle schema) |
next_seq | integer not null default 1 | 下一個序號(1-based) |
departures 新增欄位:
| 欄位 | 型別 | 備註 |
|---|---|---|
group_code | text nullable | 完整團號 26EGTK1010A;partial unique index(non-null 唯一) |
region_code | text nullable | FK → region_codes.code,onDelete: restrict |
airline_code | text nullable | FK → airline_codes.code,onDelete: 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)。
動機(多租戶開放性):荒野用「年+地區+航空+月日+序號」,但別家旅行社慣例不同(有的用四位年、有的加分隔符、有的完全不放地區/航空)。故團號格式改為 per-tenant 可設定的 {token} 樣板,不寫死荒野特定 pattern。
{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 分隔符(-、/ …) | 原樣 |
{YYYY}{MM}{seq})。只有 region / airline 可加 ? 選填;日期/年一定有值、{seq} 必填不可選。parsePattern):括號平衡、token 白名單、恰一 {seq};違反回人話。group_code_pattern = NULL):{YY}{region}{airline?}{MMDD}{seq} —— 逐位元組重現現行海外 26EGTK1010A / 國內 26YS1010A(airline 空 → 省略段),零回歸。bucket key = pattern 渲染時把 {seq} 換成空字串(renderPrefix)。沿用既有 group_code_counter 分桶 + next_group_code_suffix(prefix)(race-safe 不動)。同一 tenant 同一 prefix 的序號單調遞增;不同 pattern 渲染出不同 prefix → 各自從頭起算(互不干擾)。
group_code_seq_style / group_code_seq_width)計數器永遠存整數序號;樣式只在渲染時套用(seqToStyled):
| style | 規則 | 例(seq 1 / 27) |
|---|---|---|
alpha(預設) | A–Z bijective base-26 | A / AA |
alpha_no_io | 同 bijective 但字母表跳過 I / O(24 字母) | A / AA(9→J) |
numeric | 補零至 group_code_seq_width(超寬不截斷) | 01 / 27(width 2) |
GROUP_CODE_RE(容歷史碼,不強制對照表碼存在)。deriveRegex(pattern, seqStyle) 衍生的 regex 驗(seq 樣式感知:numeric → \d+、alpha* → [A-Z]+)。GROUP_CODE_RE 是 sync 常數、pattern 是 async 取得,故驗證下沉到 server action / repo 層(overrideDepartureGroupCodeAction)。/admin/settings 「團號格式」tab(admin-only,閘 group_code.manage):pattern 輸入 + 序號樣式 Select + 補零寬度 + live preview(純函式引擎即時渲染假資料)+「改樣板不回寫既有團號」提示。updateGroupCodePatternAction(requirePermission + parsePattern 驗 + 前/後值 audit group_code.pattern.update)。packages/core/src/catalog/group-code-pattern.ts(零 DB / 零 server-only)供 server repo 與 client live-preview 共用同一份真相。generateGroupCode / bulk-import 讀 loadGroupCodePatternConfig(db) → renderPrefix 取 bucket → 計數器 → seqToStyled → renderGroupCode。bulk-import「缺地區→不產號」改 pattern-driven(pattern 含 {region} 才要求地區碼)。{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.ts(parsePattern / 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)。YY+地區+MMDD+序號),不用 sentinel 碼。departures.airline_code 對國內團寫 NULL。buildGroupCodePrefix / generateGroupCode / GROUP_CODE_RE / bulk-import / CLI 全放寬,airlineCode 已改 optional。group_code_seq_style 設定(alpha A–Z 預設 / alpha_no_io 跳過 I·O / numeric 數字補零)。seqToStyled 純函式吸收;不影響 schema / 計數器(計數器仍存整數序號,樣式只在渲染時套用)。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-29 | 初版 | — |
| 2 | 2026-05-30 | 整理:對齊現行 RBAC/欄位慣例 + 路徑搬移 | — |
| 3 | 2026-06-20 | 碼表維護權限由 tenant.settings 改為獨立 resource group_code.manage(避免與 settings 頁其他租戶設定權限糾纏/撞名)。 | — |
| 4 | 2026-06-23 | D1 航空碼改選填 + 國內變體:國內團為主軸(荒野主業國內高山團,無航空),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 未決定案。 | — |
| 5 | 2026-07-02 | airlineCode 改 optional 落地:buildGroupCodePrefix / generateGroupCode / GROUP_CODE_RE / bulk-import / CLI 全放寬,移除 sentinel XX,國內團省略航空段、departures.airline_code 寫 NULL;國內高山團 bulk-import 可正常產號(37 測試)。§3.4 對齊註與 §12 未決同步翻正為已落地。 | — |
| 6 | 2026-07-03 | tenant 級團號 pattern({token} DSL)落地:新增 §12「tenant 級團號 pattern(DSL)」(token 表 / bucket 語意 / 序號樣式 / 衍生 regex 驗證 / 設定 UI);純函式引擎 packages/core/src/catalog/group-code-pattern.ts(parsePattern / renderPrefix / renderGroupCode / deriveRegex / seqToStyled)取代兩份寫死真相;tenant_settings 加 group_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 兩未決定案。 | — |