線稿索引所有 spec最後更新 2026-07-03
specs/order-assignment.md

訂單派發機制

一句話:訂單派發提供兩種模式(自動 / 主管審核)讓 tenant 自選,建立 sales attribution 的底層機制(assigned_to_user_id)供 dashboard / leaderboard / commission 使用。

1. 問題與動機

1.1 現況

  • 業績歸屬目前依賴 orders.created_by_user_id
  • 只有 K 單(/admin/orders/new 由業務手動建立)有值
  • 客戶自助結帳建立的訂單 → created_by_user_id = null,沒有業務歸屬
  • /admin/leaderboard 直接 join created_by_user_id 排行 → 客戶自建單完全不計

1.2 經理需求

  • 客戶自建單也要派業務(讓 sales 跑得到客戶自建的訂單業績)
  • 不同公司可選擇 自動派發(不需主管介入)或 主管審核(線控人工派)
  • 這是 S1(角色化 dashboard)、S2(請款單佣金)、/admin/leaderboard 的共同前置依賴

1.3 為什麼不直接 reuse created_by_user_id

  • created_by_user_id 語意是「誰建立訂單」(K 單建單者 / 自助為 null)
  • assigned_to_user_id 語意是「業績歸屬」(可被重新指派 / 一定要有值才計業績)
  • 兩者重疊但不完全相同(K 單建單者 = 建立者 = 預設受派者;客戶自建單建立者 = null 但仍可被派發)
  • 拆兩個欄位讓未來「重派」「離職換手」可乾淨處理 + 保留 audit trail

2. 模式設計

2.1 模式 A:自動派發 (assignment_mode='auto')

三個子模式:

子模式行為範圍
auto_round_robin按建立順序輪流派給活躍業務現階段
auto_customer_choice客戶端 checkout 時可選擇業務(從 sales 清單下拉)現階段
auto_by_destination依梯次地區碼對應「地區 → 業務」規則指派現階段

auto_by_destination 規則設計

  • 對應鍵 = departures.region_code(group-code 的 region_codes catalog,per-tenant 自維護、不寫死任何租戶的目的地清單)。checkout 時以該梯次的 region_code 查表。
  • 規則表 assignment_destination_rulesregion_code 唯一 → assignee_user_id):region_code FK → region_codes.code(restrict,被引用的碼不可硬刪);assignee_user_id FK → user.idset null,業務離職 / 刪帳號 → 規則退化為未命中,走兜底)。
  • 派發判定:命中規則且該業務仍為 assignable staff(assignableStaffWhere:sales/admin 且未被 ban)→ 派給他(assigned)。梯次無 region_code / 查無規則 / assignee 已失效(set null 或 banned)→ 落兜底階梯default_assignee_user_id(若設)→ 否則 pending_review(與 round-robin 兜底同一階梯,assignment.tsfallbackLadder)。
  • 維護 UI/admin/settings 派發區塊的「地區 → 業務對應規則」表(region Combobox × 業務 Combobox,upsert 以 region_code 唯一 + 刪除)。audit tenant_settings.destination_rule.upsert / .delete

2.2 模式 B:主管審核派發 (assignment_mode='manual_review')

  • 新訂單 → status='pending_payment' + assignment_status='pending_review'
  • 線控 / 老闆在 /admin/orders/review 看到 unassigned 訂單清單
  • 手動指派業務 → assignment_status='assigned'
  • 預設模式(給沒設定的 tenant)

2.3 模式切換

  • 由 tenant 自設於 /admin/settings → 訂單派發分頁
  • 切換只影響「之後新建」訂單;不會回溯重派已有訂單
  • audit_log: tenant_settings.assignment_mode.change

3. 資料模型

3.1 擴 orders table

// packages/core/src/schema/business.ts orders (加 3 個欄位;schema 集中於此,無 packages/core/src/orders/schema.ts)
assignedToUserId: text('assigned_to_user_id').references(() => user.id),
// null = unassigned(pending_review 模式下,或 round-robin 無活躍業務時)

assignmentStatus: text('assignment_status').notNull().default('assigned'),
// 'pending_review' | 'assigned' | 'reassigned'
// 現階段:預設 'assigned' 代表「已派或不需派」
// K 單建單者立即視同已派;客戶自建單依 tenant.assignment_mode 決定

