線稿索引所有 spec最後更新 2026-07-04
specs/climbing-screening.md

登山分級審核器

一句話:報名階段依「行程難度」對照「客人登山能力」做資格篩選,攔截越級報名;判定不過不硬擋,而是警示並轉人工審核。

🟩 登山模組 — 受 模組化 的「登山」開關 gate(租戶層 module_climbing + 行程層 trips.is_climbing 皆開才生效;否則 runOrderScreeningnot_required、UI 不顯示)。

畫面(UI = wireframe,本功能為共用畫面上的 delta):order-detail(資格審核狀態列)· control(標紅)· orders-review

能力來源 = 客人自填為主 + ERP 訂單歷史輔助;判定不過 = 警示 + 轉人工審核(不硬擋)。演算法還原自現行 GS「登山分級-審核器」分頁,見 legacy-gs-gap §2.1。

  • 關聯會員與業務歸屬(報名歷史 / 會員層)· 行程定價/內容(trip 欄位同 PR)· 訂單派發(審核轉人工流程仿 review)
  • 對應財務:模組 4「資格檢驗:以審核器審核,判斷不了才人工」;影響矩陣「會員/體能安全篩選」自評 impact 4(本批最高)。

1. 問題與動機

專業戶外行程有安全風險,必須擋掉「資歷不符的客人報高難度團」。現行 ERP 下單完全無資格檢查——任何人都能直接報能高安東軍、雪季行程。現行作業靠一張 GS「審核器」分頁:業務把客人過往爬過的山貼進去,公式比對行程難度,吐出「通過 / 不通過 / 缺乏高山經驗」+ 分級訊息。痛點:

  1. ERP 無對應:審核器只活在 GS,下單系統不認得;K 單 / 前台下單都不會觸發。
  2. 能力資料無沉澱:客人爬過什麼,每次重貼,沒有累積成客戶畫像(也是財務第三大瓶頸「缺過往報名紀錄」的一環)。
  3. 人工判斷不一致:沒有規則引擎時,是否該擋全憑業務經驗。

解法:把審核器演算法落進 ERP,下單時自動跑;能力分由「客人自填登山史 + ERP 完成訂單」共同算;判不過不硬擋,而是標記 needs_review 進人工審核佇列(對齊財務「判斷不了才人工」與既有訂單派發 review 流程)。


2. 範圍

在範圍內

  • 行程難度欄trips 加「評鑑分數」+「是否需高山經驗」+「分級標籤」。
  • 登山能力記錄:新 member_climbing_records 表,自填(客人申報過往登山,含他社/自組)+ 自動(ERP 完成訂單自動寫一筆,連回 trip 難度)。
  • 能力分演算法resolveClimbingAbility(records) 純函式(GS 公式:最高 + 次高×0.3 + 第三高×0.1 + 1)。
  • 審核演算法screenOrder(tripGrade, ability, hasAlpine) 純函式 → 硬閘(高山經驗)+ 軟分級(能力 − 難度 四級)。
  • 下單觸發 + 審核佇列:下單時跑審核,結果寫 order_screeningsneeds_review/admin/orders/review(沿用既有 review 頁加一個 tab / filter),op/admin 人工核可或駁回。
  • 角色權限:新 screening resource(read / review)。
  • 前台/ K 單接點:下單流程顯示審核結果(前台:自填登山史 + 即時分級提示;K 單:業務看到審核 badge)。

明確不做(Future / Out of scope)

  • 硬擋下單:本 spec 一律「警示 + 轉人工」,在 checkout 阻擋(user 已拍板)。日後若某些行程要硬擋,留 trips.screening_hard_block 佔位欄(本 spec 不建、§11 未決登錄)。
  • 體能 / 健康問卷評分:只看「登山經歷難度」,不做心肺 / BMI / 病史評分。
  • 嚮導手動覆寫難度 per 梯次:難度掛 trips(route 層);梯次層覆寫留 future。
  • 自動匹配自填行程到難度資料庫:自填行程的難度由客人/業務手動選分級或留空(留空 = 不計入能力分、僅供參考)。智慧比對行程名 → 難度留 future。
  • 歷史回填:既有完成訂單不自動回填 member_climbing_records(上線後新完成的才寫;要回填另跑一次性 script,列 §11)。

3. 資料模型

對齊現行慣例:穩定業務欄位 / 新表直接進 packages/core/src/schema/business.ts(不走 setup-extras ALTER);主鍵 ULID text;timestamp 帶 tz。難度分數是評等(含小數),用 real金額,不套整數 TWD 慣例)。

3.1 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 行程。

3.2 新表 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_uniqueorder_id 唯一(NULL 不受限,多筆 self 可共存),保證「completed 自動寫」idempotent。

3.3 新表 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),
  ],
);

3.4 ER 關係

trips.grade_score / requires_alpine_experience  ─┐ (報名當下快照)
                                                 ▼
orders (1) ──(1)── order_screenings   [verdict / 快照 / 人工核可]

