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

旅客資料驗證

一句話:旅客名單建檔 / 編輯時,逐人檢查資料完整與正確(身分證 checksum、生日合理性、緊急聯絡人邏輯、金額一致),把錯誤即時標出;規則還原自現行 GS「內表」的單一巨型驗證公式(見 legacy-gs-gap §2.3),驗證為讀時計算、不落 DB。

畫面(UI = wireframe,本功能為共用畫面上的 delta):order-detail(旅客卡完整度 + 金額檢查)· control(名單資料狀態)

  • 關聯金流模型(金額一致性用 order_charge_lines / payment_schedules)· 行程定價 / 內容(價格政策)· 團控分房travelers.gender 由其新增)· 證件效期提醒(同為 traveler 欄位品質)
  • 對應財務:模組 1「改行程 / 退改在各內表人工操作客人資料,財務端有失誤風險,建議改善」。

1. 問題與動機

旅客 PII(姓名 / 身分證 / 護照 / 生日 / 緊急聯絡人)目前在 ERP travelers 表純自由輸入,無任何驗證。現行 GS「內表」靠一條巨型公式逐人把關,吐出十多種錯誤(身分證錯誤、生日未來人、緊急聯絡人是隊員、手機與緊連重複、收款差…)。痛點:

  1. ERP 無對應驗證 → 髒資料直接進庫;送入山申請 / 名冊時才發現格式錯,臨時補。
  2. 身分證 / 生日打錯無人擋 → 入山證、保險、名冊連帶錯。
  3. 金額對不起來(訂金 + 尾款 ≠ 應付)財務端才抓到。

解法:把 GS 內表的驗證規則落成 ERP 的 traveler 驗證模組,建檔 / 編輯即時顯示問題,團控名單用「資料完整度」欄一眼看出哪幾位要補。非阻擋式(沿用 GS:只標記、不擋存,讓 OP 能分次補資料),但出團前置檢核會把未解決者標紅。


2. 範圍

2.1 在範圍內

  • 驗證規則模組:純函式 validateTraveler(traveler, context) → 回問題清單(每項:欄位 + 等級 + 訊息)。
  • 台灣身分證 checksum + 生日合理性 + 緊急聯絡人邏輯(≠ 隊員、≠ 本人手機)+ 必填空值 檢查。
  • 金額一致性(order 層):SUM(payment_schedules 各節點目標額) == SUM(current order_charge_lines)(收款計畫總額 == 當前應收總額)。應收真相在 order_charge_lines、收款節點在 payment_schedules(見 金流模型)——不再讀已從 orders 外殼移除的 total_twd / deposit_due_twd / balance_due_twd
  • 輸入輔助:民國 ⇄ 西元生日轉換 helper(貼上「114.5.30」「民國 114 年」自動轉西元)。
  • 補欄位travelers.address(入山名冊需要;現 schema 無)。
  • UI 呈現:traveler 編輯表單即時驗證;團控名單「資料完整度」欄 / badge。
  • 免驗標記:領隊 / 嚮導 / 佔位列可標 validation_exempt(對齊 GS 的 ECPTLIST 免驗名單)。

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

  • 阻擋存檔:驗證不擋 save(可分次補)。出團前的「必須補齊」由 出團前置 檢核呈現,本 spec 只產生問題清單。
  • 護照欄位 OCR / 效期:屬 護照 OCR(deferred)/ 證件效期。本 spec 不碰護照效期規則。
  • 外國旅客(無台灣身分證):身分證 checksum 僅在 id_number 有值且看似台灣格式時套用;有護照無身分證者跳過 ID 檢查。
  • gender 欄位:由 團控分房 擁有 travelers.gender;本 spec 驗「性別空 / 性別值非法」,不負責建欄(依賴關係見 §3.1)。
  • 自動修正:只標問題,不自動改資料(生日轉換是輸入輔助,由使用者確認後寫入)。

3. 資料模型

對齊現行慣例:穩定欄位直接進 packages/core/src/schema/business.ts

3.1 travelers 補欄位(drizzle schema)

// packages/core/src/schema/business.ts — travelers 表 append
/** 通訊地址(入山人員名冊需要)。nullable。 */
address: text("address"),
/** 免驗標記:領隊 / 嚮導 / 佔位列等不需通過旅客驗證(對齊 GS ECPTLIST)。 */
validationExempt: boolean("validation_exempt").notNull().default(false),

依賴travelers.gender團控分房 擁有。本 spec 只定義驗證規則,不重複定義分房資料模型。

