線稿索引所有 spec最後更新 2026-07-01
specs/member-ownership.md

會員與業務歸屬

一句話:現行 M1 —— 新增「同業」客戶分類實體(agencies 表,含負責業務 owner_user_id),同業通路訂單經 orders.agency_id 直連歸屬;會員層只有客戶分類 member_profiles.customer_type→ M2(未落地):會員 / 同業各自掛「預設負責業務」並接成訂單派發的來源之一(不改派發規則本身)、同業 B2B 登入下單入口(見 §9)。

1. 問題與動機

1.1 需求

  • 「會員 / 同業各自記負責業務」的概念,業務能看到「我的客戶」。系統目前只有訂單層業績歸屬(訂單派發assigned_to_user_id),沒有客戶層的長期歸屬。
  • 同業(旅行社同業 / 票務代理 / 揪團團主)是旅行社的重要客群,需要:
    1. 一個地方建檔同業公司資料(名稱、統編、聯絡人、出帳資訊)。
    2. 一組獨立「同業價」(只給後台內部看,現階段不開 B2B 登入,見 §9 future)。
    3. 一位負責業務(owner)。

1.2 為什麼需要「客戶層歸屬」而非只靠訂單層歸屬

  • 訂單層 assigned_to_user_id單一訂單的業績歸屬,可重派、可離職換手 —— 它回答「這張單算誰的業績」。
  • 客戶層 owner_user_id 回答「這個客戶長期由誰維護」—— 它是 CRM 語意,不是業績語意。
  • 兩者並存且互補
    • 客戶層 owner = 預設值來源(下單時若客戶有預設業務 → 餵進派發當來源之一)。
    • 訂單層 assigned = 實際業績歸屬(仍由派發三模式 + 主管手動派決定,可覆寫客戶預設)。
  • 拆兩層讓「客戶換手」不會改寫歷史訂單的業績歸屬,反之亦然。

1.3 範圍邊界(與訂單派發的明確劃界)

  • 本 spec 不改寫訂單派發的 decideAssignment 派發規則(三模式:manual_review / auto_round_robin / auto_customer_choice)。
  • 本 spec 只新增一個「來源」:在 dispatcher 之前先看「客戶(會員 / 同業)的預設業務」,若有就作為候選餵進派發流程;既有模式仍是最終仲裁者(見 §5)。

2. 既有客戶實體現況盤點

