線稿索引所有 spec最後更新 2026-07-03
specs/document-expiry.md

證件效期提醒

一句話:以可手動輸入的「護照到期日」欄位,在團控、主管儀表板與 members 頁被動呈現「護照效期不足」警示;不是任何資料的金流 SSOT。護照欄位定義來源見 護照上傳與 OCR(OCR 本身 deferred)。

模組化 的「海外」開關 gate:租戶層 module_overseas + 行程層 trips.is_overseas 皆開才顯示護照效期警示。

界定:本檔僅「護照效期」(海外團)。 入山證 / 入園證 / 山屋床位核准證 / 登山險的 deadline 警戒不在本檔 —— 那是出團行政期限,屬 departure-prep 的登山專屬子集(登山 gate)。本檔(海外 gate)與 departure-prep 的 deadline(登山 gate)是兩個不同模組、不重疊。

出國團要提早確認護照效期,避免臨出發才發現客人護照剩不到半年。

1. 問題與動機

1.1 現況痛點

  1. 臨出發才發現效期不足:海外行程(北海道、紐西蘭等)多數國家要求入境時護照效期 ≥ 6 個月。目前系統沒有任何效期檢查,業務只能靠經驗逐一翻護照影本,常常在出團前一兩週才發現某位旅客護照快過期 → 緊急辦急件、甚至無法成行。
  2. 跨梯次無總覽:主管想知道「未來三個月所有出國團裡,有沒有人護照效期會出問題」,現在沒有任何地方看得到,只能逐團逐人查。
  3. 期望系統自動提醒,團控一看就知道哪幾位有問題,主管也能在儀表板看到整體狀況。

1.2 為什麼可獨立於護照 OCR 先做

  • 護照上傳 + OCR 整套 deferred(連動 KMS),但效期提醒只需要一個可手動輸入的「護照到期日」欄位。
  • 規則純粹是日期比較:出發日 − 護照到期日 < threshold,無需影像、無需 OCR、無需加密基礎建設。
  • 因此本功能可以獨立先上線:先落地一個 passport_expiry_date 欄位 + 手動輸入 UI,未來 OCR 上線時自動把 OCR 抽到的效期寫進同一欄位即可(同欄位、零 migration 衝突)。

2. 範圍與不在範圍

2.1 在範圍

  • 一個 traveler 層的「護照到期日」欄位(手動可輸入;未來 OCR 填同一欄)。
  • 一個 tenant 層可調整的 threshold 設定(預設 180 天)。
  • 效期計算邏輯(以 Asia/Taipei 為基準的日期比較)。
  • 三處呈現:
    1. 團控 per-梯次警示(梯次標頭 badge + 旅客名單「護照」欄色標)。
    2. 主管儀表板跨梯次提醒 widget。
    3. members / 旅客頁的效期色標。
  • server / repo 查詢(per-departure 彙總 + 跨梯次彙總)。
  • 角色權限(沿用既有 departure.read / order.read / 主管視角)。

2.2 不在範圍

  • Email / LINE 主動通知 Email 已落地(後補的主動通知層,見 §12);LINE 另案未做。本 spec 核心(§4–§7)仍是「進系統看得到」的被動提醒,主動通知建構於相同警示判準之上。
  • 護照 OCR / 影像上傳:屬護照 OCR spec 範圍,deferred。
  • 自動續辦 / 急件流程:只提醒,不處理辦證。
  • 簽證效期 / 其他證件:現階段只看護照。其他證件留待後續。
  • 目的地別的效期規則(有些國家要求效期 ≥ 入境後 6 個月、有些只要涵蓋停留期):現階段用單一全域 threshold,不做 per-國家規則表(列入未決)。
  • 以旅客生日 / 身分證做任何提醒:只看護照到期日。

3. 與護照 OCR 的欄位依賴

3.1 現況探查結論