assignedAt: timestamp('assigned_at', { mode: 'date', withTimezone: true }),
assignedByUserId: text('assigned_by_user_id'), // null = 系統自動派 / 同 created_by

附 index:

  • (assignment_status, created_at)/admin/orders/review 查待派
  • (assigned_to_user_id, created_at) — sales 個人 dashboard / leaderboard

3.2 擴 tenant_settings table

// packages/core/src/schema/business.ts tenant_settings (加 2 個欄位)
assignmentMode: text('assignment_mode').notNull().default('manual_review'),
// 'auto_round_robin' | 'auto_customer_choice' | 'auto_by_destination' | 'manual_review'

defaultAssigneeUserId: text('default_assignee_user_id'),
// 後備:沒有活躍業務時的兜底指派對象(避免 null 卡 leaderboard)
// 通常設老闆 / 線控自己

allowSalesClaim: boolean('allow_sales_claim').notNull().default(false),
// per-tenant 開關:是否開放業務自派搶單(order.claim + /admin/orders/available)。
// 預設關;關閉時整條搶單功能 fail-closed(見 §6)。

3.3 不擴的資料表(明確說明理由)

想做為什麼跳過
sales_queue / sales_pool 動態表用同庫 "user" where role IN ('sales','admin') 直接查即可(auth 與商業表同庫);不需另一張表
assignment_rules 規則引擎太早 over-engineer,等真有「複雜路由」需求再做
Round-robin 計數持久化 table改用「last-assigned-at 排序,最久沒派的優先」derive 即可

4. 派發邏輯

4.1 派發決定函式

// packages/core/src/orders/assignment.ts(實際簽名:named args;回傳 3 欄,不含 assignedByUserId)
export type AssignmentDecision = {
  assignedToUserId: string | null;
  assignmentStatus: 'pending_review' | 'assigned';
  assignedAt: Date | null;
};

export async function decideAssignment(args: {
  mode: 'auto_round_robin' | 'auto_customer_choice' | 'manual_review';
  defaultAssigneeUserId: string | null;
  customerChoiceUserId?: string | null; // 僅 auto_customer_choice 用,會驗證 role
  db: ScopedDb; // round-robin 用來查 last-assigned-at
}): Promise<AssignmentDecision> {
  const now = new Date();

  if (args.mode === 'manual_review') {
    return { assignedToUserId: null, assignmentStatus: 'pending_review', assignedAt: null };
  }

  if (args.mode === 'auto_customer_choice') {
    // 客戶選的 sales 必須是同庫具 assignable role 的 user;否則 fail closed 落兜底
    const chosen = args.customerChoiceUserId
      ? await validateAssignee(args.db, args.customerChoiceUserId)
      : null;
    const target = chosen ?? args.defaultAssigneeUserId ?? null;
    return target
      ? { assignedToUserId: target, assignmentStatus: 'assigned', assignedAt: now }
      : { assignedToUserId: null, assignmentStatus: 'pending_review', assignedAt: null };
  }

  // auto_round_robin
  const next = await pickNextSalesInRoundRobin({ db: args.db });
  const target = next ?? args.defaultAssigneeUserId ?? null;
  return target
    ? { assignedToUserId: target, assignmentStatus: 'assigned', assignedAt: now }
    : { assignedToUserId: null, assignmentStatus: 'pending_review', assignedAt: null };
}

重要decideAssignment 不接 source / createdByUserId、回傳不含 assignedByUserId。「K 單建單者即受派者」與 assignedByUserId(actor)由呼叫端 / repo 層補:K 單路徑直接帶 created_by_user_id 進 order;客戶自建單走 decideAssignment,repo insert 時 assignedByUserId 設為系統派(null)。ASSIGNABLE_ROLES = ['sales','admin'](不含 owner)。

4.2 Round-robin 演算法