探查 packages/core/src/schema/business.tspackages/core/src/members/admin-repo.tspackages/core/src/orders/* 後的結論。沒有獨立的 members 業務表;「會員 / 旅客 / customer」散落三個地方,必須先釐清才能決定 owner 掛點。

概念實際落地DB說明
登入帳號 / 使用者user各 tenant <tenant>_dbbetter-auth 管理(auth 表與商業表同庫,無共享 auth_db);id 由 better-auth 產生。員工 + 旅客會員都在這。角色記在 user.role(admin plugin;無 organization/member 表)。
後台「旅客會員」頁的 source of truthuserrole='customer'各 tenant <tenant>_dbmembers/admin-repo.tslistMembers 直接讀同庫 user(role 篩客戶)。orders.user_id 原生 FK → user.id
會員偏好擴充member_profiles各 tenant <tenant>_db1:1 掛 user.iduser_id unique FK,onDelete cascade)。存 phone / address / birthDate / preferredDestinations。後台會員頁 LEFT JOIN 取這些欄位。
旅客名單(per-order PII)travelers各 tenant <tenant>_dborders.id,存每位同行者姓名 / 身分證 / 護照 / 緊急聯絡人 / 房間。不是客戶帳號——同一個人在不同訂單會有多筆 traveler row。is_primary 標訂購人。

結論

  1. 「會員(旅客)」= userrole='customer')+ member_profiles。「會員」概念實際對應到 同庫 user(帳號層)+ member_profiles(偏好層)。沒有也不要新建 members
  2. travelers 是訂單明細層的 PII,不是 owner 掛點(它沒有帳號語意、會重複)。
  3. owner_user_id 掛點 = member_profiles(不是 user 表)。理由:
    • user 是 better-auth 管理的帳號表,欄位應由 better-auth 主導;塞業務欄位會破壞「帳號表 vs 租戶自有資料」的職責分離。
    • member_profiles 已經是「租戶自有的會員擴充表」,是放 CRM 欄位的正確位置;它本來就是 admin 會員頁 join 的對象。
    • 1:1 cardinality 已由 member_profiles_user_unique 保證。
    • 代價:沒有 member_profiles row 的會員(純註冊未填偏好)就沒有 owner。對策見 §4.1(owner 寫入時 upsert profile row)。
  4. 同業(agency)= 全新概念,既有 schema 完全沒有,必須新建 agencies 表(§4.2)。同業仍以某個 user(auth 帳號)作為「下單帳號」(現階段由內部代下單),agencies 表透過 primary_user_id 連回 user

3. 範圍

3.1 做

  1. 會員層 ownermember_profiles.owner_user_id(FK → user.id,nullable)。後台會員頁可指派 / 變更負責業務 + 依負責業務篩選。
  2. 同業層:新 agencies 表(同業公司資料 + owner_user_id + primary_user_id)。後台新增獨立 nav「同業管理」(/admin/agencies)。
  3. 客戶分類member_profiles.customer_type'direct' 直客 / 'agency_contact' 同業聯絡人),接同業價(同業價的「該不該套用」判斷依此 + agency 關聯)。
  4. 下單帶預設業務:客戶(會員 / 同業)若有 owner → 作為派發的來源之一餵進既有 dispatcher(§5),派發模式仍是最終仲裁。
  5. 角色權限:新增 agency.read / agency.manage / member.assign_owner

3.2 不做 / future

  • 同業 B2B 登入下單入口:只在 §9 future 章節描述佔位,不實作、不畫 wireframe
  • 同業帳期 / 月結對帳:未來會計對帳模組範圍,本 spec 不碰(agencies 表只留 payment_terms free-text 欄位佔位)。
  • 客戶層佣金分潤(owner 抽成):請款單仍以訂單層 assigned_to_user_id 計佣,owner 不直接影響佣金。

4. 資料模型

對齊 CLAUDE.md「同庫 user FK」慣例:所有指向使用者的欄位都原生 FK 到本租戶 DB 的 user(auth 與商業表同庫)。Code Is Truth:穩定業務欄位一律直接進 packages/core/src/schema/business.ts(不再走 setup-extras ALTER)。下方 §4.1–§4.3 描述現行 M1 落地(以 code 為準),§4.4 列 → M2(未落地) 的客戶層 owner 擴充。

4.1 member_profiles(現行 M1:只有客戶分類)

現行 code 的 member_profiles 客戶層只落地 customer_type'direct' / 'agency_contact'):沒有 owner_user_id、沒有 agency_id(兩者屬 M2,見 §4.4)。customer_type 是「客戶是誰」的客戶層分類(CRM 單一同業辨識來源);成交時套用的價格通路快照寫在 product line(tour line 的 price_channel_snapshot'direct'|'agency'),與本欄解耦、不放訂單外殼。

現行 drizzle(packages/core/src/schema/business.ts,節錄實況):

export const memberProfiles = pgTable(
  'member_profiles',
  {
    id: idColumn('id', 'memberProfile').primaryKey(),
    userId: idColumn('user_id', 'user')
      .notNull()
      .references(() => user.id, { onDelete: 'cascade' }),
    phone: text('phone'),
    address: text('address'),
    birthDate: date('birth_date'),
    preferredDestinations: text('preferred_destinations').array(),
    /** CRM 客戶分類:'direct'(直客,預設)| 'agency_contact'(同業聯絡人)。接同業價判斷。 */
    customerType: text('customer_type').notNull().default('direct'),
    /** PDPA 匿名化軟刪標記(見 member-pdpa-self-service)。 */
    anonymizedAt: timestamp('anonymized_at', { withTimezone: true }),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    uniqueIndex('member_profiles_user_unique').on(t.userId),
    check(
      'member_profiles_customer_type_valid',
      sql`${t.customerType} IN ('direct','agency_contact')`,
    ),
  ],
);

