一句話:熱門梯次(如國家公園山屋床位)走抽籤分配。抽籤演算法太複雜,決議留在 Google Sheets + Apps Script,不寫進 ERP;ERP 負責抽籤地基(
allocation_mode/lottery_state)+ 線控手動裁決;GS 出入向整合走 agent-driven CLI(control lottery-orders/lottery-resolve),webhook 模型為棄用備選。
module_lottery 已落地);未開通時梯次不出現 allocation_mode=lottery、所有手動裁決 write action fail-closed、本整合與下游轉備案整組不啟用。部分梯次的名額不是先到先得,而是抽籤決定(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)orders.waitlist_position + 席滿改建候補單(不佔 booked_count、有應收 charge/schedule 但無 intent)+ race-safe 迴圈遞補(promoteWaitlistInTx,前位釋出即依序補滿剩餘空位、head-of-line 避讓)。travelers.id_number_bidx(blind-index)+ 建單 tx 內 checkQuotaInTx(COUNT(DISTINCT departure) + advisory-lock 序列化併發、超限整 tx rollback);受限行程強制填身分證。整合路徑 / 職責邊界(已定):
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,不走再裁決。promoteWaitlistInTx);「外部 permit 釋回」的偵測本質是抽籤流程的訊號(盯台灣山林悠遊網),與抽籤邏輯同屬 GS 側——agent 偵測到釋回後經 CLI lottery-resolve 把候補者裁 won 即完成遞補。ERP 不為輪詢外部網站長 infra(避免把外部改版風險綁進平台維運)。不做:抽籤演算法本身(留 GS);外部 permit 釋回偵測(歸 GS 側 agent,見上);下游備案細節見 backup-itinerary-failover。
departures.allocation_mode:'fcfs'(先到先得,預設,現行行為)/ 'lottery'(抽籤制)。已落地欄位。
lottery梯次的訂單是應募,名額(外部 permit / 山屋床位)由抽籤決定;但下單即扣本梯次的 ERP 席(booked_count +1,與一般梯次一致、庫存即時反映)。抽籤搶的是外部稀缺資源、非 ERP 席次(見 §3.3 與 §5)。
訂單在抽籤梯次下,於 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。lottery_rounds(棄用備選)
lottery_rounds表未實作,不再列未來批——agent-driven CLI 路徑(§3.2)已取代 webhook 模型;僅在未來需要更高自動化(免 agent 的機器對機器整合)時重啟評估。
| 欄位語意 | 說明 |
|---|---|
departure_id | 哪個梯次。 |
status | collecting(收件中)→ submitted(已推給 GS)→ resolved(結果已回)。 |
submitted_at / resolved_at | 推送 / 回收時間。 |
external_ref | GS 端對應這輪的識別(回呼比對用)。 |
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。 |
出向撈名單 = 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 不知道、不在意抽籤怎麼算。
舊模型(push.ts 推名單 + 入站 HMAC 驗簽 webhook + lottery_rounds 狀態機)未實作、由 §3.2 取代;僅在需要免 agent 的機器對機器自動化時重啟評估,屆時仍接同一 resolveLotteryOrder。
| 結果 | 觸發 |
|---|---|
won 中籤 | 確認訂單 + enqueue 催尾款信 + 啟用尾款 payment_schedules。 |
lost 未中籤 | 進 備案自動轉單:沿 order_backup_choices 鏈式 cascade(FCFS 落腳 / 抽籤重應募 + carry forward);清單走完→取消 + 退款 draft。 |
下游動作由 resolveLotteryOrder 串起,不散落在 CLI / UI handler 內(任何新入口一律接同一服務)。
熱門梯次(玉山)席滿後仍可收候補;前位釋出即依序遞補。受 trips.waitlist_enabled(trips §2.1)gate。實作 waitlist.ts + orders/repo.ts。
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。lottery-resolve 裁 won 即觸發遞補,見 §1);ERP 側無待辦。國家公園 permit 同一身分證計次(restricted_quota_enabled + quota_per_person_limit + quota_window):語意 = 「同一身分證在這條 permit 路線(= 同一 trip)、每 quota_window(如 'annual')最多參加 quota_per_person_limit 個不同梯次」(奇萊南華 / 嘉明湖 / 北大武等)。實作 quota.ts。
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 外洩不可反推、不可跨租戶移植計次)。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 陣列(一般行程無強制)→ 直接放行。/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 若重啟才需要免登入驗簽)。lottery 梯次下單(應募)即扣本梯次 ERP 席(booked_count +1)—— ERP 席只反映「誰報了這梯」,抽籤搶的是外部 permit / 山屋床位。備案梯次不預扣席;未中籤 failover 當下才扣備案席;取消主梯 → 釋回主梯 ERP 席。tenant_settings);現行 CLI 路徑用 device-flow bearer,無此議題。quota_window 目前僅實作 'annual'(同西元年),其餘值 fall back 全時間累計。滑動視窗(近 N 個月)等更細口徑列後續。lost 單顯式 re-open 回 applied 的路徑(目前 lost 是終態,見 §3.1 guard)。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-06-20 | 初版(草案):ERP↔GS 抽籤整合契約、allocation_mode / lottery_state / lottery_rounds、出向推名單 + 入向驗簽收結果 + 下游觸發。抽籤邏輯不進 ERP。 | — |
| 2 | 2026-06-20 | 定案席次模型:下單(應募)即扣主梯次 ERP 席、備案梯次不預扣(failover 才扣);釐清抽籤搶的是外部 permit/山屋床位非 ERP 席。 | — |
| 3 | 2026-06-23 | D5:玉山搶候補 / 候補名單遞補標記為未來批(GS 整合批落地);新增 §3.5 人頭使用次數限制(未來批)。 | — |
| 4 | 2026-06-30 | 落地後更新(13-task SDD):§1 重寫拆「已落地批」vs「未來批」;§2.2 標 allocation_mode/lottery_state 為已落地欄位、lottery_rounds 標未來批;§3 重組為線控手動裁決(§3.1 resolveLotteryOrder 三出口,已落地)+ 出入向 GS(§3.2/§3.3,未來批)+ 下游觸發(§3.4)+ 候補/配額(§3.5/§3.6,未來批);§4 UI 補 module gate fail-closed;§5 開放議題更新。 | — |
| 5 | 2026-06-30 | 多備案鏈式 cascade(客人側):§2.2 單欄 backup_departure_id → order_backup_choices 有序多備案;§3.1 / §3.4 lost 出口改 advanceToNextBackup 鏈式 cascade,解除「禁止鏈式自動抽籤」——鏈由客人有限排序的 order_backup_choices 界定、每跳遇抽籤目標停在 applied 等該輪結果(非無限自動);清單走完才取消 + 回溯鏈首退款。 | — |
| 6 | 2026-07-01 | 狀態校正(Code Is Truth):§1 未來批改寫——GS 出入向整合改走 agent-driven CLI(control lottery-orders + lottery-resolve 已備,免 webhook/簽驗/lottery_rounds infra);§3.5/§3.6 標題改「設定欄已落 / 邏輯未做」並澄清 waitlist_enabled 與 restricted_quota_enabled/quota_per_person_limit/quota_window 僅設定欄 + 後台表單落地、enforcement/遞補邏輯零實作(候補缺 orders.waitlist_position + race-safe 遞補;配額卡 id_number KMS 密文無法聚合計次,需 blind-index)。 | — |
| 7 | 2026-07-01 | 修 advanceToNextBackup lost guard 缺口(codex 對抗式覆核揪出):守衛段補 lottery_state='lost' → already_resolved,堵住「有備案但當時售罄→exhausted 待安置→備案出缺→重送 lost」會繞過 resolve.ts exhausted guard 重進 cascade 的路徑(初次裁決主單為 applied,不受影響)。§1 重複保護回填為全終態冪等、§5 移除該開放議題。回歸測試 lottery-resolve.test.ts(備案售罄→出缺→重送 lost 回 already_resolved + 0 child)。 | — |
| 8 | 2026-07-02 | 候補 / 人頭配額 enforcement 落地(Code Is Truth):§1 兩項由「未來批」移入「已落地批」;§2.2 新增 orders.waitlist_position / travelers.id_number_bidx 已落地欄位;§3.5 重寫為候補建單(席滿改建候補單、不佔 booked_count、有應收無 intent)+ promoteWaitlistInTx race-safe 迴圈遞補(head-of-line 避讓、SKIP LOCKED 併發安全);§3.6 重寫為 blind-index(HKDF per-tenant、不落 DB)+ checkQuotaInTx(COUNT(DISTINCT departure) + advisory-lock TOCTOU 硬化 + 受限行程強制身分證 quota_id_required);§5 開放議題更新配額口徑(候補計入、僅 annual)。實作 packages/core/src/{orders/quota.ts,orders/waitlist.ts,pii/blind-index.ts,orders/repo.ts},migration 0037。經 self + codex 兩輪對抗式覆核揪出並修 6+3 bug(候補付款繞過 / 配額 TOCTOU / 無證繞過 / failover bidx / 遞補 head-of-line / 多席遞補 / 旅客編輯增訂 bidx 同步 / 結團 audit 冪等)。 | — |
| 9 | 2026-07-02 | GS 整合職責邊界定案:外部 permit 釋回的偵測與遞補決策歸 GS 側抽籤 agent(偵測後經 CLI lottery-resolve 裁 won,ERP 內部釋位遞補本已自動)——ERP 側無剩餘工程項,不為輪詢外部網站長 infra。webhook 出入向(push.ts/入站驗簽/lottery_rounds)由「未來批」改標棄用備選(§2.3/§3.3,agent-driven CLI 取代,需免 agent 的 M2M 自動化才重啟);§3.2 改寫為現行 CLI 路徑;§5「誰主動」定案 GS 側主動、驗簽密鑰改棄用備選;移除 §1 過時的「玉山搶候補(未來批)」註(候補 v8 已落地)。 | — |