線稿索引所有 spec最後更新 2026-07-02
specs/lottery-integration.md

抽籤整合

一句話:熱門梯次(如國家公園山屋床位)走抽籤分配。抽籤演算法太複雜,決議留在 Google Sheets + Apps Script,不寫進 ERP;ERP 負責抽籤地基(allocation_mode/lottery_state)+ 線控手動裁決;GS 出入向整合走 agent-driven CLI(control lottery-orders / lottery-resolve),webhook 模型為棄用備選。

  • 模組 gate:受「抽籤模組」平台授權(見 模組化,module_lottery 已落地);未開通時梯次不出現 allocation_mode=lottery、所有手動裁決 write action fail-closed、本整合與下游轉備案整組不啟用。

1. 目標 / 問題

部分梯次的名額不是先到先得,而是抽籤決定(permit / 山屋床位)。抽籤規則多變且複雜(0617 會議決議不寫死進 ERP)。本 spec 定義抽籤子系統整體範圍——分已落地與整合路徑 / 職責邊界:

已落地(本批,13-task SDD + 候補/配額 enforcement 批):

  • 抽籤地基:departures.allocation_mode(fcfs/lottery)+ orders.lottery_state(applied/won/lost)
  • module_lottery gate(control_db 欄 + proxy header + helper + write action fail-closed)
  • 線控手動裁決:線控逐單標中籤(won)/ 未中籤(lost)三出口全通(won→確認、lost+備案→failover、lost+無備案→取消+退款 draft)
  • 候補名單 / 遞補(§3.5):orders.waitlist_position + 席滿改建候補單(不佔 booked_count、有應收 charge/schedule 但無 intent)+ race-safe 迴圈遞補(promoteWaitlistInTx,前位釋出即依序補滿剩餘空位、head-of-line 避讓)。
  • 人頭使用次數配額(§3.6):travelers.id_number_bidx(blind-index)+ 建單 tx 內 checkQuotaInTx(COUNT(DISTINCT departure) + advisory-lock 序列化併發、超限整 tx rollback);受限行程強制填身分證。

整合路徑 / 職責邊界(已定):

  • GS 出入向整合 → 改走 agent-driven CLI(無需 webhook):tenant CLI 已具 control lottery-orders <depId>(讀應募名單,回 orderId/orderNumber/lotteryState)+ control lottery-resolve <orderId> --outcome won|lost(接同一 resolveLotteryOrder)。agent 讀 GS 抽籤結果 → 逐單敲 CLI 完成裁決,省下出向 push / 入向 webhook / HMAC 簽驗 / lottery_rounds 整套 infra(這些列為「要更高自動化才需要」的選項,非預設路徑)。重複保護:resolveLotteryOrder 對主單 SELECT … FOR UPDATE 串行化,所有已裁決終態重送皆回 already_resolved(CLI/route 表為 409,agent 視為 skip)——won 重送(won 路徑 lottery_state='applied' guard)、已取消 / 已轉入備案(有 child)重送、以及已 lost 待人工安置單重送 lost(failover.ts §2 的 lottery_state='lost' guard 擋下,不會重進 cascade)。建議 agent 仍只對 lottery_state='applied' 的單裁決;待安置單的後續處置走 cancelLotteryLostOrder 或線控 forcePlaceToTarget,不走再裁決。
  • 外部 permit 釋回的偵測與遞補決策 → 歸 GS 側抽籤 agent(2026-07-02 定案,ERP 無剩餘工程項):ERP 內部釋位路徑已同 tx 自動遞補(promoteWaitlistInTx);「外部 permit 釋回」的偵測本質是抽籤流程的訊號(盯台灣山林悠遊網),與抽籤邏輯同屬 GS 側——agent 偵測到釋回後經 CLI lottery-resolve 把候補者裁 won 即完成遞補。ERP 不為輪詢外部網站長 infra(避免把外部改版風險綁進平台維運)。

不做:抽籤演算法本身(留 GS);外部 permit 釋回偵測(歸 GS 側 agent,見上);下游備案細節見 backup-itinerary-failover。

2. 資料模型

2.1 梯次分配模式

  • departures.allocation_mode:'fcfs'(先到先得,預設,現行行為)/ 'lottery'(抽籤制)。已落地欄位。

    lottery 梯次的訂單是應募,名額(外部 permit / 山屋床位)由抽籤決定;但下單即扣本梯次的 ERP 席(booked_count +1,與一般梯次一致、庫存即時反映)。抽籤搶的是外部稀缺資源、非 ERP 席次(見 §3.3 與 §5)。

2.2 訂單抽籤狀態