// 實際回傳 string | null(userId 或 null);候選池來自同庫 "user" role IN ('sales','admin')
async function pickNextSalesInRoundRobin(args: { db: ScopedDb }): Promise<string | null> {
  const db = args.db;
  const activeSales = await getActiveSalesUsers(db); // 同庫 "user" role IN ('sales','admin')
  if (activeSales.length === 0) return null;

  // 找每位 sales「最後一次被派的時間」,取最舊(含從未被派 → NULL 視為最舊)
  const lastAssignedMap = await db
    .select({
      userId: orders.assignedToUserId,
      lastAt: sql<Date>`max(${orders.assignedAt})`,
    })
    .from(orders)
    .where(
      inArray(
        orders.assignedToUserId,
        activeSales.map((s) => s.userId),
      ),
    )
    .groupBy(orders.assignedToUserId);

  const sorted = activeSales
    .map((s) => ({
      userId: s.userId,
      lastAt: lastAssignedMap.find((m) => m.userId === s.userId)?.lastAt ?? new Date(0),
    }))
    .sort((a, b) => a.lastAt.getTime() - b.lastAt.getTime());

  return sorted[0]?.userId ?? null;
}

4.3 整合點:客戶自助結帳

decideAssignment 實際在 src/app/(public)/checkout/actions.ts 呼叫(非 repo / service 層),結果透過 createOrderWithReservationpackages/core/src/orders/repo.ts)寫入訂單:

// src/app/(public)/checkout/actions.ts(節錄)
const db = await getCurrentTenantDb();
const settings = await getTenantSettings(db);
const mode = (settings.assignmentMode ?? 'manual_review') as AssignmentMode;
const assignment = await decideAssignment({
  mode,
  defaultAssigneeUserId: settings.defaultAssigneeUserId,
  customerChoiceUserId: null, // 現階段:尚無 customer-choice UI
  db,
});

await createOrderWithReservation(db, {
  // ... existing fields
  assignedToUserId: assignment.assignedToUserId,
  assignmentStatus: assignment.assignmentStatus,
  assignedAt: assignment.assignedAt,
  assignedByUserId: null, // 客戶自建單 → 系統派
});

4.4 K 單路徑(手動建單 action)

K 單建單者即受派者:建單 action 直接帶 created_by_user_id 進 order,並設 assignedToUserId = created_byassignmentStatus = 'assigned'assignedByUserId = created_by不經 decideAssignmentdecideAssignment 只處理客戶自建單的 mode 分派)。

5. 工作流

5.1 客戶 checkout 流程

模式UI
auto_round_robin客戶看不到業務選項;訂單後台自動指派
auto_customer_choice客戶在 checkout 看到「您的業務聯絡人」<Select>,列出活躍 sales(含照片 + 介紹 — 後期可加 /sales/[id] 個人頁)
manual_review客戶看不到業務選項;訂單建立後由 admin/op 在 review 頁派發

