一句話:旅客報名抽籤梯次時自選一串有序備案(同行程其他梯次,可混先到先得/抽籤);未中籤時 ERP 沿清單鏈式 cascade 前進——遇先到先得有位即當下成立、遇抽籤梯次則重新應募等下一輪;每跳取消舊單、在目標梯次成立新單、以應收 credit 法把訂金一路結轉、寄信通知補尾款。清單走完 / 全滿 → 待人工安置(不自動退款),由線控 force-place 到同 trip 任意梯 或 取消退款。後台亦可代客(K 單)維護備案清單。由 抽籤整合 的
lost訊號觸發(現為線控手動裁決)。
抽籤制熱門團,旅客常在報名時就先選好「抽不到就改去哪一梯」的備案。0617 會議決議:未中籤後自動完成「取消主單 → 成立備案單 → 催尾款」,不要營運逐筆手搬(過去在 GS 是純人工)。
範圍:主→備的鏈式訂單轉移、訂金結轉、自動催尾款、走完退款。
不做:抽籤本身(抽籤整合);無可落腳備案時的退款細節走 金流模型 refunds,本檔負責「沿清單找落腳並轉」。
| 欄位語意 | 說明 |
|---|---|
departures.allocation_mode | 'fcfs'(先到先得,預設)/ 'lottery'(抽籤制)。 |
orders.lottery_state | null(fcfs)/ 'applied'→'won'/'lost';線控手動裁決寫入。 |
order_backup_choices | 有序多備案清單(取代單欄 backup_departure_id):id / order_id(FK orders, cascade) / departure_id(FK departures, restrict) / rank(1-based 偏好序) / created_at;unique(order_id, rank) + unique(order_id, departure_id)。 |
orders.failover_from_order_id | 備案新單回指被未中籤取消的主單(UNIQUE 冪等最後防線;譜系 / audit)。 |
orders.cancellation_reason | 新增(nullable,CHECK enum 含 lottery_lost)。報表篩選真相,區分抽籤取消與一般取消。 |
同行程同價約束(checkout 強制):
order_backup_choices的每個departure_id必須是主單 同trip的另一個departure,且departures.price_twd與主梯相同。同 trip 不保證同價(promo / 梯次 override 可造成異價),故「同價」是 checkout 強制約束、非假設:公開站備案選擇器只顯示同價候選(backup-picker過濾priceTwd === 主梯 priceTwd);送出 server action 以 server 為權威重讀候選再驗證、丟棄非同 trip / 異價 / 重複 / 主梯本身的 id(沿用靜默丟棄慣例)。如此 cascade 跨單結轉的成交價恆一致,消滅主備價差,無需處理差額。裁決期二次防線(placement 重驗):備案寫入後、抽籤裁決前梯次可能改價,故advanceToNextBackupcascade loop 在落點時重驗候選price_twd === 主梯定價(base-to-base,與forcePlaceToTarget的守衛鏡射一致),異價 → skip 該 rank 落下一順位,被跳過的 rank 記入 audit metadatapriceMismatchSkippedRanks供追溯。可混先到先得 / 抽籤梯次,上限為該行程剩餘梯次數(無人為上限)。不新增金流表,訂金帶過走應收 credit 法(見 §3.2)。
packages/core/src/lottery/failover.ts(db: ScopedDb),advanceToNextBackup 函式,全程同一 tx(db.transaction)。沿主單 order_backup_choices(rank 升序)找第一個可落腳目標:
SELECT … FOR UPDATE 鎖主單 + 冪等檢查(非抽籤單→not_lottery;已 cancelled 或已有 failover 子單→already_resolved)。departure_id / party_size / unit_price_twd_snapshot / price_channel_snapshot)。讀 order_backup_choices(rank 升序);空清單 → 整 tx rollback、回 exhausted(呼叫端走無備案退款路徑)。createOrderWithReservationInTx(reservation primitive,skipDefaultFinancials)扣席建單(沿用 lottery 席次模型「應募即扣席」;候選梯次此刻才佔):
sold_out/closed/departed)→ 前進下一 rank。targetOrderId,記 hitRank + 候選 allocation_mode,break。exhausted。transferMainToTarget(per-hop 轉單帳務,見 §3.2),落點依候選 allocation_mode 參數化:
booking_state='confirmed' + lottery_state='won'(終態,鏈結束),outcome placed。booking_state='pending_payment' + lottery_state='applied'(等那一輪抽籤),outcome reapplied;且把 rank > hitRank 的剩餘備案 carry forward 複製到新單的 order_backup_choices(重排 rank 從 1),鏈在此暫停(等下一次裁決對新單觸發)。assertOrderFinancialInvariants + 寫主 / 備兩單 audit + 譜系。
resolveLotteryOrder的lost出口呼叫本函式:placed/reapplied皆回backupOrderId(涵蓋兩種落點);exhausted→ 走無備案取消 + 退款 draft(見 §3.3)。
旅客已付訂金不退回再收,每跳以應收 credit 法帶到目標單。不在 payment_transactions 建任何 transfer 列——現金分類帳保持純銀行真相(為什麼不走現金移轉對,見 ADR 0008)。訂金現金實體只留在鏈首;下游每跳新單的訂金以負 failover_deposit_credit charge line 形式結轉(非現金)。
主單 / 上一跳單(被取消):
gross_receivable 沖至 0(逐筆 reversal charge line),所有 current schedules supersede(reconcileSchedulesToReceivable,H3 ✓)。payment_transactions 不動)→ payment_state 派生 paid(receivable=0 且 collected>0,與全退單仍 paid 一致);booking_state=cancelled、lottery_state='lost',營收認列用 gross=0(不計營收)。目標單(新成立):
appendChargeLine(base_fare = 成交價)(價格用主單 tour_booking_lines.unit_price_twd_snapshot × party_size,不裸用 departures.price_twd,保快照穩定性)。appendChargeLine(type='failover_deposit_credit', amount = −cap),以 failover_from_order_id 串回上一跳稽核。其中 cap = min(carriedDeposit, 成交價),carriedDeposit = customerCashApplied(上一跳) + |上一跳已結轉的 failover_deposit_credit|——鏈長 ≥ 2 時上一跳已無現金(訂金以 credit 存在),必須把結轉 credit 加回,否則訂金會在第二跳起蒸發。gross_receivable = 成交價 − cap = 尾款(同 trip 同價下 cap = 訂金,尾款為剩餘應收)。createSchedule(purpose='full', target=尾款) + createIntentForSchedule(SUM(current target) == gross_receivable,H3 ✓)。不建 satisfied 訂金列——訂金以 credit 內含於較低 gross。尾款 = 0(cap == 成交價,主單已全額付款)時整組跳過——不建 schedule 也不鑄 intent(gross 0 對零張 schedule 的 SUM 0,H3 仍 ✓),杜絕 UI 出現可付 0 元的付款入口(ECPay 不接受 0 元)。目標 payment_state 派生 unpaid(gross=尾款、cash_applied=0)。客人付尾款後 → paid;全程付款總額 = 訂金(鏈首)+ 尾款(落腳單)= 原價。
exhausted)→ 待人工安置(不自動退款):空清單、未選備案、或所有備案皆滿 / 不可訂 → cascade 整 tx rollback、主單保持原狀;呼叫端(resolveLotteryOrder)僅設 lottery_state='lost'(FOR UPDATE 防並發),保留 booking_state、保留主梯席次、不取消、不退款,回傳 awaitingPlacement。主單進「待人工安置」清單,由線控顯式裁決(§3.4 force-place 或取消退款)。抽籤梯此時已不可報名,被未中籤單佔著的席次不影響任何人;force-place 或取消才釋主梯席,席次帳全程一致。(舊版「exhausted 即自動取消 + 退訂金 draft」已退場,改由線控顯式觸發 cancelLotteryLostOrder。)applied)+ carry forward 剩餘清單,不再禁止鏈式自動轉(鏈由客人有限排序的 order_backup_choices 界定,非無限自動)。failover_from_order_id UNIQUE 為最後防線(一新單只來自一上一跳);FOR UPDATE 鎖主單防並發;主單已 cancelled 或已有 failover 子單 → 回 already_resolved。consent_signatures 以 append-only 複製原簽署(沿用 signedAt);旅客以 PII 重加密(AAD 綁新 rowId)複製,不搬移原列。待安置主單(或線控主動介入 applied 單,客人來電指定梯)由線控顯式裁決,兩條出口建構於同一套 cascade 帳務引擎:
forcePlaceToTarget(db, mainOrderId, targetDepartureId, actor)(packages/core/src/lottery/failover.ts):線控指定同 trip 任意梯單一目標安置(不限 order_backup_choices)。單一 tx:FOR UPDATE 鎖主單 + 冪等(lottery_state null→not_lottery;已 cancelled 或已有 failover 子單→already_resolved;允許主單 applied/lost)→ 讀成交 snapshot + 算 cap(同 §3.2)→ 驗目標同 trip(否則 not_same_trip)、非主梯本身 → createOrderWithReservationInTx 扣目標席(sold_out/closed/departed→回 target_full,不 rollback 成 exhausted,讓線控改選)→ 重用 transferMainToTarget(resultLotteryState='won' / resultBookingState='confirmed',即使目標為抽籤梯也直接成立,線控已裁量)。force-place 為終態,不 carry forward 備案。沿用 credit-law、base_fare 用主單 snapshot ⇒ 同 trip 同價零價差。cancelLotteryLostOrder(db, mainOrderId, actor):線控顯式「取消退款」。內容沿用原 exhausted 邏輯(cancellation_reason='lottery_lost' → cancelOrder 釋席 / 殺 rails → 沿 failover_from_order_id 回溯鏈首 → 依鏈首 money state 選 receivable_reduction/overpayment_return 建 draft 退款;無訂金→null)。setOrderBackupChoices(db, orderId, departureIds[], actor)(整批 replace 語意):後台代客(電話下單 K 單)設定 / 調整 order_backup_choices。FOR UPDATE 鎖訂單、驗為抽籤單(lottery_state 非 null)且未終結 → 對 departureIds 逐一 server 重讀候選,剔除非同 trip / 異價(price_twd ≠ 主梯)/ 重複 / 主梯本身 / 不存在(沿用 checkout 靜默丟棄慣例、保輸入順序去重)→ DELETE 既有 + 依序重寫 rank。與公開站 checkout 備案寫入同一套驗證規則,只是 actor 換成後台。allocation_mode='lottery')的結帳流程(公開站 design system apps/web/src/components/ui/*)顯示抽籤說明文案 + 備案選擇器(backup-picker.tsx)讓旅客從同行程其他在售梯次中自選並排序多個備案;送出時 server action 設 lottery_state='applied' + 依序寫 order_backup_choices(同 trip 驗證、去重、保留排序)。團控不再提供「設定備案梯次」action(備案改客人自助)。lottery_state badge。applied 抽籤單另顯示備案管理區(setOrderBackupChoices):有序清單 + 新增(同 trip 同價在售候選 Combobox)/ 移除 / 排序,供後台代客(電話 K 單)維護備案;非 applied 只唯讀顯示譜系。/admin/control):抽籤梯次顯示應募訂單列 + 各筆 lottery_state + 已選備案筆數 + 逐單「中籤 / 未中籤」action(線控手動裁決);cascade 結果(落腳 / 重應募)可視。待人工安置列(exhausted)顯示 [手動安置](開 dialog 選同 trip 任意梯 force-place)+ [取消退款](cancelLotteryLostOrder);applied/lost 單亦可主動「手動轉團」(客人來電指定梯)→ 同一 force-place dialog。trip.manage / order.update),不新增 resource;所有 write action 受 module_lottery gate fail-closed。trip 任意梯(沿用 snapshot 同價、零價差)。真正換產品(跨行程、不同登山分級 / 證件需求、價格基礎不同)走既有「取消 + 退款 + 重新建單」,不硬塞進 cascade 引擎。