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

帶團排班(Guide Roster)

一句話:跨梯次的領隊 / 協作月度排班引擎 —— 名冊本月載量彙總、梯次 × 領隊指派矩陣、tentative(預塞)↔ confirmed(正式)狀態機、以及「同一 guide 檔期重疊」fail-closed 不變式。單一梯次的指派 / 換領隊與月度排程總覽共用同一份 departure_guide_assignments 真相。

畫面(UI = wireframe):領隊 / 嚮導排班 · 跨梯次月度總覽

1. 目標 / 問題

荒野一個月開多個高山梯次,領隊 / 嚮導人力有限:同一個領隊不能在日期重疊的兩梯都帶團、單月帶團天數有體力上限、送籤前得先「預塞」估算載量、送籤後才正式確認並可能臨時換領隊。舊作法靠人腦 + 試算表排班,衝突(同人撞期)只能事後補救。

本檔描述:

  • 名冊載量視角/admin/guide-roster):每位 active 嚮導本月 confirmed 帶團天數 / 梯次數,派生「可帶 / 預塞中 / 已滿」載量狀態。
  • 梯次指派:per-departure 的領隊 / 協作指派(同一梯次同一 guide 唯一,換角色 = update)。
  • 狀態機:tentative(送籤前暫定)↔ confirmed(正式確認)。
  • fail-closed 檔期衝突不變式:任一 guide 不得在日期重疊的兩梯次同時 confirmed。

範圍departure_guide_assignments 指派表 + guides.roster_role / guides.max_days_per_month 排班欄 + 月度彙總 / 衝突偵測 repo + /admin/guide-roster 後台頁 + guide_roster 權限。

不做:嚮導本人的受限 login / dossier / 現場填報(guide-portal,confirmed 指派是其梯次 scoping 來源);嚮導建檔目錄 CRUD(guide-portal §2.4);領隊費 / 出款(payables(payee_type='guide')control-finance §5);範本人頭成本(指派變動觸發 recalculateDepartureTemplateCosts,見 cost-template §3.3,本檔只是觸發者)。

2. 資料模型

2.1 guides 排班欄(既有表,guides 主檔在 guide-portal §2.1

欄位型別說明
roster_roletext NOT NULL default 'both'排班職能:leader(可帶團)/ assistant(協作)/ both(兩者皆可)。CHECK guides_roster_role_valid
max_days_per_monthinteger nullable單月可帶團上限(天)。NULL = 不限。CHECK guides_max_days_positive(NULL 或 > 0)。載量派生用。
  • roster_role排班職能(能不能被排為 leader / assistant),與 departure_guide_assignments.role(該次實際擔任的角色)正交;與 guides.status(active / inactive 目錄狀態)也正交。
  • 嚮導僱用型態(guides.engagement_type,2026-07-02 落地):full_time(正式)/ partner(協作)/ external(外聘),CHECK 約束(決策 D2)。與 roster_role(排班職能)、status(active / inactive 目錄狀態)正交。

2.2 departure_guide_assignments(指派表)

per-departure 級別的領隊 / 協作指派;跨梯次月度視角與單一梯次指派共用此表(同一梯次同一 guide 唯一,換角色 = update role)。

欄位型別說明
iddga_<ulid>PK。
departure_idFK → departures.idonDelete: cascade梯次刪除連帶清指派。
guide_idFK → guides.idonDelete: restrict有指派的嚮導不得被刪(保排班完整)。
roletext NOT NULLleader / assistant。CHECK dga_role_valid
statustext NOT NULL default 'tentative'tentative(預塞)/ confirmed(正式)。CHECK dga_status_valid
confirmed_attimestamptz nullable首次轉 confirmed 時寫入(COALESCE 保留最早值)。
change_reasontext nullable換手 / 異動原因(稽核,換領隊時寫)。
notetext nullable備註。
created_at / updated_attimestamptz

索引 / 約束:UNIQUE(departure_id, guide_id)dga_departure_guide_unique,是 upsert 的衝突鍵)、index(guide_id)index(departure_id)

天數定義:一梯次帶團天數 = (return_date - departure_date) + 1(含頭尾兩端,與 wireframe「3 日」「5 日」一致)。

3. 邏輯 / 存取層

