一句話:報名階段依「行程難度」對照「客人登山能力」做資格篩選,攔截越級報名;判定不過不硬擋,而是警示並轉人工審核。
🟩 登山模組 — 受 模組化 的「登山」開關 gate(租戶層
module_climbing+ 行程層trips.is_climbing皆開才生效;否則runOrderScreening回not_required、UI 不顯示)。畫面(UI = wireframe,本功能為共用畫面上的 delta):
order-detail(資格審核狀態列)·control(標紅)·orders-review
能力來源 = 客人自填為主 + ERP 訂單歷史輔助;判定不過 = 警示 + 轉人工審核(不硬擋)。演算法還原自現行 GS「登山分級-審核器」分頁,見 legacy-gs-gap §2.1。
專業戶外行程有安全風險,必須擋掉「資歷不符的客人報高難度團」。現行 ERP 下單完全無資格檢查——任何人都能直接報能高安東軍、雪季行程。現行作業靠一張 GS「審核器」分頁:業務把客人過往爬過的山貼進去,公式比對行程難度,吐出「通過 / 不通過 / 缺乏高山經驗」+ 分級訊息。痛點:
解法:把審核器演算法落進 ERP,下單時自動跑;能力分由「客人自填登山史 + ERP 完成訂單」共同算;判不過不硬擋,而是標記 needs_review 進人工審核佇列(對齊財務「判斷不了才人工」與既有訂單派發 review 流程)。
trips 加「評鑑分數」+「是否需高山經驗」+「分級標籤」。member_climbing_records 表,自填(客人申報過往登山,含他社/自組)+ 自動(ERP 完成訂單自動寫一筆,連回 trip 難度)。resolveClimbingAbility(records) 純函式(GS 公式:最高 + 次高×0.3 + 第三高×0.1 + 1)。screenOrder(tripGrade, ability, hasAlpine) 純函式 → 硬閘(高山經驗)+ 軟分級(能力 − 難度 四級)。order_screenings;needs_review 進 /admin/orders/review(沿用既有 review 頁加一個 tab / filter),op/admin 人工核可或駁回。screening resource(read / review)。trips.screening_hard_block 佔位欄(本 spec 不建、§11 未決登錄)。trips(route 層);梯次層覆寫留 future。member_climbing_records(上線後新完成的才寫;要回填另跑一次性 script,列 §11)。對齊現行慣例:穩定業務欄位 / 新表直接進
packages/core/src/schema/business.ts(不走 setup-extras ALTER);主鍵 ULIDtext;timestamp 帶 tz。難度分數是評等(含小數),用real(非金額,不套整數 TWD 慣例)。
trips 加難度欄(drizzle schema)// packages/core/src/schema/business.ts — trips 表 append(與 trip-pricing 的 trips 欄位同一 PR 進 schema,避免 migration 衝突)
/** 評鑑分數(難度)。越高越難。對照客人能力分做分級。NULL = 未評級(不審核,視同「無須審核」)。 */
gradeScore: real("grade_score"),
/** 是否需具備「高山經驗」才可報名(硬閘)。 */
requiresAlpineExperience: boolean("requires_alpine_experience").notNull().default(false),
/** 人類可讀分級標籤(如「C+」「健行級」),前台 / 後台顯示用,可選。 */
gradeLabel: text("grade_label"),
為何難度掛
trips不掛departures:難度是「路線」屬性(玉山主峰就是這個難度),不是梯次屬性。梯次差異(雪季更難)留 future override。對齊 GS——審核器查表是 per 行程。
member_climbing_records(登山能力記錄)/**
* 會員登山經歷記錄(能力分的輸入)。兩個來源:
* - source='order':ERP 完成訂單自動寫一筆,gradeScore 取自該 trip.grade_score,
* orderId 連回。完成(status='completed')時寫入,取消不寫。
* - source='self':客人 / 業務自填過往登山(可能是他社團、自組隊)。gradeScore
* 由填寫者手動選分級或留 NULL(NULL = 僅參考、不計入能力分)。
*
* 掛 user.id(同庫 better-auth 帳號,對齊「同庫 user FK」)。一個 user 多筆。
*/
export const memberClimbingRecords = pgTable(
'member_climbing_records',
{
id: text('id').primaryKey(), // 'clr_<ulid>'
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/** 行程 / 山岳名稱(自填為自由文字;order 來源 = trip.title 快照)。 */
label: text('label').notNull(),
/** 難度分數。NULL = 未知(self 來源可留空,不計入能力分)。 */
gradeScore: real('grade_score'),
/** 此筆是否認列為「高山經驗」(硬閘用)。order 來源依 trip.requires_alpine_experience。 */
hasAlpineExperience: boolean('has_alpine_experience').notNull().default(false),
source: text('source').notNull(), // 'order' | 'self'
/** source='order' 時連回訂單(去重 + 可追溯);self 為 NULL。 */
orderId: text('order_id').references(() => orders.id, { onDelete: 'set null' }),
climbedOn: date('climbed_on'), // 約略完成日期(order 來源 = departure_date)
/** 自填者 / 寫入者(self = 客人本人或代填業務;order = 系統 NULL)。 */
createdByUserId: text('created_by_user_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('member_climbing_records_user_idx').on(t.userId, t.climbedOn),
// 同一訂單只自動寫一筆(防 completed 重跑重複寫)。
uniqueIndex('member_climbing_records_order_unique').on(t.orderId),
],
);
member_climbing_records_order_unique對order_id唯一(NULL 不受限,多筆 self 可共存),保證「completed 自動寫」idempotent。
order_screenings(審核結果,1:1 訂單)/**
* 訂單的審核器判定結果(每張單一筆)。下單時計算寫入;needs_review 進人工佇列。
* 保留判定當下的快照(難度 / 能力分),即使日後 trip 難度改了也不變。
*/
export const orderScreenings = pgTable(
'order_screenings',
{
id: text('id').primaryKey(), // 'scr_<ulid>'
orderId: text('order_id')
.notNull()
.references(() => orders.id, { onDelete: 'cascade' }),
/** 報名當下行程難度快照(NULL = 行程未評級 → verdict='not_required')。 */
tripGradeScore: real('trip_grade_score'),
/** 計算出的客人能力分快照。 */
abilityScore: real('ability_score'),
/** 硬閘:是否通過高山經驗要求。 */
alpineGatePassed: boolean('alpine_gate_passed').notNull().default(true),
/** 軟分級 1-4(1=輕鬆通過 … 4=能力不足)。NULL = not_required。 */
softBand: integer('soft_band'),
/**
* 'not_required' 行程未評級
* 'auto_pass' 自動通過(硬閘過 + 軟分級 1/2)
* 'needs_review' 待人工(硬閘不過 或 軟分級 3/4)
* 'manual_approved' 人工核可放行
* 'manual_rejected' 人工駁回
*/
verdict: text('verdict').notNull(),
/** 對客 / 對內訊息(演算法產生,如「❌缺乏高山經驗」「建議評估體能」)。 */
message: text('message'),
reviewedByUserId: text('reviewed_by_user_id').references(() => user.id, {
onDelete: 'set null',
}),
reviewNote: text('review_note'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('order_screenings_order_unique').on(t.orderId),
index('order_screenings_verdict_idx').on(t.verdict),
],
);
trips.grade_score / requires_alpine_experience ─┐ (報名當下快照)
▼
orders (1) ──(1)── order_screenings [verdict / 快照 / 人工核可]
user (1) ──(N)── member_climbing_records [self + order 來源]
▲ orderId (order 來源去重)
放 packages/core/src/screening/algorithm.ts,純函式,不碰 db(好測),可直接抄 GS。
resolveClimbingAbility/**
* 依登山記錄算能力分(GS:最高 + 次高×0.3 + 第三高×0.1 + 1)。
* 只取有 gradeScore 的記錄(self 留空者不計)。不足三筆時缺項以 0 計。
* 空記錄 → 回 1(基底,等同新手)。
*/
export function resolveClimbingAbility(records: Array<{ gradeScore: number | null }>): number {
const scores = records
.map((r) => r.gradeScore)
.filter((s): s is number => s != null)
.sort((a, b) => b - a); // desc
const [a = 0, b = 0, c = 0] = scores;
return a + b * 0.3 + c * 0.1 + 1;
}
hasAlpineExperience/** 任一記錄認列高山經驗 → true。(GS 的 C18:登山史裡有勾「高山」者。)*/
export function hasAlpineExperience(records: Array<{ hasAlpineExperience: boolean }>): boolean {
return records.some((r) => r.hasAlpineExperience);
}
screenOrderexport interface ScreenResult {
verdict: 'not_required' | 'auto_pass' | 'needs_review';
alpineGatePassed: boolean;
softBand: 1 | 2 | 3 | 4 | null;
message: string;
}
/**
* GS 還原:
* - trip 未評級(gradeScore 為 NULL)或 <=1 → not_required(「無須審核」)。
* - 硬閘:trip 需高山經驗且客人無 → alpineGatePassed=false → needs_review(「❌缺乏高山經驗」)。
* - 軟分級:diff = ability - tripGrade
* >= 0.2 → band 1(輕鬆通過)
* >= -0.3 → band 2(通過)
* >= -0.5 → band 3(勉強,建議評估)→ needs_review
* else → band 4(能力不足)→ needs_review
* - auto_pass = 硬閘過 且 band ∈ {1,2};其餘 needs_review。
*/
export function screenOrder(input: {
tripGradeScore: number | null;
requiresAlpine: boolean;
ability: number;
customerHasAlpine: boolean;
}): ScreenResult;
門檻常數(
0.2 / -0.3 / -0.5)抽成SCREENING_THRESHOLDS具名常數(對齊 GS,且日後可調)。
userId → 查其 member_climbing_records(含本次自填的)。ability = resolveClimbingAbility(records)、alpine = hasAlpineExperience(records)。result = screenOrder({ tripGradeScore, requiresAlpine, ability, customerHasAlpine: alpine })。order_screenings(verdict / 快照 / message)+ writeAuditLog('order.screen', metadata=result)。verdict='needs_review' 時,order 在 review 佇列亮「待審核」(與付款 / 派發狀態並存)。與付款的關係:審核不擋收款(避免卡金流)。但
needs_review的單,團控 / order-detail 顯著標紅,主管可在出團前處理(補經歷、勸退、改報簡單團 → 走退改)。
addClimbingRecordAction(source='self')。createdByUserId=業務)。completed 時(既有狀態轉移點),若該 trip 有 grade_score,upsert 一筆 member_climbing_records(source='order', orderId, label=trip.title, gradeScore, hasAlpineExperience=trip.requiresAlpine, climbedOn=departure_date)。order_unique 保證 idempotent。/admin/orders/review(既有派發審核頁)加一個 「資格審核」分頁 / filter:分頁為 待審核(needs_review)/ 待回覆(follow_up_state='awaiting_reply')/ 已核可 / 已駁回。列出對應單,顯示客人能力分 vs 行程難度、message、登山史摘要。verdict='manual_approved' + note + audit(inline);點「駁回」→ 開三分支處置 dialog(見 §5.5)。對齊 SOP 受理階段第 8 步。駁回不擋單、不自動退單,而是擇一後續處置,系統寄對應「不通過」罐頭訊息並開追蹤:
| disposition | 意義 | 罐頭範本預選 key(慣例) | follow_up_state |
|---|---|---|---|
resupplement | 補件重審 | SCREENING_REJECT_RESUPPLEMENT | awaiting_reply |
switch_trip | 換行程 | SCREENING_REJECT_SWITCH | awaiting_reply |
refund | 退費 | SCREENING_REJECT_REFUND | null(不追蹤) |
order_screenings):disposition text nullable(app 層驗證,不加 DB CHECK enum,保未來擴充處置種類的彈性)、disposition_note、disposition_at、follow_up_state text nullable(awaiting_reply / resolved)。verdict='manual_rejected' + disposition/note/at + 派生 follow_up_state(followUpForDisposition:補件 / 換團→awaiting_reply、退費→null)+ reviewedBy/At + audit(order.screen_review);再 best-effort 寄承辦選定的罐頭訊息(by template id → email_log 留痕),寄送失敗不回滾駁回。awaiting_reply 的單進「待回覆」分頁;客人回覆並完成處置後承辦點「標記已解決」→ follow_up_state='resolved'(order.screen_followup_resolved audit)。退費分支不開追蹤(走既有退款流程)。放 packages/core/src/screening/repo.ts + actions.ts;first arg db: ScopedDb;result-object 錯誤;transaction + writeAuditLog。
// 下單時呼叫(建單 transaction 內,不獨立 action)
runOrderScreening(db, { orderId, userId, trip }): Promise<ScreenResult>
addClimbingRecordAction(input: {
userId: string; label: string; gradeScore?: number | null;
hasAlpineExperience?: boolean; climbedOn?: string | null;
}): Promise<{ ok: true; recordId: string } | { ok: false; reason: 'INVALID_GRADE' }>
deleteClimbingRecordAction(recordId): Promise<{ ok: true } | { ok: false; reason: 'RECORD_NOT_FOUND' }>
// self 來源可刪;order 來源不可刪(系統沉澱,擋)→ reason: 'AUTO_RECORD_READONLY'
reviewScreeningAction(input: {
orderId: string; decision: 'approve' | 'reject'; note?: string;
}): Promise<{ ok: true } | { ok: false; reason: 'NOT_PENDING' | 'ORDER_NOT_FOUND' }>
// 核可路徑(decision='approve');僅 verdict='needs_review' 可審;寫 reviewedByUserId / reviewedAt / verdict / audit
rejectScreeningAction(fd: FormData): Promise<{ ok; error?; emailSent? }>
// 駁回三分支(§5.5):disposition 必選(zod fail-closed)+ 選填 templateId(罐頭範本)+ note
// rejectScreeningWithDisposition(同 tx 寫 disposition/follow_up_state)+ best-effort 寄範本
resolveScreeningFollowUpAction(fd: FormData): Promise<{ ok; error? }>
// 追蹤閉環:follow_up_state 'awaiting_reply' → 'resolved';screening.review 守門
// completed 轉移點呼叫(既有 markOrderCompleted 內 hook)
sinkCompletedOrderToClimbingHistory(db, orderId): Promise<void>
走 better-auth
createAccessControl(packages/core/src/permissions-ac.ts)。新增一個screeningresource。requirePermission('screening.review')first-dot split = resourcescreening/ actionreview。
// statement catalog 新增:
screening: ["read", "review"],
// 編難度欄走既有 statement.trip 的 "manage"(catalog 已有 trip: ['read','manage'],不需新增)
| 動作 | permission | 授權角色 |
|---|---|---|
| 看審核結果 / 佇列 | screening.read | admin、op、sales(只看自己訂單的) |
| 人工核可 / 駁回 | screening.review | admin、op |
| 編行程難度欄 | trip.manage(trip-pricing / dynamic-rbac 已有) | admin、op |
| 自填 / 編登山史 | 客人本人(自己的);業務代填 = order.create(K 單情境) | customer(self)、sales、admin |
admin 因攤平整個 catalog 自動涵蓋;screening.* 仍須先註冊進 catalog,trip.manage catalog 已有(不需新增)。sales 不得 screening.review(與「不准自審 / 搶單」一致);可 read 自己客人的審核結果。needs_review 有權限者含核可 / 駁回三分支 dialog。右欄快速動作加 SOP 三顆:生成 / 傳送罐頭訊息(範本 picker → 預覽 → 寄,入 email_log)、寄尾款繳款單(deposit_paid 未結清時,複用補尾款連結 + 寄含連結罐頭信)、列印旅客名冊(連團控 rooms/print)。/admin/orders/review(既有):加「資格審核」tab(待審核 / 待回覆 / 已核可 / 已駁回),提供核可(inline)/ 駁回三分支 dialog / 追蹤閉環。/admin/control:旅客列對 needs_review 的單加紅標(出團前提醒)。色彩:通過 badge--success、待審核 badge--warning、駁回 badge--destructive、無須審核 badge--secondary。
resolveClimbingAbility:空 → 1;單筆 → score+1;三筆權重(最高+次×0.3+三×0.1+1);>3 筆只取前三;NULL 分數不計。hasAlpineExperience:任一 true → true;全 false / 空 → false。screenOrder:trip 未評級 → not_required;硬閘不過 → needs_review + alpineGatePassed=false;軟分級四個 band 邊界(0.2 / -0.3 / -0.5);auto_pass 僅 band 1/2 且硬閘過。runOrderScreening:寫入 order_screenings 快照正確;trip 無 grade → not_required。sinkCompletedOrderToClimbingHistory:completed 寫一筆;重跑不重複(order_unique);取消單不寫。reviewScreeningAction:approve / reject 改 verdict + reviewedBy;非 needs_review → NOT_PENDING。deleteClimbingRecordAction:self 可刪、order 來源擋(AUTO_RECORD_READONLY)。| 類別 | 數量 | 細節 |
|---|---|---|
trips 新欄 | 3 | grade_score / requires_alpine_experience / grade_label(drizzle,與 trip-pricing 同 PR) |
| 新表 | 2 | member_climbing_records、order_screenings(drizzle) |
| 新 permission | 2 | screening.read / screening.review(編難度欄沿用既有 trip.manage,不需新增) |
| 新 repo / 純函式 | 2 | packages/core/src/screening/algorithm.ts、packages/core/src/screening/repo.ts |
| 新 server actions | ~4 | add/delete climbing record · review screening(+ 建單 hook + completed hook) |
| 改既有 page | 4 | orders-new · order-detail · orders/review · control(標紅) |
| 改既有流程 | 2 | 下單建單 transaction 加 runOrderScreening;markOrderCompleted 加 sink... |
| 測試 | unit + 1 E2E flow | ~16 vitest |
不影響:付款 / 綠界(審核不擋金流);訂單派發規則(review 頁只是多一個 tab);既有無難度的行程(grade NULL → not_required,行為等同今日)。
| 項目 | 預設 | 待釐清 |
|---|---|---|
| 硬擋下單開關 | 不做(一律警示+人工) | 是否要 trips.screening_hard_block 給雪季 / 技術攀登? |
| 自填分級的下拉帶值 | 提供 4 級對應分數(健行 / 一般高山 / 進階 / 技術) | 分數帶與 trip.grade_score 同尺規?需定義分級 → 分數對照 |
| 既有完成單回填登山史 | 不自動回填 | 是否跑一次性 backfill script |
| 能力分時間衰減 | 不做(爬過就算) | 是否「N 年前的經歷打折」?GS 無此邏輯,先不做 |
| 難度評分來源 | 人工填 trips.grade_score | 是否引入標準分級表(百岳難度資料庫)自動帶 |
| 駁回後訂單處置 | 只標 manual_rejected + 提示業務 | 是否自動觸發退訂 / 改團流程 |
packages/core/src/orders/repo.ts(建單 transaction)、markOrderCompleted(completed hook)、/admin/orders/review| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-30 | 初版:GS 審核器落 ERP(自填+歷史能力分、警示+人工審核) | — |
| 2 | 2026-06-20 | 對齊現行 catalog:trip.update 全改 trip.manage(編難度欄走既有 trip: ['read','manage'],trip.update action 不存在亦不新增) | — |
| 3 | 2026-07-02 | 核心 + 三處接線落地:completeDepartureInTx 梯次結團沉澱登山史(confirmed→completed,冪等)、會員中心 /member/climbing 自填登山史(K 單代填)、團控 needs_review 標紅(getDepartureScreeningSummary + climbing gate)。 | — |
| 4 | 2026-07-04 | 駁回三分支處置 + 追蹤閉環(§5.5)落地:order_screenings 加 disposition / disposition_note / disposition_at / follow_up_state(migration 0049);駁回開三分支 dialog(補件 / 換團 / 退費)+ 罐頭範本 picker(依 disposition 慣例 key 預選、不寫死)+ best-effort 寄信入 email_log;review 頁加「待回覆」分頁 + 標記已解決。訂單明細右欄加 SOP 快速動作(罐頭訊息 / 尾款繳款單 / 列印名冊)。 | — |