一句話:訂單派發提供兩種模式(自動 / 主管審核)讓 tenant 自選,建立 sales attribution 的底層機制(
assigned_to_user_id)供 dashboard / leaderboard / commission 使用。
orders.created_by_user_id/admin/orders/new 由業務手動建立)有值created_by_user_id = null,沒有業務歸屬/admin/leaderboard 直接 join created_by_user_id 排行 → 客戶自建單完全不計/admin/leaderboard 的共同前置依賴created_by_user_idcreated_by_user_id 語意是「誰建立訂單」(K 單建單者 / 自助為 null)assigned_to_user_id 語意是「業績歸屬」(可被重新指派 / 一定要有值才計業績)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_rules(region_code 唯一 → assignee_user_id):region_code FK → region_codes.code(restrict,被引用的碼不可硬刪);assignee_user_id FK → user.id(set null,業務離職 / 刪帳號 → 規則退化為未命中,走兜底)。assignableStaffWhere:sales/admin 且未被 ban)→ 派給他(assigned)。梯次無 region_code / 查無規則 / assignee 已失效(set null 或 banned)→ 落兜底階梯:default_assignee_user_id(若設)→ 否則 pending_review(與 round-robin 兜底同一階梯,assignment.ts 的 fallbackLadder)。/admin/settings 派發區塊的「地區 → 業務對應規則」表(region Combobox × 業務 Combobox,upsert 以 region_code 唯一 + 刪除)。audit tenant_settings.destination_rule.upsert / .delete。assignment_mode='manual_review')status='pending_payment' + assignment_status='pending_review'/admin/orders/review 看到 unassigned 訂單清單assignment_status='assigned'/admin/settings → 訂單派發分頁tenant_settings.assignment_mode.changeorders 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 / leaderboardtenant_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)。
| 想做 | 為什麼跳過 |
|---|---|
sales_queue / sales_pool 動態表 | 用同庫 "user" where role IN ('sales','admin') 直接查即可(auth 與商業表同庫);不需另一張表 |
assignment_rules 規則引擎 | 太早 over-engineer,等真有「複雜路由」需求再做 |
| Round-robin 計數持久化 table | 改用「last-assigned-at 排序,最久沒派的優先」derive 即可 |
// 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)。
// 實際回傳 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;
}
decideAssignment 實際在 src/app/(public)/checkout/actions.ts 呼叫(非 repo / service 層),結果透過 createOrderWithReservation(packages/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, // 客戶自建單 → 系統派
});
K 單建單者即受派者:建單 action 直接帶 created_by_user_id 進 order,並設 assignedToUserId = created_by、assignmentStatus = 'assigned'、assignedByUserId = created_by。不經 decideAssignment(decideAssignment 只處理客戶自建單的 mode 分派)。
| 模式 | UI |
|---|---|
auto_round_robin | 客戶看不到業務選項;訂單後台自動指派 |
auto_customer_choice | 客戶在 checkout 看到「您的業務聯絡人」<Select>,列出活躍 sales(含照片 + 介紹 — 後期可加 /sales/[id] 個人頁) |
manual_review | 客戶看不到業務選項;訂單建立後由 admin/op 在 review 頁派發 |
/admin/orders/review)新頁面,列出 assignment_status='pending_review' 訂單。
頂部:
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:「沒有等待派發的訂單 — 所有訂單都有業務歸屬」
// 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');
}
/admin/orders/[id] 加「重新派發」按鈕(admin only):
assignOrderAction,但內部判斷 before.assignmentStatus === 'assigned' → 標 reassigned在 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/review) | admin, op |
order.assign | 派發訂單 | admin, op |
order.reassign | 重新派發訂單 | admin(catalog 有 reassign;op 實際授予 review/assign) |
order.claim | 業務自派搶單(/admin/orders/available) | admin, 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
WHERE assigned_to_user_id = currentUser.id,不用 created_by_user_idassignment_status IN ('assigned', 'reassigned')(不含 pending_review)assigned_to_user_id(不再是 created_by_user_id)pending_review)→ 不計入任何業務佣金,但 tenant 全公司營收仍計reassigned)後,舊業務該單原始佣金處理規則:
/admin/leaderboardassigned_to_user_id 排行(不再是 created_by_user_id)assignment_status='pending_review'(未確定歸屬不計)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。無直接影響(房型 = traveler 層;派發 = order 層)。但 sales 個人 dashboard 列出「我的訂單」會用到 assigned_to_user_id。
無直接影響。
/admin/orders (update)待派發 N)assignment_status='assigned' → avatar + nameassignment_status='reassigned' → avatar + name + 灰色「重派」chipassignment_status='pending_review' → 紅色 待派發 badge + 「派發」icon button/admin/orders/review(new)/admin/settings(update)新增「訂單派發」設定區塊(或加入「公司資料」分頁):
/admin/orders/[id](後期 update — 本 spec 不附 wireframe)decideAssignment 三種模式 + K 單路徑各別 caseassignment_mode → 建客戶自助訂單 → 驗證 assigned_to_user_id 結果/admin/orders/review bulk 派給某 sales → 驗證 audit_log 寫 N 筆 order.assignreassigned + 雙方通知(mock)| 類型 | 數量 | 細節 |
|---|---|---|
| Schema ALTER | 2 | orders (3 欄 + 2 indexes)、tenant_settings (2 欄) |
| 新 page | 1 | /admin/orders/review |
| 修改 page | 3 | /admin/orders(filter + 業務欄位)、/admin/settings(派發模式區塊)、/admin/orders/[id](重派按鈕) |
| Server action | 4 | 擴 createOrderWithReservation + createManualOrderAction;新 assignOrderAction + reassignOrderAction(或合併) |
| Permission | 2(+1) | order.review + order.assign(catalog 另含 order.reassign) |
| Service 函式 | 2 | decideAssignment、pickNextSalesInRoundRobin |
| 邏輯影響 | 2 | /admin/leaderboard 排行依據改 assigned_to_user_id;sales dashboard query 改 assigned_to_user_id |
| 任務 | 天數 |
|---|---|
| Schema migration + types | 0.25 |
decideAssignment + round-robin 邏輯 + tests | 0.25 |
/admin/orders/review 頁 + actions | 0.25 |
| Settings page 派發模式 toggle | 0.1 |
| Leaderboard / dashboard 改 assigned_to_user_id | 0.1 |
| Audit log + notification stubs | 0.05 |
| 合計(不含 customer-choice picker UI) | 1.0 |
| Customer-choice sales picker UI(客戶端) | +0.5 |
| 項目 | 狀態 |
|---|---|
| Round-robin 計數持久化? | 暫用 last-assigned-at derive;未來如需「強保證公平」再加 counter table |
Customer-choice 是否要 /sales/[id] 個人頁? | 後期 |
| 重派時舊業務的訂單在他 dashboard 「失蹤」UX | reassign 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)。 |
| 風險 | 緩解 |
|---|---|
| 既有訂單 backfill 規則不清 | Migration 時:K 單 assigned_to = created_by;客戶自建單統一 pending_review。線控上線後人工派一輪。 |
| Round-robin 在低流量時看起來「不公平」 | 文件化「依 last-assigned-at」即可;leaderboard 顯示 N 單即可自證 |
| sales 離職 → 他的訂單怎麼辦 | 加 admin tool「批次 reassign by user」(本 spec 範圍外,但 schema 已支援) |
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)| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-28 | 初版 | 3710164 |
| 2 | 2026-05-30 | 整理:對齊現行 code + 路徑搬移 | — |
| 3 | 2026-06-20 | §4.2 round-robin 範例最後一行改回純 string | null(return sorted[0]?.userId ?? null;),對齊函式簽名與現行 code | — |
| 4 | 2026-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。 | — |
| 5 | 2026-07-03 | auto_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。 | — |