user (1) ──(N)── member_climbing_records   [self + order 來源]
                         ▲ orderId (order 來源去重)

4. 演算法

packages/core/src/screening/algorithm.ts,純函式,不碰 db(好測),可直接抄 GS。

4.1 能力分 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;
}

4.2 高山經驗 hasAlpineExperience

/** 任一記錄認列高山經驗 → true。(GS 的 C18:登山史裡有勾「高山」者。)*/
export function hasAlpineExperience(records: Array<{ hasAlpineExperience: boolean }>): boolean {
  return records.some((r) => r.hasAlpineExperience);
}

4.3 審核 screenOrder

export 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,且日後可調)。


5. 流程

5.1 下單觸發(前台 + K 單)

  1. 解析下單客戶的 userId → 查其 member_climbing_records(含本次自填的)。
  2. ability = resolveClimbingAbility(records)alpine = hasAlpineExperience(records)
  3. result = screenOrder({ tripGradeScore, requiresAlpine, ability, customerHasAlpine: alpine })
  4. 建單 transaction 內寫 order_screenings(verdict / 快照 / message)+ writeAuditLog('order.screen', metadata=result)
  5. 不阻擋建單:訂單照常成立。verdict='needs_review' 時,order 在 review 佇列亮「待審核」(與付款 / 派發狀態並存)。

與付款的關係:審核擋收款(避免卡金流)。但 needs_review 的單,團控 / order-detail 顯著標紅,主管可在出團前處理(補經歷、勸退、改報簡單團 → 走退改)。

5.2 自填登山史

  • 前台:checkout(或會員中心)一個「我的登山經歷」區,客人增列:行程名 + 可選分級(下拉「健行 / 一般高山 / 進階 / 技術」對應分數帶)+ 是否高山。→ addClimbingRecordAction(source='self')。
  • K 單:業務在 orders-new 客人區可代填 / 編輯該客的登山史(同 action,createdByUserId=業務)。

5.3 自動沉澱(完成訂單 → 記錄)

  • 訂單轉 completed 時(既有狀態轉移點),若該 trip 有 grade_scoreupsert 一筆 member_climbing_records(source='order', orderId, label=trip.title, gradeScore, hasAlpineExperience=trip.requiresAlpine, climbedOn=departure_date)。order_unique 保證 idempotent。

5.4 人工審核

  • /admin/orders/review(既有派發審核頁)加一個 「資格審核」分頁 / filter:分頁為 待審核(needs_review)/ 待回覆follow_up_state='awaiting_reply')/ 已核可 / 已駁回。列出對應單,顯示客人能力分 vs 行程難度、message、登山史摘要。
  • op/admin 點「核可」→ verdict='manual_approved' + note + audit(inline);點「駁回」→ 開三分支處置 dialog(見 §5.5)。

5.5 駁回三分支處置 + 追蹤閉環

對齊 SOP 受理階段第 8 步。駁回不擋單、不自動退單,而是擇一後續處置,系統寄對應「不通過」罐頭訊息並開追蹤:

disposition意義罐頭範本預選 key(慣例)follow_up_state
resupplement補件重審SCREENING_REJECT_RESUPPLEMENTawaiting_reply
switch_trip換行程SCREENING_REJECT_SWITCHawaiting_reply
refund退費SCREENING_REJECT_REFUNDnull(不追蹤)
  • 欄位(order_screenings):disposition text nullable(app 層驗證,不加 DB CHECK enum,保未來擴充處置種類的彈性)、disposition_notedisposition_atfollow_up_state text nullable(awaiting_reply / resolved)。
  • 送出 server action 同 tx:寫 verdict='manual_rejected' + disposition/note/at + 派生 follow_up_statefollowUpForDisposition:補件 / 換團→awaiting_reply、退費→null)+ reviewedBy/At + audit(order.screen_review);再 best-effort 寄承辦選定的罐頭訊息(by template id → email_log 留痕),寄送失敗不回滾駁回。
  • 罐頭範本 picker(多租戶鐵則):Select 列 active email 範本,依 disposition 的慣例 key 預選(找不到對應範本則退回「不寄信」);實際寄哪則由承辦自選,key 對應不寫死。
  • 追蹤閉環awaiting_reply 的單進「待回覆」分頁;客人回覆並完成處置後承辦點「標記已解決」→ follow_up_state='resolved'order.screen_followup_resolved audit)。退費分支不開追蹤(走既有退款流程)。

6. Server Actions 與存取層

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>

7. 角色與權限

走 better-auth createAccessControlpackages/core/src/permissions-ac.ts)。新增一個 screening resource。requirePermission('screening.review') first-dot split = resource screening / action review