訂單在抽籤梯次下,於 booking_state 之外多一個抽籤軸(派生顯示用,真相在下表):

  • orders.lottery_state:'applied'(已應募)→ 'won'(中籤)/ 'lost'(未中籤)。fcfs 訂單恆 null。已落地欄位,由線控手動裁決或 GS 側 agent 經 CLI lottery-resolve 寫入(接同一 resolveLotteryOrder)。
  • orders.waitlist_position:候補序位(nullable int)。NULL = 一般在席訂單;非 NULL = 候補單(不佔 booked_count)。已落地欄位(見 §3.5)。
  • travelers.id_number_bidx:身分證 blind-index(deterministic HMAC,見 §3.6 與 blind-index.ts),供人頭配額等值計次;密文 id_number_enc 無法聚合的補充鍵。已落地欄位 + index。
  • order_backup_choices:旅客應募時自選的有序多備案清單(同 trip、可混 FCFS/抽籤;取代舊單欄 backup_departure_id)。見 backup-itinerary-failover §2。

2.3 抽籤輪 lottery_rounds(棄用備選)

lottery_rounds 表未實作,不再列未來批——agent-driven CLI 路徑(§3.2)已取代 webhook 模型;僅在未來需要更高自動化(免 agent 的機器對機器整合)時重啟評估。

欄位語意說明
departure_id哪個梯次。
statuscollecting(收件中)→ submitted(已推給 GS)→ resolved(結果已回)。
submitted_at / resolved_at推送 / 回收時間。
external_refGS 端對應這輪的識別(回呼比對用)。

3. 邏輯 / 存取層

3.1 線控手動裁決(已落地)

packages/core/src/lottery/resolve.ts(resolveLotteryOrder,db: ScopedDb):

  • 前置:SELECT … FOR UPDATE 鎖主單,條件轉換 applied → won/lost(防並發重複裁決);受 module_lottery gate。
  • 三出口:
lottery_state動作
won 中籤booking_state='confirmed';enqueue 催尾款信;確保 balance schedule/intent 可用。
lost + 有可落腳備案advanceToNextBackup(backup-itinerary-failover §3,同 tx 沿 order_backup_choices 鏈式 cascade + credit 法)。允許鏈式自動抽籤:鏈由客人有限排序的清單界定,每跳遇抽籤目標停在 applied 等該輪結果(非無限自動)。
lost + 清單走完取消主單 + 回溯鏈首建 createRefund draft 退款,交線控 approve。

3.2 GS 出入向:agent-driven CLI(現行路徑)

出向撈名單 = CLI control lottery-orders <depId>(/api/v1/departures/:depId/lottery-orders,回 orderId/orderNumber/lotteryState);入向回寫 = CLI control lottery-resolve <orderId> --outcome won|lost(接同一 resolveLotteryOrder,含三出口與全終態冪等)。認證走 OAuth device flow + bearer,權限與 module gate 與後台同閘。只撈不決:ERP 不知道、不在意抽籤怎麼算。

3.3 webhook 出入向(棄用備選)

舊模型(push.ts 推名單 + 入站 HMAC 驗簽 webhook + lottery_rounds 狀態機)未實作、由 §3.2 取代;僅在需要免 agent 的機器對機器自動化時重啟評估,屆時仍接同一 resolveLotteryOrder。

3.4 下游觸發

結果觸發
won 中籤確認訂單 + enqueue 催尾款信 + 啟用尾款 payment_schedules。
lost 未中籤進 備案自動轉單:沿 order_backup_choices 鏈式 cascade(FCFS 落腳 / 抽籤重應募 + carry forward);清單走完→取消 + 退款 draft。

下游動作由 resolveLotteryOrder 串起,不散落在 CLI / UI handler 內(任何新入口一律接同一服務)。

3.5 候補名單與遞補(已落地)