不新增驗證結果欄:驗證是讀時計算(view-time derived),不落 DB(對齊證件效期「不依賴排程、查詢時算」的精神)。要看哪些單有問題 → 查詢時跑 validateTraveler


4. 驗證規則(純函式,還原 GS 內表)

packages/core/src/travelers/validation.ts不碰 db

export type IssueLevel = 'error' | 'warning';
export interface TravelerIssue {
  field: string; // 'id_number' | 'birth_date' | 'emergency_contact_phone' | ...
  level: IssueLevel;
  message: string; // 「身分證檢查碼錯誤」「緊急聯絡人為同團隊員」
}

export function validateTraveler(
  t: TravelerInput,
  ctx: {
    partyTravelers: Array<{ fullName: string; phone: string | null }>; // 同訂單其他旅客(緊連=隊員檢查)
    genderEnabled: boolean; // travelers.gender 是否已上線
  },
): TravelerIssue[];

4.1 規則表(對照 GS)

規則等級條件GS 來源
姓名空errorfullName姓名空
性別空 / 非法error(genderEnabled)gender 空 或 ∉ {male, female, other}性別空 / 性別錯誤
生日空warningbirthDate生日空
生日未來errorbirthDate > today生日錯誤(未來人?)
生日過老warning年齡 > 100生日錯誤(超級老)
身分證空warningidNumberpassportNo 皆空身分證空
身分證檢查碼錯erroridNumber 看似台灣格式但 checksum 不過(§4.2)身分證錯誤
電話空warningphone電話空
信箱空warningemail信箱空
地址空warningaddress地址空
緊急聯絡人姓名空warningemergencyContactName緊急聯絡人姓名空
緊急聯絡人電話空warningemergencyContactPhone緊急聯絡人電話空
緊急聯絡人為隊員erroremergencyContactName 命中同訂單其他 traveler 姓名緊急聯絡人為隊員
手機與緊連重複error正規化後 phone == emergencyContactPhone手機與緊連人重複
  • validationExempt=true → 直接回 [](免驗)。
  • 正規化電話:去除 -、空白後比對(同 GS REGEXREPLACE(...,"[- ]"))。

4.2 台灣身分證 checksum(還原 GS)

/** 台灣身分證驗證:1 英文 + 9 數字 + 檢查碼。還原 GS 的字母轉碼 + 權重 mod 10。 */
export function isValidTwId(id: string): boolean {
  // 字母轉雙位數(A=10 … 對照表 "ABCDEFGHJKLMNPQRSTUVXYWZIO" 之序 + 10),
  // 拆兩位 → 套權重 [1,9,8,7,6,5,4,3,2,1,1] → SUM mod 10 == 0。
}

對「看似台灣身分證」(/^[A-Z][0-9]{9}$/)才驗;純護照客(無 id_number)不套此規則。

4.3 金額一致性(order 層,非 per-traveler)

/** 收款計畫總額 == 當前應收總額。不一致 → error「收款金額不符」。
 *  金額來自金流模型:應收 = SUM(current order_charge_lines.amount_twd)、收款節點 = SUM(payment_schedules)。 */
export function validateOrderAmounts(o: {
  receivableTwd: number; // SUM(current order_charge_lines.amount_twd)
  scheduledTwd: number; // SUM(payment_schedules 各節點目標額)
}): TravelerIssue | null;

對齊 GS「收款差 = 集體應付 −(已排定收款)」。本檢查掛 order,不掛單一 traveler;金額來源為金流模型的 order_charge_lines(應收)與 payment_schedules(收款計畫),非已從 orders 外殼移除的 total_twd / deposit_due_twd / balance_due_twd(見 金流模型)。

4.4 生日轉換輸入輔助

/** 民國 / 西元混填 → 西元 YYYY/M/D。'114.5.30'、'民國114年5月30日'、'2025-05-30' 皆可。 */
export function normalizeBirthDate(raw: string): string | null;

還原 GS「正確資料」分頁的民國 / 西元轉換。前端輸入框 onBlur 呼叫,轉好填回,由使用者確認再存(不靜默改)。


5. 流程與呈現

  • traveler 編輯表單(order-detail / 旅客編輯):欄位 onBlur / onChange 跑 validateTraveler,即時在欄位下顯示 issue(error 紅、warning 橘)。normalizeBirthDate 掛生日欄輸入輔助。存檔不擋(warning 與 error 都只提示;error 可在送出時二次確認「仍有 N 項錯誤,確定儲存?」)。
  • 團控名單 /admin/control:每位旅客一個「資料」狀態:✓(無 issue)/ 橘(僅 warning)/ 紅(有 error)+ tooltip 列出問題。梯次標頭彙總「N 位資料待補」。
  • order-detail:訂單層「資料完整度」摘要 + 金額一致性(§4.3)紅標。
  • 主入口仍是團控(對齊線控 UX:per-departure 批次從 /admin/control 進)。