// statement catalog 新增:
screening: ["read", "review"],
// 編難度欄走既有 statement.trip 的 "manage"(catalog 已有 trip: ['read','manage'],不需新增)
動作permission授權角色
看審核結果 / 佇列screening.readadmin、op、sales(只看自己訂單的)
人工核可 / 駁回screening.reviewadmin、op
編行程難度欄trip.manage(trip-pricing / dynamic-rbac 已有)admin、op
自填 / 編登山史客人本人(自己的);業務代填 = order.create(K 單情境)customer(self)、sales、admin
  • admin 因攤平整個 catalog 自動涵蓋;screening.* 仍須先註冊進 catalogtrip.manage catalog 已有(不需新增)。
  • sales 不得 screening.review(與「不准自審 / 搶單」一致);可 read 自己客人的審核結果。
  • 客人自填登山史 = 改自己的資料,走會員自助路徑(非 admin 端點)。

8. UI

  • trip-edit(共用,delta):基本資料區加「難度評鑑分數 / 分級標籤 / 需高山經驗」欄(與 trip-pricing 的價格欄同一 card 群)。
  • orders-new(已直接編輯):客人區加「登山經歷」可增列 + 選行程後即時顯示審核 badge(通過 / 待審核 + 原因)。
  • 前台 checkout / 會員中心:「我的登山經歷」自填區;選高難度行程時即時提示分級結果(不擋送出)。
  • order-detail(共用,delta):加「資格審核」狀態列(通過 / 待審核 / 已核可 / 已駁回 + 能力分 vs 難度 + message);駁回後顯示後續處置 badge + 追蹤態(待客人回覆時可「標記已解決」);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


9. 測試

9.1 vitest(純函式 + repo)

  • 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)。

9.2 Playwright

  • 新客人(無登山史)報高難度團 → 訂單成立但標「待審核」;review 頁出現該單。
  • 自填足夠經歷 → 同團 auto_pass。
  • 需高山經驗團 + 客人無高山記錄 → needs_review「缺乏高山經驗」。
  • op 核可 → 狀態變「已核可」;sales 嘗試核可 → 403 / 按鈕不出現。
  • 完成一筆訂單 → 該客登山史自動多一筆(source=order)。

10. 影響範圍

類別數量細節
trips 新欄3grade_score / requires_alpine_experience / grade_label(drizzle,與 trip-pricing 同 PR)
新表2member_climbing_recordsorder_screenings(drizzle)
新 permission2screening.read / screening.review(編難度欄沿用既有 trip.manage,不需新增)
新 repo / 純函式2packages/core/src/screening/algorithm.tspackages/core/src/screening/repo.ts
新 server actions~4add/delete climbing record · review screening(+ 建單 hook + completed hook)
改既有 page4orders-new · order-detail · orders/review · control(標紅)
改既有流程2下單建單 transaction 加 runOrderScreeningmarkOrderCompletedsink...
測試unit + 1 E2E flow~16 vitest

不影響:付款 / 綠界(審核不擋金流);訂單派發規則(review 頁只是多一個 tab);既有無難度的行程(grade NULL → not_required,行為等同今日)。


11. 未決事項

項目預設待釐清
硬擋下單開關不做(一律警示+人工)是否要 trips.screening_hard_block 給雪季 / 技術攀登?
自填分級的下拉帶值提供 4 級對應分數(健行 / 一般高山 / 進階 / 技術)分數帶與 trip.grade_score 同尺規?需定義分級 → 分數對照
既有完成單回填登山史不自動回填是否跑一次性 backfill script
能力分時間衰減不做(爬過就算)是否「N 年前的經歷打折」?GS 無此邏輯,先不做
難度評分來源人工填 trips.grade_score是否引入標準分級表(百岳難度資料庫)自動帶
駁回後訂單處置只標 manual_rejected + 提示業務是否自動觸發退訂 / 改團流程

12. 連結

  • 依賴:訂單派發(review 頁)、行程定價/內容(trips 欄位同 PR)、會員與業務歸屬(報名歷史耦合)
  • 來源:legacy-gs-gap §2.1(GS 審核器公式還原)
  • 既有接點:packages/core/src/orders/repo.ts(建單 transaction)、markOrderCompleted(completed hook)、/admin/orders/review

Changelog

版本日期變更Commit
12026-05-30初版:GS 審核器落 ERP(自填+歷史能力分、警示+人工審核)
22026-06-20對齊現行 catalog:trip.update 全改 trip.manage(編難度欄走既有 trip: ['read','manage'],trip.update action 不存在亦不新增)
32026-07-02核心 + 三處接線落地completeDepartureInTx 梯次結團沉澱登山史(confirmed→completed,冪等)、會員中心 /member/climbing 自填登山史(K 單代填)、團控 needs_review 標紅(getDepartureScreeningSummary + climbing gate)。
42026-07-04駁回三分支處置 + 追蹤閉環(§5.5)落地order_screeningsdisposition / disposition_note / disposition_at / follow_up_state(migration 0049);駁回開三分支 dialog(補件 / 換團 / 退費)+ 罐頭範本 picker(依 disposition 慣例 key 預選、不寫死)+ best-effort 寄信入 email_log;review 頁加「待回覆」分頁 + 標記已解決。訂單明細右欄加 SOP 快速動作(罐頭訊息 / 尾款繳款單 / 列印名冊)。