4.2 agencies(現行 M1:同業公司對照表)

agencies 是全新獨立實體(跨梯次、非 per-departure),已進 drizzle schema。現行 code(packages/core/src/schema/business.ts,實況):

/**
 * 同業管理(agencies)— B2B 旅行社/團主對照表。跨梯次獨立實體(非 per-departure)。
 * 負責業務 = staff user(owner_user_id → user.id)。同業通路訂單經 orders.agency_id
 * 反向關聯(累計訂單 = COUNT orders WHERE agency_id)。tax_id 可空(個人團主無統編)。
 */
export const agencies = pgTable(
  'agencies',
  {
    id: idColumn('id', 'agency').primaryKey(), // 'agc_<ulid>'
    name: text('name').notNull(),
    /** 統一編號(個人團主可留空)。 */
    taxId: text('tax_id'),
    contactName: text('contact_name'),
    phone: text('phone'),
    email: text('email'),
    address: text('address'),
    /** 帳期/結算方式(自由文字,例:'月結 30 天')。 */
    paymentTerm: text('payment_term'),
    /** 負責業務(staff user)。NULL = 未指派。原生 FK → 同庫 `user`。 */
    ownerUserId: idColumn('owner_user_id', 'user').references(() => user.id, { onDelete: 'set null' }),
    status: text('status').notNull().default('active'), // 'active' | 'archived'
    note: text('note'),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => [
    index('agencies_status_idx').on(t.status),
    index('agencies_owner_idx').on(t.ownerUserId),
    check('agencies_status_valid', sql`${t.status} IN ('active','archived')`),
  ],
);

現行 M1 與早期設計草稿的差異(以 code 為準):

  • owner_user_id = 負責這家同業的業務(CRM owner,M1 已落地,onDelete: 'set null')。
  • primary_user_id(同業主要下單帳號)—— 屬 M2 B2B portal(§4.4 / §9)。
  • status 值域是 'active' | 'archived'(軟下架),早期草稿的 'inactive';由 agencies_status_valid CHECK 收斂。
  • 聯絡欄位命名為 phone / email(非草稿的 contact_phone / contact_email);帳期欄為 payment_term(單數)、備註欄為 note
  • tax_id 現行無 DB unique 約束;個人團主可留空,格式 / 唯一性驗證待產品畫押(見 apps/web/src/app/(admin)/admin/agencies/schemas.tstaxId 只做 trim + 長度上限)。

4.3 同業通路訂單歸屬(現行 M1:orders.agency_id 直連)

M1 的同業訂單歸屬走 orders.agency_id 直連 FK不是早期設計的「member_profiles.agency_id 推導 + agencies.primary_user_id」路徑:

// packages/core/src/schema/business.ts(orders 表節錄,實況)
/** 同業通路訂單歸屬的同業(agencies)。NULL = 直客 / 未掛同業。 */
agencyId: idColumn('agency_id', 'agency').references(() => agencies.id, { onDelete: 'set null' }),
// index: orders_agency_idx on (agency_id, created_at)

累計同業訂單 = COUNT(orders) WHERE agency_id = ?(反向關聯)。

關聯總覽(現行 M1):

user (同庫 better-auth 帳號表)
   ▲
   └── agencies.owner_user_id        (同業的負責業務 / staff)

agencies ◀── orders.agency_id         (同業通路訂單 → 所屬同業,直連 FK)

member_profiles.customer_type         (客戶層分類,M1 無 FK 到 agencies)

現行 M1 欄位清單(schema 回報,以 code 為準)

欄位型別NullableFK / 約束
member_profilescustomer_typetext NOT NULL DEFAULT 'direct'noCHECK IN ('direct','agency_contact')
ordersagency_idtextyesagencies.idset null
agencies(新表)idtext PKno'agc_<ulid>'
agenciesnametext NOT NULLno
agenciestax_idtextyes無 DB unique(app 層驗證)
agenciescontact_name / phone / email / addresstextyes
agenciespayment_term / notetextyes
agenciesstatustext NOT NULL DEFAULT 'active'noCHECK IN ('active','archived')
agenciesowner_user_idtextyesuser.idset null
indexorders_agency_idx / agencies_status_idx / agencies_owner_idx

4.4 → M2(未落地):完整客戶層 owner 歸屬

下列尚未進 code,屬 member-ownership 的 M2 階段(未上線、無真資料,屆時改回客戶層模型成本低):

  • member_profiles.owner_user_id(會員層 CRM owner)+ 對應 index / upsert 指派(無 profile row 的會員以 INSERT ... ON CONFLICT (user_id) DO UPDATE 掛 owner)。
  • member_profiles.agency_id(同業聯絡人 → 所屬公司),取代 M1 的 orders.agency_id 直連推導。
  • agencies.primary_user_id(同業主要下單帳號,B2B portal 上線後對應的同庫 user)。
  • resolveCustomerDefaultOwner 派發來源接點(§5)。
  • 會員頁「負責業務」欄 / 篩選、同業詳情頁旗下聯絡人列表(§8 的 owner 相關子項)。
  • 同業 B2B 登入下單入口(§9)。

owner 掛點(M2):owner 掛 member_profiles.owner_user_id(無獨立 members 表,理由見 §2 結論 3)。

5. 與訂單派發的接點(→ M2,未落地)

⚠ 整個 §5 屬 M2resolveCustomerDefaultOwner 與「客戶預設業務 → 派發來源」接點依賴 §4.4 的 member_profiles.owner_user_id / agency_id,M1 尚未建。以下為 M2 設計意圖(保留供實作參考),現行 code 無此路徑。

原則decideAssignmentpackages/core/src/orders/assignment.ts)保持原樣。本 spec 只在「呼叫 dispatcher 之前」加一個來源解析步驟,把客戶預設業務轉成既有的 customerChoiceUserId 輸入(或在 manual_review 模式下作為 pre-fill),不新增模式、不改三模式行為。