packages/core/src/schema/business.tstravelers table 與 scripts/setup-business-extras.ts

  • travelers 的護照號碼現為 passportNoEncpassport_no_enc jsonb,KMS envelope 加密;2026-06-11 PII 加密落地後非明文 passport_no text),且無任何 expiry / issue date 欄位。
  • 護照 OCR spec 定義的 20+ 護照欄位(含 passport_expiry_date)全部尚未落地——未進任何 migration。

因此不能假設效期欄位存在,必須自己落地最小子集。

3.2 護照 OCR 完整護照欄位清單(定義來源,本 spec 只取子集)

護照 OCR spec 的資料模型規劃在 travelers 新增(節錄與效期相關者):

欄位語意型別本 spec 是否需要
passport_numberpassport_number texttext既有 passport_no 已涵蓋(命名待對齊,見下)
passport_expiry_datedate需要(核心)
passport_issue_datedate不需要
passport_name_surname / _given / _latin_fulltext不需要
passport_nationality / _issuing_countrytext不需要
passport_sex / passport_birth_datedate/text不需要
OCR meta(confidence / mrz checksum / raw payload …)多種不需要

3.3 本 spec 落地的最小欄位子集

只新增 1 個欄位:

// packages/core/src/schema/business.ts — travelers 表 append
/** 護照到期日(手動可輸入;未來 OCR 填同一欄)。nullable。 */
passportExpiryDate: date("passport_expiry_date"),
  • 欄位名與護照 OCR spec 一致passport_expiry_date),使 OCR 上線時直接寫入同欄、無 migration 衝突、無資料搬遷。

已拍板:passport_expiry_datedate)是與 passport-ocr 共用的單一欄位名,OCR 上線寫入同欄、無 migration 衝突。

  • nullable:旅客可能還沒提供護照、或國內團根本不需要。NULL = 「未知 / 不適用」,視為警示(見計算邏輯)。
  • 既有 passport_no 維持不變且不改名(passport-ocr 也沿用 passport_no,不另立 passport_number)。

3.4 落地位置決策

passport_expiry_date 是穩定業務欄位,直接進 packages/core/src/schema/business.ts(對齊現行慣例:S5 assignment 欄位已扶正進 schema,新穩定業務欄位一律不走 setup-extras ALTER;setup-business-extras.ts 只放 sequence / function / trigger / seed)。threshold 欄 tenant_settings.passport_expiry_warning_days 同理直接進 schema(tenant_settings 已是 drizzle 表)。

3.5 threshold 設定欄位(tenant_settings

tenant_settings 為 singleton row(id='singleton'),是 drizzle schema 表(assignment_mode 等欄已扶正進 schema)。新增:

// packages/core/src/schema/business.ts — tenant_settings 表 append
/** 護照效期提醒天數(≈ 6 個月)。 */
passportExpiryWarningDays: integer("passport_expiry_warning_days").notNull().default(180),
  • 型別 integer、單位「天」、NOT NULL DEFAULT 180(預設 180 天 ≈ 6 個月)。
  • 同樣進 business.ts(與 assignment_mode 同表),並擴充 packages/core/src/settings/admin-repo.tsTenantSettingsRow / getTenantSettings 顯式欄位清單 + updateTenantSettings

4. 提醒計算邏輯

4.1 核心規則

對「已成行 / 銷售中的梯次」裡的每位旅客:

days_remaining = passport_expiry_date − departure_date   (以天計)

狀態:
  expiry 為 NULL                          → unknown(灰;不算警示,但可選擇單獨統計「N 位未填效期」)
  passport_expiry_date < departure_date   → expired_before_trip(紅;出發時護照已過期,最嚴重)
  0 ≤ days_remaining < threshold           → insufficient(橘;效期不足 threshold)
  days_remaining ≥ threshold               → ok(綠)
  • threshold = tenant_settings.passport_expiry_warning_days(預設 180)。
  • 警示 = expired_before_tripinsufficient。團控 / 儀表板的「N 位證件效期不足」即這兩者人數加總(紅 + 橘)。

4.2 時區(Asia/Taipei)

  • departure_datepassport_expiry_date 皆為 SQL date(無時區的純日曆日)。
  • 兩個純 date 相減本身不涉及時區,但「今天」的判定(用於「距出發 N 天」「是否已過期」等衍生顯示)必須以 Asia/Taipei 為準,與既有 next_order_number()NOW() AT TIME ZONE 'Asia/Taipei')一致,避免 UTC 午夜造成跨日誤差。
  • 計算建議在 SQL 完成(見下),用 (passport_expiry_date - departure_date) 直接得整數天數;需要「今天」時用 (NOW() AT TIME ZONE 'Asia/Taipei')::date