6. Server / Repo

packages/core/src/travelers/validation.ts(純函式)+ 既有 traveler repo 擴充查詢帶出彙總。

// 團控 / order 用:一批 traveler + 同訂單上下文 → 每人 issues + 訂單金額 issue
getTravelerValidationForOrder(db, orderId): Promise<{
  travelers: Array<{ travelerId: string; issues: TravelerIssue[] }>;
  amountIssue: TravelerIssue | null;
}>

getDepartureValidationSummary(db, departureId): Promise<
  Array<{ orderId: string; errorCount: number; warningCount: number }>
>
  • 編輯 traveler 沿用既有 traveler 編輯 action(order.update 權限),不新增 permission;驗證在 server action 內附帶回傳 issues(前端顯示)。
  • 無新 audit 事件(沿用既有 traveler 編輯 audit)。

7. 角色權限

不新增 permission。沿用:

視圖gate
編輯 traveler(含驗證提示)order.update(sales / op / admin)
團控資料完整度欄departure.read(admin / op)
order-detail 完整度order.read(依既有訂單可見範圍)

8. 測試

vitest(純函式為主,對齊既有 tests/*.test.ts):

  • isValidTwId:正確 ID 通過;錯檢查碼擋;非台灣格式(護照)跳過。
  • validateTraveler:各規則觸發(空值、未來生日、>100 歲、緊連=隊員、手機=緊連);validationExempt → 空清單;genderEnabled=false 時不檢性別。
  • normalizeBirthDate:民國 114.5.30民國114年、西元 2025-05-30、亂格式 → null。
  • validateOrderAmounts:訂金+尾款=應收 → null;不符 → error。
  • getTravelerValidationForOrder / getDepartureValidationSummary:彙總 error / warning 數正確;exempt 不計。

(UI 即時驗證可選一條 Playwright:建錯資料 traveler → 團控顯示紅標 + tooltip。)


9. 影響範圍

類別數量細節
travelers 新欄2addressvalidation_exempt(drizzle;與 room-assignment 的 gender 同 PR 建議)
新純函式模組1packages/core/src/travelers/validation.ts(規則 + checksum + 日期轉換)
repo 擴充1traveler / 團控查詢帶出 issue 彙總
改既有 page2-3traveler 編輯表單 · /admin/control · order-detail
Permission0沿用 order.update / departure.read
測試unit 為主~12 vitest(純函式好測)

不影響:schema 大改(只加 2 欄);不擋存檔;不依賴排程。


10. 未決事項

| 項目 | 預設 | 待釐清 | | ----------------------------------------- | ------------------------------------------------------------ | ---------------------------------- | ------------ | | error 是否在出團前轉「必須補齊」硬閘 | 本 spec 不擋;出團前置 呈現「未補齊」 | 出團前要不要擋出名冊 | | 身分證以外的證號驗證(居留證 / 護照格式) | 只驗台灣身分證 checksum | 居留證 checksum 是否要做 | | gender 值域 | 'M' | 'F'(依 room-assignment) | 是否需第三值 | | 金額一致性掛點 | order 層,金流模型 order_charge_lines / payment_schedules | 金流模型已落地,規則可直接套用(不再依賴已移除的 orders 外殼欄) | | 民國年起算 | +1911 | 邊界(民國 100 以下 2 碼)解析規則 |


11. 連結

  • 來源:legacy-gs-gap §2.3(GS 內表驗證公式還原)
  • 依賴 / 協調:團控分房travelers.gender)、行程定價(金額欄位)、證件效期 / 護照 OCR(護照欄位品質,分屬各 spec)
  • 既有接點:travelers 表、traveler 編輯 action(order.update)、/admin/control 名單

Changelog

版本日期變更Commit
12026-05-30初版:GS 內表驗證規則落 ERP(checksum / 緊連邏輯 / 金額一致 / 民國轉換)
22026-06-20金額一致性對齊金流模型 v2:改以 order_charge_lines(應收)+ payment_schedules(收款計畫),移除對已從 orders 外殼移除的 total_twd / deposit_due_twd / balance_due_twd 引用
32026-07-01Code-is-truth:§4.1 gender 值域由 {M,F} 更正為 male | female | other(對齊 packages/core/src/travelers/validation.tsVALID_GENDERS 與 schema CHECK)。