5.1 既有派發流程回顧

  • 公開下單 → createOrderWithReservationpackages/core/src/orders/repo.ts)收 assignment? 參數。
  • action 層先呼叫 decideAssignment({ mode, defaultAssigneeUserId, customerChoiceUserId?, db }) → 得 { assignedToUserId, assignmentStatus, assignedAt } → 傳進 repo。
  • K 單路徑(createManualOrderAction)直接 assignment = { assignedToUserId: actorUserId, ... 'assigned' }覆寫 tenant 模式(K 單一律歸 K 的業務)—— 本 spec 不動此路徑。

5.2 新增的「來源解析」(resolveCustomerDefaultOwner)

公開下單 action(呼叫 decideAssignment 之前)插入一支查詢函式:

// 新檔:packages/core/src/members/owner-source.ts(或併入 orders action 層)
import 'server-only';
import { sql } from 'drizzle-orm';
import type { ScopedDb } from '@toko-cloud/core/db/business';

/**
 * 解析下單客戶的「預設負責業務」作為派發來源之一。
 *
 * 來源優先序:
 *  1. 客戶若為同業聯絡人(member_profiles.agency_id 有值)→ 取 agency.owner_user_id
 *  2. 否則取 member_profiles.owner_user_id(會員直接負責業務)
 *  3. 都沒有 → null(交還既有派發模式決定)
 *
 * 回傳的 userId 仍會被 validateAssignee 驗證(必須是同庫 `user` 表內
 * assignable role:sales / admin),所以這裡不重複驗角色 —— 失效時自動 fall back。
 */