4.3 只看出國團?

  • 現階段區分國內 / 國外團——只要 traveler 填了 passport_expiry_date 就納入計算;國內團旅客通常不填效期 → 維持 unknown → 不警示。這讓本功能不需依賴「行程是否出國」的旗標(目前 schema 無此欄位;trips.destination 是自由 text,不可靠)。
  • 「出國團才檢查」列入未決(需先有可靠的「需護照」旗標,可能來自行程欄位)。

5. 呈現位置(三處)

5.1 團控面板 per-梯次(/admin/control

團控既有「護照」欄已顯示上傳狀態(護照 OCR wireframe 預留)。本 spec 在同一欄疊加效期語意:

  • 梯次標頭 dep-meta:若該梯次有警示,加一個 badge「N 位證件效期不足」(紅底;N = 紅 + 橘人數)。
  • 旅客名單「護照」欄 cell:
    • 紅(expired_before_trip):「出發前已過期」
    • 橘(insufficient):顯示到期年月,例「10/27 到期」(wireframe 已有此 pattern)
    • 綠(ok):✓
    • 灰(unknown):「未填效期」/ 與「未上傳」並存(效期狀態與上傳狀態是兩個維度,cell 以最嚴重者著色)
  • 主入口仍是團控(對齊 CLAUDE.md 線控 UX 原則:per-departure 批次從 /admin/control 進,不開獨立 nav)。

5.2 主管儀表板 widget(/admin,admin / owner 視角)

跨梯次彙總,只在主管視角顯示(對齊儀表板角色化視角;業務視角不顯示——這是營運面總覽):

  • 一張卡片「證件效期提醒」,列出未來區間內(預設未來 90 天出發、可與梯次範圍一致)有警示的梯次,每列:梯次名 + 出發日 + 「N 位效期不足」+ 連到該梯次團控的連結。
  • 空狀態:「未來梯次證件效期皆正常 ✓」。

5.3 members / 旅客頁色標(/admin/members

重要前提(探查結論):members.html / packages/core/src/members/admin-repo.ts 的「會員」是同庫 userrole='customer' 訂購帳號),不直接持有護照欄位——護照在 travelers(綁 order,非綁 user)。一個 user 可能對應多筆 traveler、多本護照狀態。

因此 members 頁的效期色標語意是「這位會員名下(其訂單的旅客)是否有任何即將出團且效期不足的護照」:

  • members 列表 status 欄區(或新增一個小 icon)顯示護照效期旗標:
    • 紅 / 橘點:該會員有「即將出發(未來梯次)+ 效期不足」的 traveler。
    • 無標記:無問題或無相關 traveler。
  • 為避免歷史資料干擾,只統計未來(departure_date >= 今天)且狀態為銷售中 / 成行的梯次對應的 traveler。
  • 旅客個別護照效期的精確值仍以團控 / order detail 為準;members 頁只做「需注意」提示色標,點進去到該會員訂單。

(替代:若 members 頁 PII 邊界顧慮較重——護照 OCR spec 已註明 members 頁刻意不含 traveler PII——則此處只放「需注意」旗標、不顯示實際到期日,符合既有設計。本 spec 採此保守作法。)

6. Server / Repo 查詢

新增 packages/core/src/documents/admin-repo.ts(或併入既有 control repo),全部 db: ScopedDb first-arg、server-only

6.1 per-departure 彙總(團控用)

針對一批 departureId(或團控當前篩選的梯次),回傳每梯次的效期統計:

SELECT
  d.id AS departure_id,
  COUNT(*) FILTER (
    WHERE t.passport_expiry_date IS NOT NULL
      AND t.passport_expiry_date < d.departure_date
  )::int AS expired_count,                         -- 紅
  COUNT(*) FILTER (
    WHERE t.passport_expiry_date IS NOT NULL
      AND t.passport_expiry_date >= d.departure_date
      AND (t.passport_expiry_date - d.departure_date) < ${thresholdDays}
  )::int AS insufficient_count,                     -- 橘
  COUNT(*) FILTER (WHERE t.passport_expiry_date IS NULL)::int AS unknown_count
FROM departures d
JOIN tour_booking_lines tb ON tb.departure_id = d.id
JOIN order_lines ol        ON ol.id = tb.order_line_id
JOIN orders o              ON o.id = ol.order_id
JOIN travelers t           ON t.order_id = o.id
WHERE d.id = ANY(${departureIds})
  AND o.booking_state IN ('pending_payment', 'confirmed', 'completed')  -- 排除 cancelled
GROUP BY d.id
  • warningCount = expired_count + insufficient_count
  • thresholdDaysgetTenantSettings(db).passportExpiryWarningDays 取得。

6.2 per-traveler 明細(團控名單 cell 著色)

團控名單本來就在查 travelers;只要 SELECT 多帶 t.passport_expiry_dated.departure_date,在 server / view layer 算出 status(沿用 §4 邏輯),免得 N+1。

6.3 跨梯次彙總(儀表板 widget)

SELECT
  d.id, d.departure_date, tr.title AS trip_title,
  COUNT(*) FILTER (
    WHERE t.passport_expiry_date IS NOT NULL
      AND (t.passport_expiry_date - d.departure_date) < ${thresholdDays}
  )::int AS warning_count
FROM departures d
JOIN trips tr ON tr.id = d.trip_id
JOIN tour_booking_lines tb ON tb.departure_id = d.id
JOIN order_lines ol        ON ol.id = tb.order_line_id
JOIN orders o              ON o.id = ol.order_id
JOIN travelers t           ON t.order_id = o.id
WHERE d.departure_date >= (NOW() AT TIME ZONE 'Asia/Taipei')::date
  AND d.departure_date <  (NOW() AT TIME ZONE 'Asia/Taipei')::date + ${horizonDays}
  AND d.status IN ('selling', 'closed')
  AND o.booking_state IN ('pending_payment', 'confirmed', 'completed')
GROUP BY d.id, d.departure_date, tr.title
HAVING COUNT(*) FILTER (
    WHERE t.passport_expiry_date IS NOT NULL
      AND (t.passport_expiry_date - d.departure_date) < ${thresholdDays}
  ) > 0
ORDER BY d.departure_date ASC
  • horizonDays 預設 90(widget 看未來 3 個月);可與團控篩選一致。

6.4 members 旗標(members 列表用)

對 members 列表的每位 user,判斷是否存在「未來梯次 + 效期不足」的 traveler。可用 EXISTS 子查詢或 LATERAL,避免破壞既有 listMembers 的分頁聚合:

LEFT JOIN LATERAL (
  SELECT 1
  FROM orders o
  JOIN order_lines ol        ON ol.order_id = o.id
  JOIN tour_booking_lines tb ON tb.order_line_id = ol.id
  JOIN departures d          ON d.id = tb.departure_id
  JOIN travelers t           ON t.order_id = o.id
  WHERE o.user_id = u.id
    AND d.departure_date >= (NOW() AT TIME ZONE 'Asia/Taipei')::date
    AND t.passport_expiry_date IS NOT NULL
    AND (t.passport_expiry_date - d.departure_date) < ${thresholdDays}
  LIMIT 1
) pe ON true
-- pe IS NOT NULL → 顯示效期旗標

7. 角色權限

沿用既有 packages/core/src/permissions.ts,不新增 permission:

視圖gate說明
團控 per-梯次效期警示departure.read(admin / op 有)線控本來就需要此權限進團控
主管儀表板 widget主管視角(admin / owner)跨梯次營運總覽;sales 視角不顯示(對齊儀表板角色化)
members 效期旗標member.read(admin / sales / op / accountant 有)與 members 列表同權限
編輯 traveler passport_expiry_dateorder.update(sales / op / admin 有)traveler 隸屬 order,沿用既有 traveler 編輯權限
  • 手動輸入效期 = 編輯 traveler 欄位的一部分,沿用既有 traveler PII 編輯路徑與權限(不需新 endpoint,整合進既有 traveler 編輯 form / action)。
  • audit:traveler 欄位編輯若既有路徑已寫 audit,效期欄位一併涵蓋;新增 audit_log action 視既有 traveler 編輯是否已記錄而定(不另立新事件,沿用 traveler.update 之類既有 key;若無則本 spec 不強制新增——非高敏感變更)。

8. UI 規範要點

  • 色階沿用既有 admin token / badge 慣例:
    • 紅 = badge--destructive(出發前已過期)
    • 橘 = badge--warning(效期不足 threshold;顯示到期年月 MM/YY 到期
    • 綠 = badge--success(充足)
    • 灰 = badge--secondary / muted(未填效期)
  • 團控「護照」欄是雙維度(上傳狀態 × 效期狀態)。現行規則:cell 著色取兩者中最需注意者;title tooltip 同時說明(例「已上傳 · 2027/10 到期(效期不足)」)。
  • 設定頁(/admin/settings)新增一個 number input「護照效期提醒天數」,預設 180,存 tenant_settings.passport_expiry_warning_days(沿用既有 settings form / updateTenantSettings)。
  • 儀表板 widget 用既有 card + todo-row 結構(與「最新待付款」卡片同款),每列一個梯次。

9. 測試

vitest(repo 層,對齊既有 tests/*.test.ts 慣例,用 TRUNCATE CASCADE 清資料):

  1. threshold 邊界:到期日剛好 = 出發日 + threshold(應為 ok);= 出發日 + threshold − 1(應為 insufficient)。
  2. 已過期:到期日 < 出發日 → expired_before_trip(紅)。
  3. NULL 效期:不計入警示,計入 unknown。
  4. per-departure 彙總:混合多位旅客(紅 / 橘 / 綠 / 灰)→ warningCount 正確、各類人數正確。
  5. 取消訂單排除status='cancelled' 的 order 之 traveler 不計入。
  6. 跨梯次 widget:只回傳未來區間內、有警示的梯次;無警示梯次不出現;過去梯次排除。
  7. threshold 可調:改 tenant_settings.passport_expiry_warning_days 後彙總結果隨之變化。
  8. 時區:到期日 = 「Asia/Taipei 今天」邊界附近,驗證 (NOW() AT TIME ZONE 'Asia/Taipei')::date 不因 UTC 而誤判。

10. 影響範圍

項目描述
Schemadrizzle business.ts 加 2 欄:travelers.passport_expiry_date datetenant_settings.passport_expiry_warning_days integer NOT NULL DEFAULT 180(穩定業務欄位直接進 schema,不走 setup-extras ALTER)
Repopackages/core/src/documents/admin-repo.ts(per-departure / 跨梯次 / members 旗標查詢);擴 packages/core/src/settings/admin-repo.ts(threshold 欄位讀寫);擴 control / members repo 帶出 expiry 欄位
Server actions設定頁 threshold 更新(沿用既有 settings action);traveler 編輯帶入 expiry(沿用既有 traveler 編輯路徑)
UI/admin/control(per-梯次 + 名單 cell 色標)、/admin(主管 widget)、/admin/members(旗標)、/admin/settings(threshold input)、traveler 編輯表單(效期欄位)
Permissions無新增(沿用 departure.read / member.read / order.update + 主管視角)
Audit不新增事件(沿用既有 traveler 編輯 audit,如有)
依賴護照 OCR:只共用 passport_expiry_date 欄位名;本功能可獨立先上,OCR 上線後寫入同欄

11. 未決事項

  1. 欄位扶正位置 已定passport_expiry_date 與 threshold 直接進 drizzle business.ts(對齊現行慣例:穩定業務欄位不走 setup-extras ALTER)。
  2. 出國團才檢查:現階段用「有填效期就檢查」。若要「只有出國團強制檢查 + 提示未填」,需先有可靠的「需護照」旗標——可能來自行程欄位(route / trip 層 requires_passport flag)。
  3. per-國家效期規則:不同國家入境效期要求不同(6 個月 vs 涵蓋停留期)。現階段單一全域 threshold;未來可做 per-目的地規則表。
  4. members 頁是否顯示實際到期日:本 spec 採保守(只放需注意旗標,不顯示日期值,符合「members 不含 traveler PII」邊界)。是否要顯示到期日待決。
  5. 儀表板 widget horizon:預設未來 90 天;是否與團控篩選範圍綁定或獨立設定,待決。
  6. passport_nopassport_number 改名:護照 OCR 未來若改名,需確認不影響本功能(本功能不碰號碼欄位,理論上無影響)。

12. 主動通知 job(email-only,後補層)

被動提醒(§4–§7)之上的排程主動通知。核心 spec 原將主動通知列為「不在範圍」(§2.1),此為後補的 email-only 層(LINE 另案)。

  • 排程:Cloud Scheduler 每日 09:00 Asia/Taipei → POST /api/jobs/notify-document-expiry(X-Job-Token 閘,比照 flush-emails / sync-bot-rates)→ notifyExpiryWarningsAsSystem 逐 active 租戶 notifyExpiryWarningsForTenant。實作 packages/core/src/documents/notify.ts + scheduler.tf notify_document_expiry job。
  • 警示判準:與被動提醒完全一致(§4.1)—— passport_expiry_date 非 NULL 且 (expiry − departure) < threshold(含出發前已過期);只掃未來 [今天, 今天 + horizonDays)(預設 90)出發、selling/closed、非取消訂單的梯次。
  • 收件人:訂單負責業務orders.assigned_to_user_id,退回 created_by_user_id),非旅客本人 —— §1.1 痛點的行動者是業務。無負責業務 email 者跳過不 claim(待日後指派後的下一輪 job 再通知)。以專用 React Email 範本 document_expiry_notice 走既有 outgoing_emails queue。
  • 冪等鍵document_expiry_notifications(traveler_id, departure_id)(UNIQUE)。per-row transaction 內先 INSERT ... ON CONFLICT DO NOTHING RETURNING(claim)成功才 enqueue(claim + enqueue 同 tx),同旅客同梯次的臨界窗只寄一次、重跑 / 並發安全。

Linked

Changelog

版本日期變更Commit
12026-05-29初版
22026-05-30整理:對齊現行 code + 路徑搬移
32026-06-20對齊現行 code:§6 SQL 改走 order_linestour_booking_lines.departure_id(orders 外殼無 departure_id)、過濾改 booking_state(無 o.status/'paid');§3.1 現況更新為 passport_no_enc(KMS 加密);欄位落地統一為直接進 drizzle business.ts(不走 setup-extras)
42026-06-23開頭補界定:本檔僅護照效期(海外 gate);入山/入園/山屋/登山險 deadline 屬 departure-prep 登山專屬子集(登山 gate),兩者不重疊。
52026-07-03新增 §12 主動通知 job(email-only):每日排程掃臨界證件 → enqueue document_expiry_notice 給訂單負責業務;冪等鍵 document_expiry_notifications(traveler_id, departure_id)(claim-then-enqueue 同 tx)。§2.1 主動通知由「不在範圍」改為「Email 已落地、LINE 另案」。