一句話:跨梯次的領隊 / 協作月度排班引擎 —— 名冊本月載量彙總、梯次 × 領隊指派矩陣、tentative(預塞)↔ confirmed(正式)狀態機、以及「同一 guide 檔期重疊」fail-closed 不變式。單一梯次的指派 / 換領隊與月度排程總覽共用同一份
departure_guide_assignments真相。畫面(UI = wireframe):
領隊 / 嚮導排班 · 跨梯次月度總覽
荒野一個月開多個高山梯次,領隊 / 嚮導人力有限:同一個領隊不能在日期重疊的兩梯都帶團、單月帶團天數有體力上限、送籤前得先「預塞」估算載量、送籤後才正式確認並可能臨時換領隊。舊作法靠人腦 + 試算表排班,衝突(同人撞期)只能事後補救。
本檔描述:
/admin/guide-roster):每位 active 嚮導本月 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,本檔只是觸發者)。
guides 排班欄(既有表,guides 主檔在 guide-portal §2.1)| 欄位 | 型別 | 說明 |
|---|---|---|
roster_role | text NOT NULL default 'both' | 排班職能:leader(可帶團)/ assistant(協作)/ both(兩者皆可)。CHECK guides_roster_role_valid。 |
max_days_per_month | integer 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 目錄狀態)正交。departure_guide_assignments(指派表)per-departure 級別的領隊 / 協作指派;跨梯次月度視角與單一梯次指派共用此表(同一梯次同一 guide 唯一,換角色 = update role)。
| 欄位 | 型別 | 說明 |
|---|---|---|
id | dga_<ulid> | PK。 |
departure_id | FK → departures.id(onDelete: cascade) | 梯次刪除連帶清指派。 |
guide_id | FK → guides.id(onDelete: restrict) | 有指派的嚮導不得被刪(保排班完整)。 |
role | text NOT NULL | leader / assistant。CHECK dga_role_valid。 |
status | text NOT NULL default 'tentative' | tentative(預塞)/ confirmed(正式)。CHECK dga_status_valid。 |
confirmed_at | timestamptz nullable | 首次轉 confirmed 時寫入(COALESCE 保留最早值)。 |
change_reason | text nullable | 換手 / 異動原因(稽核,換領隊時寫)。 |
note | text nullable | 備註。 |
created_at / updated_at | timestamptz |
索引 / 約束:UNIQUE(departure_id, guide_id)(dga_departure_guide_unique,是 upsert 的衝突鍵)、index(guide_id)、index(departure_id)。
天數定義:一梯次帶團天數 = (return_date - departure_date) + 1(含頭尾兩端,與 wireframe「3 日」「5 日」一致)。
排班 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)。
listRosterGuides)active guides(includeInactive 可放寬)+ 指定月份(預設當前 UTC 年月)的彙總:
monthDays = SUM(return_date − departure_date + 1) over confirmed 指派、且該梯次 departure_date 落在月份視窗內。tentative 不計入帶團天數。monthTrips = 該月 confirmed 帶團梯次數。hasTentative = 該月是否含 tentative 指派(BOOL_OR)。availability(派生載量狀態):
full:max_days_per_month != null && monthDays >= max_days_per_month。tentative:否則若 hasTentative。open:其餘。載量上限只影響 availability 顯示,不硬擋指派(是否加硬性上限閘見 §5)。
listMonthDepartures)當月(依 departure_date 落窗)出團梯次 JOIN trips 取 title,附掛各梯次所有指派(領隊在前、再依 guide 名排序)。預設排除 status = 'cancelled' 梯次(includeCancelled 可含)。攤平 days / capacity / booked_count 給 UI 排班矩陣。
assignGuide(upsert):INSERT ... ON CONFLICT (departure_id, guide_id) DO UPDATE 換 role / status / note。status 預設 tentative(送籤前預塞)。撞 UNIQUE 走 update = 同一 guide 換角色 / 升級狀態,不新增列。confirmed 時順手寫 confirmed_at(COALESCE 保留最早)。回傳實際落地 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→leader、status→confirmed)。
所有寫入路徑(assign / confirm / remove / changeLeader)在 tx 末尾呼叫
recalculateDepartureTemplateCosts(cost-template §3.3):confirmed 領隊 / 協作人頭變動會重算範本人頭成本行。
不變式(#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、confirmAssignment、changeLeader)都先查、有衝突回 { ok: false, reason: 'CONFLICT', conflict } 不寫入。tentative 預塞不擋(僅 UI 警示,供送籤前估載量時故意允許重疊)。findConfirmedConflict:公開包裝,供 dialog / preview 在送出前預查同一條件。detectConflicts(year, month):批次偵測 —— 同一 guide 在日期重疊的兩梯次都 confirmed → 衝突對。自連結 a.id < b.id 去重(每對回一次);月窗以「至少一端梯次出發日落在月內」為界(避免漏掉跨月邊界重疊)。/admin/guide-roster,獨立 nav)跨梯次月度排班是跨梯次功能,故開獨立後台 nav(對照線控 UX:per-departure 批次作業才從 /admin/control 進,跨梯次的 dashboard / 排班才獨立 nav)。頁面 force-dynamic,月份視窗下拉(當月起前 3 後 9 個月):
RosterMatrix):列 = 領隊 / 嚮導,欄 = 當月梯次(依出發日),格子顯示帶哪團 + 已確認 / 預塞 / 衝突三態著色;末列「待排(無領隊)」;右欄本月載量(days / max + 團數,超載標紅)。GuideListTable):姓名 / 職能 / 可帶路線(specialties)/ 本月帶團數 / 載量 / 檔期狀態(可帶 / 預塞中 / 已滿)+ 指派操作。AssignDialog):選梯次 → 選領隊 → 標「正式確認 / 預塞」;送出前 findConfirmedConflict 預查提示,confirmed 衝突由 server fail-closed 擋下。單一梯次的指派 / 換領隊亦可從 出團準備工作台(/admin/control per-departure)操作,與本頁共用同一份 departure_guide_assignments 真相。
guide_roster)packages/core/src/permissions-ac.ts 註冊 guide_roster: ['read', 'manage']:
| action | 用途 | admin | op | sales | accountant | guide |
|---|---|---|---|---|---|---|
guide_roster.read | 進排班總覽頁、看名冊載量 / 矩陣 / 衝突 | ✅ | ✅ | ❌ | ❌ | ❌ |
guide_roster.manage | 指派 / 換領隊 / 確認 / 移除 | ✅ | ✅ | ❌ | ❌ | ❌ |
requirePermission('guide_roster.manage');審計由 action 層寫(guide_roster.assign / .confirm / .remove / .change_leader,target_type='departure_guide_assignment')。ActionError 回使用者可讀訊息(含撞期梯次名 / 日期),引導改用「預塞」或先調整既有梯次。guides.engagement_type(full_time / partner / external,CHECK 約束),與 roster_role(排班職能)、status(目錄狀態)正交(見 §2.1)。max_days_per_month 目前只驅動 availability 顯示(full 標示),不擋指派。是否要在 confirm 時對「超月上限」也 fail-closed(比照檔期衝突),或維持軟提示,待定。guides.contact_email)—— confirmAssignment / assignGuide(status=confirmed) / changeLeader(新領隊)皆觸發,換領隊另通知被換下的舊領隊。走既有 emails outgoing_emails queue(專用 React Email 範本 guide_assignment_notice,非罐頭 message-template)。實作在 packages/core/src/roster/notify.ts(notifyGuideAssignment),四個寫入入口(後台 action + /api/v1/guide-roster/* + departure-prep)一致接線;best-effort(通知失敗不回滾指派)、無 email 靜默略過。tentative 預塞不寄。LINE 推播另案未做。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-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。已實作。 | — |
| 2 | 2026-07-03 | §5「換領隊 / 確認通知」落地(email-only):notifyGuideAssignment(packages/core/src/roster/notify.ts)在指派成為 confirmed 時 enqueue guide_assignment_notice 給嚮導 contact_email,換領隊另通知舊領隊;後台 action + /api/v1/guide-roster/* + departure-prep 四入口一致接線,best-effort。LINE 推播另案未做。 | — |