5.2 線控派發界面(/admin/orders/review

新頁面,列出 assignment_status='pending_review' 訂單。

頂部

  • 待派發訂單數 badge
  • Filter:行程 / 出發日 / 建立日期區間

Table 欄位

欄位內容
bulk select checkbox
訂單編號W26070600042 link → order-detail(格式 W{YYMMDD}{NNNNN}
客戶訂購人姓名 + phone
行程 / 出發日trip name + departure date
人數party_size
金額total_twd
建立時間created_at relative
派發dropdown 「指派給 sales」+「批次選擇後一鍵派發」

Bulk action toolbar(選 ≥1 筆後出現):

  • 指派給 → [sales dropdown] + 確認按鈕
  • 加註備註(optional)
  • 取消選取

Empty state:「沒有等待派發的訂單 — 所有訂單都有業務歸屬」

5.3 派發 server action

// src/app/admin/orders/review/actions.ts
'use server';

export async function assignOrderAction(input: {
  orderIds: string[]; // 支援 bulk
  assigneeUserId: string;
  reason?: string;
}) {
  const { userId: actorId } = await requirePermission('order.assign');
  const db = await getCurrentTenantDb();

  await db.transaction(async (tx) => {
    for (const orderId of input.orderIds) {
      const before = await tx.query.orders.findFirst({ where: eq(orders.id, orderId) });
      if (!before) continue;

      await tx
        .update(orders)
        .set({
          assignedToUserId: input.assigneeUserId,
          assignmentStatus:
            before.assignmentStatus === 'pending_review' ? 'assigned' : 'reassigned',
          assignedAt: new Date(),
          assignedByUserId: actorId,
        })
        .where(eq(orders.id, orderId));

      await writeAuditLog({
        action: before.assignmentStatus === 'pending_review' ? 'order.assign' : 'order.reassign',
        entityType: 'order',
        entityId: orderId,
        actorUserId: actorId,
        before: {
          assignedToUserId: before.assignedToUserId,
          assignmentStatus: before.assignmentStatus,
        },
        after: { assignedToUserId: input.assigneeUserId, assignmentStatus: 'assigned' },
        metadata: { reason: input.reason },
      });
    }
  });

  // Notify assignees (Plan 5 — Email/Telegram)
  await notifyAssignmentBatch(input.orderIds, input.assigneeUserId);
  revalidatePath('/admin/orders/review');
}

5.4 重新指派

/admin/orders/[id] 加「重新派發」按鈕(admin only):

  • 開 dialog → 選新業務 + 填理由
  • 走同一個 assignOrderAction,但內部判斷 before.assignmentStatus === 'assigned' → 標 reassigned
  • 通知新業務(接到單)
  • 通知舊業務(單被轉走)— 避免他不知道為什麼從 dashboard 消失

6. 角色權限

order resource 的 action 清單新增 review / assign / reassign / claim(statement catalog packages/core/src/permissions-ac.ts)。權限格式 resource.action(點),授權閘走 auth.api.userHasPermission

Permission對應動作Roles
order.review看 pending_review 清單(/admin/orders/reviewadmin, op
order.assign派發訂單admin, op
order.reassign重新派發訂單admin(catalog 有 reassign;op 實際授予 review/assign)
order.claim業務自派搶單(/admin/orders/availableadmin, sales

業務自派搶單(per-tenant 開關):由 tenant_settings.allow_sales_claim(boolean,預設 false)控制整條功能是否開放。sales 角色具 order.claim 能力,但實際可用與否取決於該租戶開關——關閉時整條功能不可見 / 不可用(nav 入口隱藏、/admin/orders/available fail-closed 顯示「未開放」、claimOrderAction 一律拒),即使持 order.claim 亦然(雙閘 fail-closed)。搶單不繞過審核閘sales 仍無 order.review,不得進 /admin/orders/review

搶單機制:業務於 /admin/orders/available(搶單池)看到尚未指派的 pending_review 訂單,最小欄位防 PII 洩漏(行程 / 出發日 / 人數 / 應收金額,不含客戶聯絡細節),點「搶單」→ race-safe CAS(claimOrder:conditional UPDATE WHERE assignment_status='pending_review' AND assigned_to_user_id IS NULL AND booking_state NOT IN ('cancelled','completed'),0 列 → already_claimed;claimant 須過 assignableStaffWhere,banned 拒)→ 成功後歸該業務 owner scope(才看得到完整資料)+ audit order.claim

授予方式(ac.newRole({...})ROLE_PERMS 物件admin 攤平整個 catalog 自動含全部):

// packages/core/src/permissions-ac.ts(節錄)
statement.order = ['read', 'create', 'update', 'review', 'assign', 'reassign', 'adjust', 'claim'];
export const op = ac.newRole({ order: ['read', 'update', 'review', 'assign', 'adjust'] /* ... */ });
export const sales = ac.newRole({ order: ['read', 'create', 'update', 'claim'] /* 不含 review/assign */ });
// admin = 攤平 statement → 自動含 review/assign/reassign/claim

7. 影響其他 spec

7.1 Dashboard 角色化 → 儀表板角色化

  • sales 視角「我的本月訂單」改用 WHERE assigned_to_user_id = currentUser.id不用 created_by_user_id
  • 客戶自建單派給某 sales 後也會出現在他的 dashboard
  • assignment_status IN ('assigned', 'reassigned')(不含 pending_review)

7.2 團控財務 / 佣金 → 團控財務

  • 業務佣金計算依據 = assigned_to_user_id(不再是 created_by_user_id
  • 客戶自建單若未派發(pending_review)→ 不計入任何業務佣金,但 tenant 全公司營收仍計
  • 重新指派(reassigned)後,舊業務該單原始佣金處理規則:
    • 佣金「移轉」,整單算新業務
    • 是否要按「派發時間切點」算 partial commission?— 後期視會計需求決定

7.3 /admin/leaderboard

  • 改用 assigned_to_user_id 排行(不再是 created_by_user_id
  • 篩選掉 assignment_status='pending_review'(未確定歸屬不計)
  • 統計加一個「待派發」card:本月有 N 筆 pending_review,總額 NT$ X — 提醒線控派完才有完整 leaderboard
  • 佣金呈現(決策:接 leaderboard 顯示,2026-07-01):排行榜 podium / 表格顯示每位業務的佣金(抽成 + 月度加碼),資料來自 團控財務 的 payables 佣金流(assigned_to_user_id 歸屬),不另建佣金公式。同時顯示 6 個月業績趨勢、客單均價,並把客戶自助下單(無 assignee)列為對照不計排名列。已落地:leaderboard 顯示每位業務佣金(抽成估算 = 業績 × tenant_settings.sales_commission_bps;月度加碼 = 冠軍 × monthly_top_bonus_bps,僅 period=month 顯示)+ 6 個月業績趨勢 + 客單均價,並把客戶自助下單列(created_by_user_id IS NULL,不計佣金 / 不計排名)獨立呈現。leaderboard 佣金為展示估算;佣金財務真相仍走 團控財務 的 payables / profit_share。

7.4 房型分配 → 團控分房

無直接影響(房型 = traveler 層;派發 = order 層)。但 sales 個人 dashboard 列出「我的訂單」會用到 assigned_to_user_id

7.5 護照 OCR → 護照 OCR

無直接影響。

8. UI 規格(wireframe 對應)

8.1 /admin/orders (update)

  • Filter bar 加「待派發」status filter(tabs 加一個 待派發 N
  • Table 加「業務」欄位顯示:
    • assignment_status='assigned' → avatar + name
    • assignment_status='reassigned' → avatar + name + 灰色「重派」chip
    • assignment_status='pending_review' → 紅色 待派發 badge + 「派發」icon button

8.2 /admin/orders/review(new)

  • 頁面標題:「訂單派發」+ 副標「等待主管指派業務的訂單」
  • 頂部 stat:待派發 N 筆 / 待派發總額 NT$ X / 最久等待 N 小時
  • Filter bar:行程 / 出發日 / 來源 / 排序(建立時間 / 出發日近)
  • Table(如 §5.2)
  • Bulk toolbar 浮動於底部(選 ≥1 才顯示)
  • Empty state

8.3 /admin/settings(update)

新增「訂單派發」設定區塊(或加入「公司資料」分頁):

  • Radio group「派發模式」
    • ○ 主管審核派發(預設)
    • ○ 自動派發 — round robin
    • ○ 自動派發 — 客戶選擇業務
  • Select「兜底業務」— 沒有活躍業務時的預設指派對象
  • 警示:切換模式不影響既有訂單

8.4 /admin/orders/[id](後期 update — 本 spec 不附 wireframe)

  • 「業務歸屬」欄位顯示 assigned avatar + 「重新派發」button(admin only)

9. 測試

9.1 Unit / Vitest

  • decideAssignment 三種模式 + K 單路徑各別 case
  • Round-robin 公平性:建 3 個 sales、跑 9 筆訂單 → 每位接 3 單
  • Round-robin 沒有 active sales 時:回兜底 user,沒有就 pending_review

9.2 E2E / Playwright

  • 切換 assignment_mode → 建客戶自助訂單 → 驗證 assigned_to_user_id 結果
  • /admin/orders/review bulk 派給某 sales → 驗證 audit_log 寫 N 筆 order.assign
  • 重派訂單 → 驗證 status 變 reassigned + 雙方通知(mock)

10. 影響範圍

類型數量細節
Schema ALTER2orders (3 欄 + 2 indexes)、tenant_settings (2 欄)
新 page1/admin/orders/review
修改 page3/admin/orders(filter + 業務欄位)、/admin/settings(派發模式區塊)、/admin/orders/[id](重派按鈕)
Server action4createOrderWithReservation + createManualOrderAction;新 assignOrderAction + reassignOrderAction(或合併)
Permission2(+1)order.review + order.assign(catalog 另含 order.reassign
Service 函式2decideAssignmentpickNextSalesInRoundRobin
邏輯影響2/admin/leaderboard 排行依據改 assigned_to_user_id;sales dashboard query 改 assigned_to_user_id

11. 預估工時

任務天數
Schema migration + types0.25
decideAssignment + round-robin 邏輯 + tests0.25
/admin/orders/review 頁 + actions0.25
Settings page 派發模式 toggle0.1
Leaderboard / dashboard 改 assigned_to_user_id0.1
Audit log + notification stubs0.05
合計(不含 customer-choice picker UI)1.0
Customer-choice sales picker UI(客戶端)+0.5

12. 未決事項

項目狀態
Round-robin 計數持久化?暫用 last-assigned-at derive;未來如需「強保證公平」再加 counter table
Customer-choice 是否要 /sales/[id] 個人頁?後期
重派時舊業務的訂單在他 dashboard 「失蹤」UXreassign action 強制填理由 + 通知舊業務
Partial commission(重派後切分佣金)請款單規劃時再決定,本 spec 假設整單移轉
Sales 自派 / 搶單 mode已定案(2026-07-03):per-tenant 開關 allow_sales_claim(預設關);開啟後業務於 /admin/orders/available race-safe 搶單(見 §6)。
Customer-choice 時客戶必須選 vs 可不選(→ 兜底)預設「可不選,落兜底」;可由 tenant setting 加 strict flag
auto_by_destination 規則設計已定案(2026-07-03):對應鍵 = region_codes;規則表 assignment_destination_rules(region 唯一 → 業務)+ 兜底階梯(見 §2.1)。

13. 風險

風險緩解
既有訂單 backfill 規則不清Migration 時:K 單 assigned_to = created_by;客戶自建單統一 pending_review。線控上線後人工派一輪。
Round-robin 在低流量時看起來「不公平」文件化「依 last-assigned-at」即可;leaderboard 顯示 N 單即可自證
sales 離職 → 他的訂單怎麼辦加 admin tool「批次 reassign by user」(本 spec 範圍外,但 schema 已支援)

14. 連結

  • 先行 spec:系統設計總綱
  • 相關 spec:
    • 儀表板角色化 — 角色化 dashboard,sales 視角 query 改 assigned_to_user_id
    • 團控財務 — 請款核簽、業務佣金計算依 assigned_to_user_id
    • 團控分房 — 房型分配(無直接耦合)
    • 護照 OCR — 護照 OCR(無直接耦合)
  • 對應 wireframes:
    • apps/wireframes/public/legacy/admin/orders.html(update — 加待派發 filter + 業務欄位)
    • apps/wireframes/public/legacy/admin/orders-review.html(new)
    • apps/wireframes/public/legacy/admin/settings.html(update — 加派發模式 panel)

Changelog

版本日期變更Commit
12026-05-28初版3710164
22026-05-30整理:對齊現行 code + 路徑搬移
32026-06-20§4.2 round-robin 範例最後一行改回純 string | nullreturn sorted[0]?.userId ?? null;),對齊函式簽名與現行 code
42026-07-02§7.3 code 落地:leaderboard 接佣金展示(sales_commission_bps 抽成估算 + monthly_top_bonus_bps 月度加碼,僅 period=month)+ 6 月業績趨勢 + 客單均價 + 自助下單對照列(created_by_user_id IS NULL 不計佣金 / 排名);財務真相仍走 payables / profit_share。解除 D4。
52026-07-03auto_by_destination + 業務自派搶單落地。§2.1 destination 由「後續」改現行(對應鍵 = region_codes、規則表 assignment_destination_rules、兜底階梯 fallbackLadder);§6 新增 order.claim(admin/sales)+ per-tenant allow_sales_claim 開關(預設關、雙閘 fail-closed)+ race-safe claimOrder CAS + PII-minimal 搶單池 /admin/orders/available;§3.2 加 allow_sales_claim;§12 兩項未決定案。schema:assignment_destination_rules(migration 0041)+ tenant_settings.allow_sales_claim