export async function resolveCustomerDefaultOwner(
  db: ScopedDb,
  customerUserId: string,
): Promise<string | null> {
  const rows = await db.execute<{ owner_user_id: string | null }>(sql`
    SELECT COALESCE(a.owner_user_id, mp.owner_user_id) AS owner_user_id
    FROM member_profiles mp
    LEFT JOIN agencies a ON a.id = mp.agency_id
    WHERE mp.user_id = ${customerUserId}
    LIMIT 1
  `);
  return [...rows][0]?.owner_user_id ?? null;
}

5.3 如何餵進派發(兩種模式的行為)

在公開下單 action 內:

const defaultOwner = await resolveCustomerDefaultOwner(db, customerUserId);

const decision = await decideAssignment({
  mode: tenantSettings.assignmentMode,
  defaultAssigneeUserId: tenantSettings.defaultAssigneeUserId,
  // 把客戶預設業務當成「客戶選擇」來源餵進去。
  // - auto_customer_choice:客戶自己在 checkout 有選 → 用客戶選的;沒選才用 defaultOwner。
  // - 其餘模式:見下表。
  customerChoiceUserId: explicitCustomerChoice ?? defaultOwner ?? null,
  db,
});

各模式下「客戶預設業務」的作用不改派發程式邏輯,只透過餵 customerChoiceUserId 達成):

tenant 模式客戶有預設業務時的行為是否改派發程式
auto_customer_choice客戶 checkout 沒手動選 → 落到預設業務(經 validateAssignee 驗證後 assigned)。手動選優先。否(既有 fall-through 即可)
manual_review訂單仍進 pending_review不自動派,尊重主管審核);但在 /admin/orders/review 的派發 dropdown 預選 (pre-fill) 客戶預設業務,主管一鍵確認即可。pre-fill 是 UI 層,不改 dispatcher。
auto_round_robin預設業務優先於輪派:若 resolveCustomerDefaultOwner 有值且通過驗證 → 直接 assigned 給該業務,跳過 round-robin(語意:老客戶回到原業務手上比公平輪派重要)。實作:action 層在 mode==='auto_round_robin' 且 defaultOwner 有效時,不呼叫 dispatcher、直接組 decision;否則照常呼叫。否(包在 action 層的分支,dispatcher 本體不動)

設計取捨auto_round_robin 的「預設業務優先」是 action 層的一個 if 分支,不是改 decideAssignment。若 user 認為 round-robin 應該無視客戶歸屬(純公平),把這個 if 拿掉即可 —— 列入 §12 未決。

5.4 audit

  • owner 變更(會員 / 同業)寫 audit_logaction='member.assign_owner' / 'agency.assign_owner',metadata 記 before/after owner。
  • 下單時若因 defaultOwnerassigned,沿用既有的 order.assign audit(assigned_by = null 表系統 / 客戶歸屬來源),metadata 加 source: 'customer_default_owner' 以利追溯。

6. Server actions

action檔案(建議)權限行為
assignMemberOwnerAction({ userId, ownerUserId | null })src/app/(admin)/admin/members/[id]/actions.tsmember.assign_ownerupsert member_profiles(保證 row 存在)→ set owner → audit member.assign_ownerownerUserId=null 為取消歸屬。
setMemberCustomerTypeAction({ userId, type, agencyId? })同上member.assign_ownercustomer_typeagency_contact 時必填 agencyId(驗證 agency 存在)。
createAgencyAction(input)src/app/(admin)/admin/agencies/actions.tsagency.manageagencies row(驗證統編格式 + 唯一)→ audit agency.create
updateAgencyAction(id, patch)同上agency.manage更新欄位(含 owner / status)→ audit agency.update / agency.assign_owner
archiveAgencyAction(id)同上agency.managestatus='inactive'(軟下架,不刪 —— 保留歷史訂單關聯)→ audit。
listAgenciesForOwnerPicker() / listAssignableStaffForOwner()repo helperagency.read / member.read下拉選單資料:負責業務候選沿用既有 listAssignableStaff(讀同庫 "user" 表 role IN ('sales','admin'))。

