一句話:以可手動輸入的「護照到期日」欄位,在團控、主管儀表板與 members 頁被動呈現「護照效期不足」警示;不是任何資料的金流 SSOT。護照欄位定義來源見 護照上傳與 OCR(OCR 本身 deferred)。
受 模組化 的「海外」開關 gate:租戶層
module_overseas+ 行程層trips.is_overseas皆開才顯示護照效期警示。
界定:本檔僅「護照效期」(海外團)。 入山證 / 入園證 / 山屋床位核准證 / 登山險的 deadline 警戒不在本檔 —— 那是出團行政期限,屬 departure-prep 的登山專屬子集(登山 gate)。本檔(海外 gate)與 departure-prep 的 deadline(登山 gate)是兩個不同模組、不重疊。
出國團要提早確認護照效期,避免臨出發才發現客人護照剩不到半年。
出發日 − 護照到期日 < threshold,無需影像、無需 OCR、無需加密基礎建設。passport_expiry_date 欄位 + 手動輸入 UI,未來 OCR 上線時自動把 OCR 抽到的效期寫進同一欄位即可(同欄位、零 migration 衝突)。departure.read / order.read / 主管視角)。查 packages/core/src/schema/business.ts 的 travelers table 與 scripts/setup-business-extras.ts:
travelers 的護照號碼現為 passportNoEnc(passport_no_enc jsonb,KMS envelope 加密;2026-06-11 PII 加密落地後非明文 passport_no text),且無任何 expiry / issue date 欄位。passport_expiry_date)全部尚未落地——未進任何 migration。因此不能假設效期欄位存在,必須自己落地最小子集。
護照 OCR spec 的資料模型規劃在 travelers 新增(節錄與效期相關者):
| 欄位語意 | 型別 | 本 spec 是否需要 |
|---|---|---|
passport_number(passport_number text) | text | 既有 passport_no 已涵蓋(命名待對齊,見下) |
passport_expiry_date | date | 需要(核心) |
passport_issue_date | date | 不需要 |
passport_name_surname / _given / _latin_full | text | 不需要 |
passport_nationality / _issuing_country | text | 不需要 |
passport_sex / passport_birth_date | date/text | 不需要 |
| OCR meta(confidence / mrz checksum / raw payload …) | 多種 | 不需要 |
只新增 1 個欄位:
// packages/core/src/schema/business.ts — travelers 表 append
/** 護照到期日(手動可輸入;未來 OCR 填同一欄)。nullable。 */
passportExpiryDate: date("passport_expiry_date"),
passport_expiry_date),使 OCR 上線時直接寫入同欄、無 migration 衝突、無資料搬遷。已拍板:
passport_expiry_date(date)是與 passport-ocr 共用的單一欄位名,OCR 上線寫入同欄、無 migration 衝突。
passport_no 維持不變且不改名(passport-ocr 也沿用 passport_no,不另立 passport_number)。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 表)。
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.ts 的 TenantSettingsRow / getTenantSettings 顯式欄位清單 + updateTenantSettings。對「已成行 / 銷售中的梯次」裡的每位旅客:
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_trip ∪ insufficient。團控 / 儀表板的「N 位證件效期不足」即這兩者人數加總(紅 + 橘)。departure_date 與 passport_expiry_date 皆為 SQL date(無時區的純日曆日)。next_order_number()(NOW() AT TIME ZONE 'Asia/Taipei')一致,避免 UTC 午夜造成跨日誤差。(passport_expiry_date - departure_date) 直接得整數天數;需要「今天」時用 (NOW() AT TIME ZONE 'Asia/Taipei')::date。passport_expiry_date 就納入計算;國內團旅客通常不填效期 → 維持 unknown → 不警示。這讓本功能不需依賴「行程是否出國」的旗標(目前 schema 無此欄位;trips.destination 是自由 text,不可靠)。/admin/control)團控既有「護照」欄已顯示上傳狀態(護照 OCR wireframe 預留)。本 spec 在同一欄疊加效期語意:
dep-meta:若該梯次有警示,加一個 badge「N 位證件效期不足」(紅底;N = 紅 + 橘人數)。expired_before_trip):「出發前已過期」insufficient):顯示到期年月,例「10/27 到期」(wireframe 已有此 pattern)/admin/control 進,不開獨立 nav)。/admin,admin / owner 視角)跨梯次彙總,只在主管視角顯示(對齊儀表板角色化視角;業務視角不顯示——這是營運面總覽):
/admin/members)重要前提(探查結論):members.html / packages/core/src/members/admin-repo.ts 的「會員」是同庫 user(role='customer' 訂購帳號),不直接持有護照欄位——護照在 travelers(綁 order,非綁 user)。一個 user 可能對應多筆 traveler、多本護照狀態。
因此 members 頁的效期色標語意是「這位會員名下(其訂單的旅客)是否有任何即將出團且效期不足的護照」:
departure_date >= 今天)且狀態為銷售中 / 成行的梯次對應的 traveler。(替代:若 members 頁 PII 邊界顧慮較重——護照 OCR spec 已註明 members 頁刻意不含 traveler PII——則此處只放「需注意」旗標、不顯示實際到期日,符合既有設計。本 spec 採此保守作法。)
新增 packages/core/src/documents/admin-repo.ts(或併入既有 control repo),全部 db: ScopedDb first-arg、server-only。
針對一批 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。thresholdDays 由 getTenantSettings(db).passportExpiryWarningDays 取得。團控名單本來就在查 travelers;只要 SELECT 多帶 t.passport_expiry_date 與 d.departure_date,在 server / view layer 算出 status(沿用 §4 邏輯),免得 N+1。
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 個月);可與團控篩選一致。對 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 → 顯示效期旗標
沿用既有 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_date | order.update(sales / op / admin 有) | traveler 隸屬 order,沿用既有 traveler 編輯權限 |
audit_log action 視既有 traveler 編輯是否已記錄而定(不另立新事件,沿用 traveler.update 之類既有 key;若無則本 spec 不強制新增——非高敏感變更)。badge--destructive(出發前已過期)badge--warning(效期不足 threshold;顯示到期年月 MM/YY 到期)badge--success(充足)badge--secondary / muted(未填效期)title tooltip 同時說明(例「已上傳 · 2027/10 到期(效期不足)」)。/admin/settings)新增一個 number input「護照效期提醒天數」,預設 180,存 tenant_settings.passport_expiry_warning_days(沿用既有 settings form / updateTenantSettings)。card + todo-row 結構(與「最新待付款」卡片同款),每列一個梯次。vitest(repo 層,對齊既有 tests/*.test.ts 慣例,用 TRUNCATE CASCADE 清資料):
expired_before_trip(紅)。status='cancelled' 的 order 之 traveler 不計入。tenant_settings.passport_expiry_warning_days 後彙總結果隨之變化。(NOW() AT TIME ZONE 'Asia/Taipei')::date 不因 UTC 而誤判。| 項目 | 描述 |
|---|---|
| Schema | drizzle business.ts 加 2 欄:travelers.passport_expiry_date date、tenant_settings.passport_expiry_warning_days integer NOT NULL DEFAULT 180(穩定業務欄位直接進 schema,不走 setup-extras ALTER) |
| Repo | 新 packages/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 上線後寫入同欄 |
passport_expiry_date 與 threshold 直接進 drizzle business.ts(對齊現行慣例:穩定業務欄位不走 setup-extras ALTER)。requires_passport flag)。passport_no → passport_number 改名:護照 OCR 未來若改名,需確認不影響本功能(本功能不碰號碼欄位,理論上無影響)。被動提醒(§4–§7)之上的排程主動通知。核心 spec 原將主動通知列為「不在範圍」(§2.1),此為後補的 email-only 層(LINE 另案)。
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。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),同旅客同梯次的臨界窗只寄一次、重跑 / 並發安全。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-29 | 初版 | — |
| 2 | 2026-05-30 | 整理:對齊現行 code + 路徑搬移 | — |
| 3 | 2026-06-20 | 對齊現行 code:§6 SQL 改走 order_lines→tour_booking_lines.departure_id(orders 外殼無 departure_id)、過濾改 booking_state(無 o.status/'paid');§3.1 現況更新為 passport_no_enc(KMS 加密);欄位落地統一為直接進 drizzle business.ts(不走 setup-extras) | — |
| 4 | 2026-06-23 | 開頭補界定:本檔僅護照效期(海外 gate);入山/入園/山屋/登山險 deadline 屬 departure-prep 登山專屬子集(登山 gate),兩者不重疊。 | — |
| 5 | 2026-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 另案」。 | — |