熱門梯次(玉山)席滿後仍可收候補;前位釋出即依序遞補。受 trips.waitlist_enabled(trips §2.1)gate。實作 waitlist.ts + orders/repo.ts。

  • 候補建單:建單 tx 內走一般 race-safe 席次 CAS(booked_count + N <= capacity);失敗(售罄)且行程 waitlist_enabled → 改建候補單:waitlist_position = COALESCE(MAX,0)+1、不扣 booked_count、有完整 charge lines + payment_schedules(應收),但不鑄 payment intent(未遞補前不可付款)。party_size > capacity 的單一律拒(候補也裝不下)。
  • 遞補(promoteWaitlistInTx 迴圈版):任何釋位路徑(棄單回收 / ATM 逾期 / 取消 / 抽籤轉單)在同一 tx 釋回席次後呼叫,把剩餘空位盡量塞滿——依 (waitlist_position, created_at, id) 序遍歷候補、對每個走與一般建單同一條 race-safe CAS 扣席、遞補第一個裝得下剩餘空位者(不 fit 的跳過 → 避 head-of-line block)。命中即清 waitlist_position、鑄付款 intent(轉可付款)、重算範本人頭成本、enqueue「已遞補上車」通知(沿用 order_confirmed 模板、payUrl 走 canonical origin)。候補單以 FOR UPDATE OF o SKIP LOCKED 鎖定,併發遞補互不重補。席次不變式與建單一致:booked_count 恆 ≤ capacity。
  • 外部 permit 釋回的偵測 → 歸 GS 側抽籤 agent(偵測後經 CLI lottery-resolve 裁 won 即觸發遞補,見 §1);ERP 側無待辦。

3.6 人頭使用次數限制(已落地)

國家公園 permit 同一身分證計次(restricted_quota_enabled + quota_per_person_limit + quota_window):語意 = 「同一身分證在這條 permit 路線(= 同一 trip)、每 quota_window(如 'annual')最多參加 quota_per_person_limit 個不同梯次」(奇萊南華 / 嘉明湖 / 北大武等)。實作 quota.ts。

  • blind-index 補聚合缺:travelers.id_number 是 KMS envelope 加密 PII,每列密文不同無法 GROUP BY。故另存 id_number_bidx = HMAC-SHA256(perTenantKey(dbName), normalize(idNumber))(blind-index.ts)——deterministic 可等值聚合,key 由平台祕密 HKDF 派生綁 dbName、不落 DB(備份/replica 外洩不可反推、不可跨租戶移植計次)。
  • enforcement:建單 tx 內、旅客(含 id_number_bidx)insert 之後呼叫 checkQuotaInTx——COUNT(DISTINCT tb.departure_id) > limit(booking_state <> 'cancelled'、視窗述詞作用在 departure_date);超限 → 呼叫端回 quota_exceeded 讓整 tx rollback(席次一併回滾)。TOCTOU 硬化:COUNT 前對每個 bidx 取 pg_advisory_xact_lock(hashtext(bidx))(去重排序依序取避死結),序列化同證號的併發報名。
  • 受限行程強制身分證:restricted_quota_enabled 的行程建單時,缺 id_number_bidx 的旅客 → 回 quota_id_required(否則無證者可繞過計次);候補單仍計入配額(permit 稀缺,寧可保守擋在報名端)。空 bidx 陣列(一般行程無強制)→ 直接放行。

4. UI 與權限

  • 團控(/admin/control):梯次可標 allocation_mode='lottery';抽籤梯次顯示應募訂單列 + 各筆 lottery_state badge + 逐單「中籤 / 未中籤」action;裁決走線控或 GS 側 agent(CLI,§3.2)。
  • 訂單詳情:顯示 lottery_state badge + 主備譜系。
  • 權限:裁決走 trip.manage / order.update;所有 write action 受 module_lottery gate fail-closed;CLI 走 device-flow bearer 同閘(入站 webhook 若重啟才需要免登入驗簽)。

5. 開放議題

  • 席次模型(已定):lottery 梯次下單(應募)即扣本梯次 ERP 席(booked_count +1)—— ERP 席只反映「誰報了這梯」,抽籤搶的是外部 permit / 山屋床位。備案梯次不預扣席;未中籤 failover 當下才扣備案席;取消主梯 → 釋回主梯 ERP 席。
  • 誰主動(已定,2026-07-02):GS 側 agent 主動——經 CLI 撈名單 / 回寫結果 / 偵測外部 permit 釋回後裁遞補;ERP 不 push、不輪詢外部系統。
  • 金流:本批假設應募即收訂金(否則無物可結轉);確切金額策略(全額 / 訂金 / 0)待租戶設定。
  • 驗簽密鑰(棄用備選):僅 webhook 模型重啟才需要(per-tenant 或 per-departure,存 tenant_settings);現行 CLI 路徑用 device-flow bearer,無此議題。
  • 配額計次口徑(已定,保守):候補單 / 未中籤待安置單皆計入配額(permit 稀缺,寧可過度限制擋在報名端);quota_window 目前僅實作 'annual'(同西元年),其餘值 fall back 全時間累計。滑動視窗(近 N 個月)等更細口徑列後續。
  • 多輪重抽:同梯次多輪抽籤仍留後續。若未來支援,需要的不只是放行重裁,而是把已 lost 單顯式 re-open 回 applied 的路徑(目前 lost 是終態,見 §3.1 guard)。