排班 repo(packages/core/src/roster/repo.ts)first arg 一律 db: ScopedDb;無 tenant_id(DB per tenant);audit 由 action 層寫。月份視窗以 monthRange(year, month) 派生 [firstDay, firstDayNextMonth)(半開區間,month 1-based)。

3.1 名冊 + 本月載量(listRosterGuides

active guides(includeInactive 可放寬)+ 指定月份(預設當前 UTC 年月)的彙總:

  • monthDays = SUM(return_date − departure_date + 1) over confirmed 指派、且該梯次 departure_date 落在月份視窗內。tentative 不計入帶團天數
  • monthTrips = 該月 confirmed 帶團梯次數。
  • hasTentative = 該月是否含 tentative 指派(BOOL_OR)。
  • availability(派生載量狀態):
    • fullmax_days_per_month != null && monthDays >= max_days_per_month
    • tentative:否則若 hasTentative
    • open:其餘。

載量上限只影響 availability 顯示,不硬擋指派(是否加硬性上限閘見 §5)。

3.2 當月梯次 + 已排指派(listMonthDepartures

當月(依 departure_date 落窗)出團梯次 JOIN trips 取 title,附掛各梯次所有指派(領隊在前、再依 guide 名排序)。預設排除 status = 'cancelled' 梯次(includeCancelled 可含)。攤平 days / capacity / booked_count 給 UI 排班矩陣。

3.3 指派狀態機(tentative ↔ confirmed)

assignGuide(upsert)INSERT ... ON CONFLICT (departure_id, guide_id) DO UPDATErole / status / notestatus 預設 tentative(送籤前預塞)。撞 UNIQUE 走 update = 同一 guide 換角色 / 升級狀態,不新增列。confirmed 時順手寫 confirmed_atCOALESCE 保留最早)。回傳實際落地 row id。

confirmAssignment:tentative → confirmed(confirmed_at = COALESCE(confirmed_at, NOW()))。不存在回 NOT_FOUND

removeAssignment:DELETE。不存在回 NOT_FOUND

changeLeader(一個 tx):移除舊領隊(若指定 fromGuideId)的 role='leader' 指派 → upsert 新領隊為 confirmed leader(撞 UNIQUE 走 update 並記 change_reason)。只動 leader 角色,assistant 指派不受影響;新領隊原本若是該梯 assistant,會被升任 leader(role→leaderstatus→confirmed)。

所有寫入路徑(assign / confirm / remove / changeLeader)在 tx 末尾呼叫 recalculateDepartureTemplateCostscost-template §3.3):confirmed 領隊 / 協作人頭變動會重算範本人頭成本行。

3.4 檔期衝突(fail-closed 不變式)

不變式(#11):任一 guide 不得在日期區間重疊的兩梯次同時持有 confirmed 指派。

  • queryConfirmedConflict(departureId, guideId):查該 guide 是否已有 confirmed 指派落在與 departureId 日期重疊(da.departure_date <= dx.return_date AND dx.departure_date <= da.return_date)的其他梯次。任何會使指派變 confirmed 的路徑(assignGuide status=confirmed、confirmAssignmentchangeLeader)都先查、有衝突回 { ok: false, reason: 'CONFLICT', conflict } 不寫入tentative 預塞不擋(僅 UI 警示,供送籤前估載量時故意允許重疊)。
  • findConfirmedConflict:公開包裝,供 dialog / preview 在送出前預查同一條件。
  • detectConflicts(year, month):批次偵測 —— 同一 guide 在日期重疊的兩梯次都 confirmed → 衝突對。自連結 a.id < b.id 去重(每對回一次);月窗以「至少一端梯次出發日落在月內」為界(避免漏掉跨月邊界重疊)。

4. UI 與權限

4.1 版面(/admin/guide-roster,獨立 nav)

跨梯次月度排班是跨梯次功能,故開獨立後台 nav(對照線控 UX:per-departure 批次作業才從 /admin/control 進,跨梯次的 dashboard / 排班才獨立 nav)。頁面 force-dynamic,月份視窗下拉(當月起前 3 後 9 個月):

  • KPI:本月出團數 / 已排領隊(含預塞)/ 待排 / 衝突數。
  • 排班矩陣RosterMatrix):列 = 領隊 / 嚮導,欄 = 當月梯次(依出發日),格子顯示帶哪團 + 已確認 / 預塞 / 衝突三態著色;末列「待排(無領隊)」;右欄本月載量(days / max + 團數,超載標紅)。
  • 領隊清單GuideListTable):姓名 / 職能 / 可帶路線(specialties)/ 本月帶團數 / 載量 / 檔期狀態(可帶 / 預塞中 / 已滿)+ 指派操作。
  • 指派 / 換領隊 DialogAssignDialog):選梯次 → 選領隊 → 標「正式確認 / 預塞」;送出前 findConfirmedConflict 預查提示,confirmed 衝突由 server fail-closed 擋下。