owner 候選池:直接重用 listAssignableStaffpackages/core/src/orders/assignment-repo.ts)—— 同一組「可受派業務」(ASSIGNABLE_ROLES = ['sales','admin']),避免兩套定義。

repo 層:新 packages/core/src/agencies/admin-repo.tslistAgencies / getAgencyDetail / createAgency / updateAgency),第一個參數一律 db: ScopedDb(對齊 branded type 慣例)。會員 owner 相關擴充進 packages/core/src/members/admin-repo.ts(既有檔)。

7. 角色權限

RBAC 走 better-auth admin plugin 的 createAccessControl statement catalog(packages/core/src/permissions-ac.ts),不是 flat string + ROLE_PERMS 物件;權限閘經 auth.api.userHasPermission 強制(見 租戶架構 §4)。角色集只有 admin / sales / op / accountant / customerowner 角色

本 spec 在 statement catalog 新增一個 agency resource,並把 owner 指派接到既有 member resource 的一個新 action:

// packages/core/src/permissions-ac.ts,擴充 statement catalog(節錄)
export const statement = {
  ...defaultStatements,
  // ...既有 order / trip / departure / payment_request 等...
  member: ['read', 'update', 'assign_owner'], // 既有 read/update + 新增 assign_owner
  agency: ['read', 'manage'], // 新 resource
} as const;

對應的 dotted perm 字串(caller 傳給 requirePermission / userHasPermission):agency.readagency.managemember.assign_owner

各角色(ac.newRole)授予:

dotted perm動作授予角色(newRole 加入該 (resource, action))
agency.read看同業列表 / 詳情admin(catalog 全包)、sales、op、accountant
agency.manage建 / 改 / 下架同業admin、sales
member.assign_owner(→ M2)指派會員 / 同業負責業務admin、op(member 層 owner 屬 M2,尚未進 code)
  • admin role 自動涵蓋整個 catalog(permissions-ac.tsstatement 攤平進 admin newRole),故新增的 agency.* / member.assign_owner 自動歸 admin。
  • sales 可讀可管同業(現行 code salesGrant.agency: ['read','manage']):業務維護自己的同業夥伴 —— 報同業價、聯絡、建檔 / 改資料 / 改歸屬都在業務日常職責內。op(對帳需讀帳期)、accountant(對款需讀同業)為 read-onlyagency: ['read'])。
  • 同業價(由行程定價定義 agency_price只後台內部看:前台行程頁不顯示;後台是否顯示同業價依 agency.read gate(任何後台角色可見內部報價)。

8. UI 規格

8.1 同業管理 /admin/agencies(新頁,獨立 nav)

依 CLAUDE.md「線控 UX 原則」:同業是跨梯次的客戶資料(非 per-departure 批次處理),所以開獨立 nav(與 members / guides 同類)。畫面:見 apps/wireframes 線稿。

  • 頂部 stat:同業總數 / 本月新增 / 活躍 / 待補統編。
  • filter bar:狀態(全部 / 活躍 / 已下架)、負責業務(下拉)、有無統編。
  • table:公司名稱 + 統編 / 主要聯絡人 + 電話 / 負責業務 / 累計同業訂單(後期 join)/ 狀態 / 操作。
  • 新增 / 編輯:dialog 或子頁,欄位對應 §4.2;負責業務 = listAssignableStaff 下拉。
  • 詳情:同業基本資料 + 旗下「同業聯絡人」(member_profiles where agency_id) 列表 + 該同業的訂單(後期)。

8.2 會員頁 /admin/members(既有頁)

  • table 加「負責業務」欄(顯示 owner 的 avatar + name,未指派顯示「未指派」chip)。
  • filter bar 加「負責業務」下拉(含「未指派」「我負責的」快捷)。
  • 詳情頁加「負責業務」可編輯欄 + 「客戶分類」(direct / 同業聯絡人 + 所屬同業) 顯示。

8.3 下單 / 派發頁的接點(不新增 wireframe)

  • /admin/orders/review(既有頁):派發 dropdown 在 manual_review 模式下 pre-fill 客戶預設業務(§5.3)—— 屬派發頁的微調,本 spec 只註記。

9. Future — 同業 B2B 登入下單入口(不實作)

本章節只描述佔位,不畫 wireframe、不進實作計畫

  • 目標:同業(旅行社 / 票務代理)取得專屬登入,自行查同業價、下單、看自己的訂單與帳期,不需內部業務代下單。
  • schema 已預留的接點
    • agencies.primary_user_id → 未來 B2B 登入後對應的同庫 user
    • member_profiles.customer_type='agency_contact' + agency_id → 區分同業帳號 vs 直客。
    • agencies.payment_terms → 未來帳期 / 月結。
  • 未來要補的東西(不在本 spec):
    • 同業專屬角色(如 agency_b2b)+ 受限前台(只看同業價、只看自己訂單)。
    • 同業價在下單流程的自動套用(目前同業價只後台內部看)。
    • 帳期 / 信用額度 / 月結對帳(接未來會計對帳模組)。
  • 定位:現階段同業 = 純後台「客戶分類 + 第二組價格」;B2B portal 是後續階段。

10. 測試

10.1 Unit / Vitest

  • resolveCustomerDefaultOwner
    • 直客有 owner → 回該 owner。
    • 同業聯絡人 → 回 agency.owner_user_id(優先於 member owner)。
    • 同業聯絡人但 agency 無 owner、member 有 owner → 回 member owner(COALESCE 行為)。
    • 都沒有 → null。
  • assignMemberOwnerAction:無 profile row 的會員指派 owner → upsert 成功、可查回。
  • createAgencyAction:重複統編 → 拒絕;無統編團主 → 允許(多筆 NULL 共存)。
  • 客戶分類 agency_contact 必須帶 agencyId,否則拒絕。

10.2 整合(派發接點,不改既有派發測試)

  • auto_customer_choice + 客戶有預設業務 + checkout 未手動選 → 訂單 assigned 給預設業務。
  • manual_review + 客戶有預設業務 → 訂單仍 pending_review(驗證不自動派);review 頁 pre-fill 正確(前端測 / e2e)。
  • auto_round_robin + 客戶有有效預設業務 → 跳過輪派、直接給預設業務(驗 action 層分支)。
  • 預設業務已離職 / 非 assignable role → validateAssignee fall back(不會派給無效對象)。

10.3 E2E / Playwright

  • 建同業 → 指派負責業務 → 在會員頁把某會員設為該同業聯絡人 → 該會員下單 → 驗證業績歸屬走到同業 owner(auto_customer_choice 模式)。
  • 會員頁依負責業務篩選 → 只顯示該業務的客戶。
  • audit_log 寫入 member.assign_owner / agency.create(用 TRUNCATE 清資料,trigger 不 fire)。

11. 影響範圍

類型數量細節
Schema drizzle(business.ts)3 欄 + 2 indexmember_profilesowner_user_id / customer_type / agency_id + owner/agency index
Schema 新表(drizzle)1agencies(11 欄 + 3 index)
新 page1/admin/agencies(列表 + 詳情 + 新增 / 編輯)
修改 page1/admin/members(負責業務欄 + 篩選 + 詳情可編輯);/admin/orders/review 微調 pre-fill
Server action5member owner / customer_type;agency create / update / archive
Repo2agencies/admin-repo.ts;擴 members/admin-repo.ts
Service 函式1resolveCustomerDefaultOwner(派發來源接點)
Permission3agency.read / agency.manage / member.assign_owner
線稿同業管理頁(新)/ 會員頁(負責業務欄 + 篩選):見 apps/wireframes
派發衝擊0 改寫decideAssignment 不動;新增來源在 action 層

12. 未決事項

項目狀態
auto_round_robin 是否該被「客戶預設業務」覆寫?預設「覆寫」(老客戶回原業務);若要純公平輪派則拿掉 action 層 if(§5.3)
owner 掛 member_profiles 而非 user已定(§2 結論 3);扶正進 drizzle 的時機跟派發 / created_by_user_id 一起決定
同業 owner 是否影響佣金否 —— 佣金仍依訂單層 assigned_to_user_id。若未來要 owner 抽成另議
同業聯絡人下單時自動套同業價?現階段同業價只後台看;自動套價屬 B2B future(§9)
一個會員是否可同時隸屬多家同業?現行模型:單一 agency_id(1:1)。多對多 → 未來加 join 表
統編格式驗證強度現行模型:8 碼數字 + 唯一即可,不做台灣統編 checksum(後期可加)
member.assign_owner 是否該讓 sales 自己「認領」客戶現行模型否(與不准搶單一致);視客戶需求再開

13. 連結

  • 依賴:訂單派發系統設計總綱
  • 既有程式接點:
    • packages/core/src/orders/assignment.tsdecideAssignment不改
    • packages/core/src/orders/assignment-repo.tslistAssignableStaff — 重用為 owner 候選池)
    • packages/core/src/members/admin-repo.ts(會員列表 source = 同庫 user + member_profiles
    • scripts/setup-business-extras.ts(idempotent ALTER 落地處)
  • 線稿:見 apps/wireframes(同業管理頁、會員頁)。

Changelog

版本日期變更Commit
12026-05-29初版
22026-05-30整理:對齊現行 code + 路徑搬移
32026-06-20§4.3 / §11 落地方式對齊現行慣例:member_profiles 三新欄(含 owner/agency index)由「ALTER(setup-extras)」改為「drizzle schema(business.ts)」
42026-06-23M1 first-pass 落地,建模簡化(user 拍板):本輪先做「同業建檔目錄」最淺層 —— agencies 表(migration 0010,含 owner_user_id/status)+ /admin/agencies 管理頁 + 訂單關聯改用 orders.agency_id 直連 FK(非本 spec 的 member_profiles.agency_id 推導 + agencies.primary_user_id)。客戶層 CRM 全套(owner 篩選、客戶換手不動歷史業績、同業價、B2B portal、member_profiles.agency_id/primary_user_id仍守 M2。決策脈絡見 docs/reviews/2026-06-23-new-epics-decisions.md「最重要:與既有 spec 的分歧」。未上線無真資料,M2 要改回客戶層模型成本低。
52026-07-01三方稽核收斂(Code Is Truth):§7 agency.manage 授予角色對齊現行 code —— 由 admin、op 改為 admin、salessalesGrant.agency: ['read','manage'],業務維護自己的同業夥伴;op/accountant read-only),並改寫「sales 不能改同業(防搶客戶)」段落。§4/§5 主敘述 realign 現行 M1 模型(member_profiles 只有 customer_typeagencies.owner_user_idorders.agency_id 直連、agencies.status 值域 active/archived、無 primary_user_id/無 tax_id unique),把完整 owner 歸屬 / member_profiles.owner_user_id/agency_id / agencies.primary_user_id / resolveCustomerDefaultOwner 派發接點 / B2B portal 整套移入 §4.4 + §5 標為「→ M2」future。SSOT:docs/reviews/2026-07-01-three-way-realign-audit.md