單一梯次的指派 / 換領隊亦可從 出團準備工作台/admin/control per-departure)操作,與本頁共用同一份 departure_guide_assignments 真相

4.2 權限(guide_roster

packages/core/src/permissions-ac.ts 註冊 guide_roster: ['read', 'manage']

action用途adminopsalesaccountantguide
guide_roster.read進排班總覽頁、看名冊載量 / 矩陣 / 衝突
guide_roster.manage指派 / 換領隊 / 確認 / 移除
  • 線控(op)掌排班;admin 攤平 catalog 自動涵蓋;sales / accountant / customer / guide 不授予。
  • 所有 mutation 經 requirePermission('guide_roster.manage');審計由 action 層寫(guide_roster.assign / .confirm / .remove / .change_leadertarget_type='departure_guide_assignment')。
  • confirmed 衝突以 ActionError 回使用者可讀訊息(含撞期梯次名 / 日期),引導改用「預塞」或先調整既有梯次。

5. 開放議題

  • 嚮導 type 維度(正式 / 協作 / 外聘) (已決 + 已落地,2026-07-02 D2):保留為一級概念,落 guides.engagement_typefull_time / partner / external,CHECK 約束),與 roster_role(排班職能)、status(目錄狀態)正交(見 §2.1)。
  • 載量硬性上限max_days_per_month 目前只驅動 availability 顯示(full 標示),不擋指派。是否要在 confirm 時對「超月上限」也 fail-closed(比照檔期衝突),或維持軟提示,待定。
  • 換領隊 / 確認通知 已實作(email-only):指派「成為 confirmed」時 enqueue 通知信給嚮導(guides.contact_email)—— confirmAssignment / assignGuide(status=confirmed) / changeLeader(新領隊)皆觸發,換領隊另通知被換下的舊領隊。走既有 emails outgoing_emails queue(專用 React Email 範本 guide_assignment_notice,非罐頭 message-template)。實作在 packages/core/src/roster/notify.tsnotifyGuideAssignment),四個寫入入口(後台 action + /api/v1/guide-roster/* + departure-prep)一致接線;best-effort(通知失敗不回滾指派)、無 email 靜默略過。tentative 預塞不寄。LINE 推播另案未做。
  • 協作(assistant)人數上限:一梯次可排多少 assistant、是否有上限,第一版不限。

Changelog

版本日期變更Commit
12026-07-01初版(三方對齊稽核 B9 回填,Code-Is-Truth):跨梯次帶團排班引擎已落地(packages/core/src/roster/repo.ts)+ 後台頁 /admin/guide-roster + 高擬真 wireframe,spec 原僅一行帶過。回填資料模型(departure_guide_assignments 指派表 + guides.roster_role / max_days_per_month 排班欄)、月度載量彙總(listRosterGuides)、指派狀態機(assignGuide / confirmAssignment / removeAssignment / changeLeader,tentative↔confirmed)、fail-closed 檔期衝突不變式queryConfirmedConflict:confirmed 重疊不得成立、tentative 不擋)、批次衝突偵測(detectConflicts)、guide_roster.read / .manage 權限(admin/op)。指派變動觸發 recalculateDepartureTemplateCosts已實作。
22026-07-03§5「換領隊 / 確認通知」落地(email-only):notifyGuideAssignmentpackages/core/src/roster/notify.ts)在指派成為 confirmed 時 enqueue guide_assignment_notice 給嚮導 contact_email,換領隊另通知舊領隊;後台 action + /api/v1/guide-roster/* + departure-prep 四入口一致接線,best-effort。LINE 推播另案未做。