Skip to content

v1.4 — 27/03/2026

Thay đổiSectionẢnh hưởng
Thêm filter loại trừ ví (wallet + wallet_promotion) vào Function 1 (DEC-012)C3 Function 1BE

Dev Spec — Báo cáo doanh số cá nhân

Ref: PRD v1.3 | Date: 2026-03-23


C1) Architecture Overview

┌──────────────┐     GraphQL      ┌─────────────────┐     SQL Function     ┌──────────────┐
│  diva-admin   │ ───────────────→ │  Hasura Engine   │ ──────────────────→  │  PostgreSQL   │
│  (Report FE)  │                  │  (ecommerce +    │                      │  ecommerce DB │
│               │                  │   project +      │                      │  project DB   │
│               │                  │   wallet DB)     │                      │  wallet DB    │
└──────────────┘                   └─────────────────┘                      └──────────────┘
  • Không cần Go backend code — chỉ SQL functions + Hasura metadata
  • Frontend gọi trực tiếp GraphQL queries qua Hasura
  • 3 DB riêng biệt — commission (ecommerce), tour (project), truy thu (wallet) — tránh cross-database

C2) Data Model

Bảng hiện có (READ ONLY — không tạo mới)

Database: ecommerce

TableDùng choKey columns
order_commission_userCommission NVuser_id, amount, invoice_id
invoiceNgày thanh toánid, paid_at (timestamptz)
accountThông tin NVid, code, display_name, deleted_at
department_userNV → departmentuser_id, department_id, deleted_at
departmentDepartment → branchid, branch_id

Database: project

TableDùng choKey columns
project_task_assigneeTour money NVuser_id, tour_money (numeric), task_id, supervisor, assigner
project_taskNgày hoàn thànhid, done_at (timestamptz), is_done (boolean), parent_id, order_id
projectProject → branchid, branch_id, automate
accountThông tin NV(shared across databases)
department_userNV → department(shared across databases)
departmentDepartment → branch(shared across databases)

Database: wallet (v1.1 — truy thu commission)

TableDùng choKey columns
transactionBút toán truy thu (actual processed)user_id, amount, wallet_type_id, request_id
transaction_requestRequest hoàn tiềnid, behavior_id, status, updated_at, order_id, code
wallet_userThông tin NV (wallet scope)id, code, display_name, branch_id, role

Lưu ý: Wallet DB không có account, department_user, department. Dùng wallet_user để lấy employee info + filter branch. Không thể filter theo chức vụ.


C3) SQL Functions

Prerequisite: Indexes (Migration riêng, deploy TRƯỚC functions)

Migration: services/controller/migrations/ecommerce/{timestamp}_indexes_for_daily_commission/

up.sql:

sql
-- order_commission_user: HIỆN TẠI KHÔNG CÓ INDEX NÀO ngoài PK
-- Bắt buộc tạo để tránh sequential scan khi join invoice + filter user
CREATE INDEX IF NOT EXISTS idx_ocu_invoice_id ON order_commission_user(invoice_id);
CREATE INDEX IF NOT EXISTS idx_ocu_user_id ON order_commission_user(user_id);

-- department_user: thiếu index trên user_id (dùng cho LEFT JOIN)
CREATE INDEX IF NOT EXISTS idx_department_user_user_id ON department_user(user_id);

-- department: thiếu index trên branch_id (dùng cho filter chi nhánh)
CREATE INDEX IF NOT EXISTS idx_department_branch_id ON department(branch_id);

down.sql:

sql
DROP INDEX IF EXISTS idx_ocu_invoice_id;
DROP INDEX IF EXISTS idx_ocu_user_id;
DROP INDEX IF EXISTS idx_department_user_user_id;
DROP INDEX IF EXISTS idx_department_branch_id;

Lý do: order_commission_user hiện chỉ có PK trên id — không có index trên invoice_id hay user_id. Không có index → PostgreSQL sequential scan toàn bộ bảng khi JOIN invoice. department_userdepartment cũng thiếu index cho các cột join/filter. Indexes này cũng benefit cho các query hiện có khác.

Index trên project DB: Không cần thêm — project_task đã có 16 indexes (bao gồm idx_project_task_done, idx_project_task_is_done), project_task_assignee đã có index trên task_id, user_id, supervisor, assigner, tour_money.

Function 1: search_employee_daily_commission (DB: ecommerce)

Migration: services/controller/migrations/ecommerce/{timestamp}_search_employee_daily_commission/

Chiến lược tối ưu: CTE filter date range TRƯỚC (dùng invoice_paid_at_index) → thu hẹp dataset → rồi mới JOIN account/department.

up.sql:

sql
CREATE OR REPLACE FUNCTION public.search_employee_daily_commission(
  _from        date,
  _to          date,
  _branch_ids  uuid[] DEFAULT '{}',
  _job_positions text[] DEFAULT '{}'
)
RETURNS TABLE (
  user_id        text,
  employee_code  text,
  display_name   text,
  report_date    date,
  total_amount   bigint
)
LANGUAGE sql STABLE
AS $$
  WITH commission_base AS (
    -- Step 1: Filter bằng paid_at index trước, thu hẹp dataset
    -- DEC-012: Loại trừ thanh toán bằng ví (giống logic report_employee_result)
    SELECT
      ocu.user_id,
      (i.paid_at AT TIME ZONE 'Asia/Ho_Chi_Minh')::date AS report_date,
      ocu.amount
    FROM order_commission_user ocu
    JOIN invoice i ON i.id = ocu.invoice_id
    WHERE i.paid_at >= _from::timestamptz
      AND i.paid_at < (_to + INTERVAL '1 day')::timestamptz
      AND i.payment_method_id <> 'wallet'
      AND i.payment_method_id <> 'wallet_promotion'
  )
  -- Step 2: JOIN account/department trên dataset đã thu hẹp
  SELECT
    cb.user_id,
    a.code AS employee_code,
    a.display_name,
    cb.report_date,
    SUM(cb.amount)::bigint AS total_amount
  FROM commission_base cb
  JOIN account a ON a.id = cb.user_id AND a.deleted_at IS NULL
  LEFT JOIN department_user du ON du.user_id = cb.user_id AND du.deleted_at IS NULL
  LEFT JOIN department d ON d.id = du.department_id
  WHERE (array_length(_branch_ids, 1) IS NULL OR d.branch_id = ANY(_branch_ids))
    AND (array_length(_job_positions, 1) IS NULL OR du.department_id::text = ANY(_job_positions))
  GROUP BY cb.user_id, a.code, a.display_name, cb.report_date
$$;

down.sql:

sql
DROP FUNCTION IF EXISTS public.search_employee_daily_commission;

Query plan mong đợi:

1. CTE commission_base:
   → Index Scan on invoice_paid_at_index (paid_at range)
   → Nested Loop → Index Scan on idx_ocu_invoice_id
2. Main query:
   → Hash Join account (PK)
   → Hash Join department_user (idx_department_user_user_id)
   → Hash Join department (PK + idx_department_branch_id)
   → HashAggregate (GROUP BY)

Function 2: search_employee_daily_tour_income (DB: project)

Migration: services/controller/migrations/project/{timestamp}_search_employee_daily_tour_income/

Chiến lược tối ưu: CTE filter done tasks TRƯỚC (dùng idx_project_task_done + idx_project_task_is_done) → thu hẹp dataset → rồi mới JOIN account/department.

up.sql:

sql
CREATE OR REPLACE FUNCTION public.search_employee_daily_tour_income(
  _from        date,
  _to          date,
  _branch_ids  uuid[] DEFAULT '{}',
  _job_positions text[] DEFAULT '{}'
)
RETURNS TABLE (
  user_id        text,
  employee_code  text,
  display_name   text,
  report_date    date,
  total_amount   bigint
)
LANGUAGE sql STABLE
AS $$
  WITH tour_base AS (
    -- Step 1: Filter done tasks + date range trước (indexes đã có)
    SELECT
      pta.user_id,
      (pt.done_at AT TIME ZONE 'Asia/Ho_Chi_Minh')::date AS report_date,
      pta.tour_money
    FROM project_task_assignee pta
    JOIN project_task pt ON pt.id = pta.task_id
    JOIN project p ON p.id = pt.project_id
    WHERE pt.done_at >= _from::timestamptz
      AND pt.done_at < (_to + INTERVAL '1 day')::timestamptz
      AND pt.is_done = true
      AND pt.parent_id IS NOT NULL
      AND pt.order_id IS NOT NULL
      AND p.automate = true
      AND pta.supervisor = false
      AND pta.assigner = false
      AND pta.tour_money > 0
  )
  -- Step 2: JOIN account/department trên dataset đã thu hẹp
  SELECT
    tb.user_id,
    a.code AS employee_code,
    a.display_name,
    tb.report_date,
    SUM(tb.tour_money)::bigint AS total_amount
  FROM tour_base tb
  JOIN account a ON a.id = tb.user_id AND a.deleted_at IS NULL
  LEFT JOIN department_user du ON du.user_id = tb.user_id AND du.deleted_at IS NULL
  LEFT JOIN department d ON d.id = du.department_id
  WHERE (array_length(_branch_ids, 1) IS NULL OR d.branch_id = ANY(_branch_ids))
    AND (array_length(_job_positions, 1) IS NULL OR du.department_id::text = ANY(_job_positions))
  GROUP BY tb.user_id, a.code, a.display_name, tb.report_date
$$;

down.sql:

sql
DROP FUNCTION IF EXISTS public.search_employee_daily_tour_income;

Lưu ý: Tour function replicate đúng filter conditions từ view report_tour_income: parent_id IS NOT NULL, order_id IS NOT NULL, automate = true, supervisor = false, assigner = false, tour_money > 0, is_done = true. Project DB đã có đầy đủ indexes — không cần tạo thêm.

Function 3: search_employee_daily_commission_clawback (DB: wallet) — v1.1

Migration: services/controller/migrations/wallet/{timestamp}_search_employee_daily_commission_clawback/

Chiến lược: Giống pattern view order_commission_refund — dùng transaction JOIN transaction_request. Dùng wallet_user thay account (wallet DB không có account table).

up.sql:

sql
CREATE OR REPLACE FUNCTION public.search_employee_daily_commission_clawback(
  _from        date,
  _to          date,
  _branch_ids  uuid[] DEFAULT '{}'
)
RETURNS TABLE (
  user_id        text,
  employee_code  text,
  display_name   text,
  report_date    date,
  total_amount   bigint
)
LANGUAGE sql STABLE
AS $$
  WITH clawback_base AS (
    -- Pattern giống view order_commission_refund: transaction JOIN transaction_request
    SELECT
      t.user_id,
      (tr.updated_at AT TIME ZONE 'Asia/Ho_Chi_Minh')::date AS report_date,
      t.amount
    FROM transaction t
    JOIN transaction_request tr ON tr.id = t.request_id
    WHERE tr.behavior_id = 'refund_commission'
      AND tr.status = 'S'
      AND t.wallet_type_id = 'COMMISSION'
      AND tr.updated_at >= _from::timestamptz
      AND tr.updated_at < (_to + INTERVAL '1 day')::timestamptz
  )
  SELECT
    cb.user_id,
    wu.code AS employee_code,
    wu.display_name,
    cb.report_date,
    SUM(cb.amount)::bigint AS total_amount
  FROM clawback_base cb
  JOIN wallet_user wu ON wu.id = cb.user_id AND wu.role = 'user'
  WHERE (array_length(_branch_ids, 1) IS NULL OR wu.branch_id = ANY(_branch_ids))
  GROUP BY cb.user_id, wu.code, wu.display_name, cb.report_date
$$;

down.sql:

sql
DROP FUNCTION IF EXISTS public.search_employee_daily_commission_clawback;

Hạn chế: Không có param _job_positions vì wallet DB không có department_user/department. FE ẩn filter chức vụ khi chọn "Truy thu commission".


C4) Hasura Metadata

Function YAML — ecommerce

File: metadata/databases/ecommerce/functions/public_search_employee_daily_commission.yaml

yaml
function:
  name: search_employee_daily_commission
  schema: public

Cập nhật: metadata/databases/ecommerce/functions/functions.yaml — thêm:

yaml
- !include public_search_employee_daily_commission.yaml

Function YAML — project

File: metadata/databases/project/functions/public_search_employee_daily_tour_income.yaml

yaml
function:
  name: search_employee_daily_tour_income
  schema: public

Cập nhật: metadata/databases/project/functions/functions.yaml — thêm:

yaml
- !include public_search_employee_daily_tour_income.yaml

Function YAML — wallet (v1.1)

File: metadata/databases/wallet/functions/public_search_employee_daily_commission_clawback.yaml

yaml
function:
  name: search_employee_daily_commission_clawback
  schema: public

Cập nhật: metadata/databases/wallet/functions/functions.yaml — thêm:

yaml
- !include public_search_employee_daily_commission_clawback.yaml

C5) GraphQL Queries

File: src/modules/report/graphql/report_employee_daily_commission.graphql

graphql
query SearchEmployeeDailyCommission(
  $args: search_employee_daily_commission_args!
) {
  search_employee_daily_commission(args: $args) {
    user_id
    employee_code
    display_name
    report_date
    total_amount
  }
}

query SearchEmployeeDailyTourIncome(
  $args: search_employee_daily_tour_income_args!
) {
  search_employee_daily_tour_income(args: $args) {
    user_id
    employee_code
    display_name
    report_date
    total_amount
  }
}

# v1.1 — Truy thu commission (wallet DB)
query SearchEmployeeDailyCommissionClawback(
  $args: search_employee_daily_commission_clawback_args!
) {
  search_employee_daily_commission_clawback(args: $args) {
    user_id
    employee_code
    display_name
    report_date
    total_amount
  }
}

# v1.1 — Chi tiết truy thu cho drill-down popup (wallet DB)
query GetClawbackDetail(
  $userId: String!
  $from: timestamptz!
  $to: timestamptz!
) {
  transaction(
    where: {
      user_id: { _eq: $userId }
      wallet_type_id: { _eq: "COMMISSION" }
      request: {
        behavior_id: { _eq: "refund_commission" }
        status: { _eq: "S" }
        updated_at: { _gte: $from, _lt: $to }
      }
    }
  ) {
    amount
    request {
      code
      id
      updated_at
      order_id
      order {
        code
      }
    }
  }
}

C6) Frontend Components

File Structure

src/modules/report/
├── pages/
│   └── EmployeeDailyCommissionHub.tsx       ← NEW (tab container — SCR-00)
├── components/
│   └── employee-daily-commission/
│       ├── EmployeeDailyCommissionFilter.tsx ← NEW
│       ├── EmployeeDailyCommissionTable.tsx  ← NEW (cell click → open popup)
│       ├── EmployeeDailyCommissionDetail.tsx ← NEW (v1.1 — drill-down popup)
│       └── useEmployeeDailyCommissionExport.tsx ← NEW
├── graphql/
│   └── report_employee_daily_commission.graphql ← NEW (3 queries + 1 detail query)
├── types.ts                                  ← EDIT (add route constants)
└── module.ts                                 ← EDIT (add routes + redirect + ẩn cards cũ)

types.ts — Thêm constants

typescript
export const ROUTE_EMPLOYEE_DAILY_COMMISSION = `${ROUTE_REPORT_LIST}/employee_daily_commission_group`;
export const EMPLOYEE_DAILY_COMMISSION = "employee_daily_commission";
export const EMPLOYEE_DAILY_COMMISSION_GROUP = "employee_daily_commission_group";

types.ts — Cập nhật REPORT_TREE

typescript
// Ẩn 2 cards cũ: xóa khỏi REPORT_TREE
// [TOUR_INCOME_REPORT_GROUP]: [TOUR_INCOME_REPORT],          ← REMOVE
// [EMPLOYEE_REVENUE_REPORT_GROUP]: [EMPLOYEE_REVENUE_REPORT], ← REMOVE

// Thêm card mới
[EMPLOYEE_DAILY_COMMISSION_GROUP]: [EMPLOYEE_DAILY_COMMISSION],

module.ts — Thêm routes

typescript
import { ROUTE_EMPLOYEE_DAILY_COMMISSION, ROUTE_EMPLOYEE_REVENUE_REPORT, ROUTE_TOUR_INCOME_REPORT } from "./types";

// Route chính — hub page
{
  path: extractSubRoute(ROUTE_EMPLOYEE_DAILY_COMMISSION, ROUTE_REPORT_LIST),
  name: ROUTE_EMPLOYEE_DAILY_COMMISSION,
  component: () => import("./pages/EmployeeDailyCommissionHub"),
  props: (route) => ({ ...route.params, tab: route.query.tab }),
}

// Redirect route cũ → hub với query param
{
  path: extractSubRoute(ROUTE_EMPLOYEE_REVENUE_REPORT, ROUTE_REPORT_LIST),
  redirect: { name: ROUTE_EMPLOYEE_DAILY_COMMISSION, query: { tab: 'revenue' } },
}
{
  path: extractSubRoute(ROUTE_TOUR_INCOME_REPORT, ROUTE_REPORT_LIST),
  redirect: { name: ROUTE_EMPLOYEE_DAILY_COMMISSION, query: { tab: 'tour' } },
}

// Route config (thêm vào ROUTE_CONFIGS)
[ROUTE_EMPLOYEE_DAILY_COMMISSION]: {
  permissions: [],
  moduleId: "report_management",
}

EmployeeDailyCommissionHub.tsx — Pseudocode

typescript
// Tab container page — SCR-00
const TAB_OPTIONS = [
  { name: 'daily', label: 'Doanh số theo ngày' },
  { name: 'revenue', label: 'Doanh thu theo đơn hàng' },
  { name: 'tour', label: 'Tiền tour' },
];

const activeTab = ref(props.tab || 'daily');

// Sync tab ↔ query param
watch(activeTab, (val) => {
  router.replace({ query: { tab: val } });
});

// Template:
// <QTabs v-model="activeTab">
//   <QTab v-for="tab in TAB_OPTIONS" :name="tab.name" :label="tab.label" />
// </QTabs>
// <QTabPanels v-model="activeTab">
//   <QTabPanel name="daily"> <EmployeeDailyCommission /> </QTabPanel>
//   <QTabPanel name="revenue"> <EmployeeRevenueReport /> </QTabPanel>  ← import hiện có
//   <QTabPanel name="tour"> <TourIncomeReport /> </QTabPanel>          ← import hiện có
// </QTabPanels>

Pivot Logic (pseudocode)

typescript
type ReportType = 'commission' | 'tour' | 'clawback';

type DailyData = {
  user_id: string;
  employee_code: string;
  display_name: string;
  report_date: string; // YYYY-MM-DD
  total_amount: number;
};

type PivotRow = {
  user_id: string;
  code: string;
  name: string;
  days: Record<string, number>; // "YYYY-MM-DD" → amount
  total: number;
};

function pivotData(raw: DailyData[]): PivotRow[] {
  const map = new Map<string, PivotRow>();
  for (const item of raw) {
    if (!map.has(item.user_id)) {
      map.set(item.user_id, {
        user_id: item.user_id,
        code: item.employee_code,
        name: item.display_name,
        days: {},
        total: 0,
      });
    }
    const row = map.get(item.user_id)!;
    row.days[item.report_date] = (row.days[item.report_date] || 0) + item.total_amount;
    row.total += item.total_amount;
  }
  return Array.from(map.values()).sort((a, b) => a.code.localeCompare(b.code));
}

Dynamic Columns

typescript
function generateDayColumns(year: number, month: number): QTableColumn[] {
  const daysInMonth = new Date(year, month, 0).getDate();
  return Array.from({ length: daysInMonth }, (_, i) => {
    const day = i + 1;
    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
    return {
      name: dateStr,
      label: `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}`,
      field: (row: PivotRow) => row.days[dateStr] || 0,
      format: (val: number) => formatCurrency(val),
      align: 'right' as const,
      sortable: true,
    };
  });
}

Drill-down Popup — Merge Logic (v1.1)

typescript
// EmployeeDailyCommissionDetail.tsx
type DetailRow = {
  date: string;
  order_code: string;
  order_kind: string;        // "ĐH dịch vụ" | "ĐH mỹ phẩm" | "Truy thu commission"
  transaction_code: string;
  payment_method: string | null;
  customer: { name: string; phone: string; code: string } | null;
  amount: number;            // dương = commission, âm = truy thu
  type: 'commission' | 'clawback';
};

async function fetchDetailRows(
  userId: string,
  from: string,  // YYYY-MM-DD or YYYY-MM-01 (cả tháng nếu click Tổng)
  to: string,
  reportType: ReportType,
): Promise<DetailRow[]> {
  if (reportType === 'tour') {
    // Chỉ query tour detail — không merge clawback
    return fetchTourDetail(userId, from, to);
  }
  if (reportType === 'clawback') {
    // Chỉ query wallet clawback
    const clawbackRows = await fetchClawbackDetail(userId, from, to);
    return clawbackRows.map(r => ({
      date: r.request.updated_at,
      order_code: r.request.order?.code || '—',
      order_kind: 'Truy thu commission',
      transaction_code: r.request.code || r.request.id,
      payment_method: null,
      customer: null,
      amount: -r.amount,  // wallet amount luôn >= 0, FE negate
      type: 'clawback' as const,
    }));
  }
  // reportType === 'commission': merge cả 2 nguồn
  const [commissionRows, clawbackRows] = await Promise.all([
    fetchCommissionDetail(userId, from, to),  // ecommerce
    fetchClawbackDetail(userId, from, to),    // wallet
  ]);
  return [
    ...commissionRows.map(r => ({ ...r, type: 'commission' as const })),
    ...clawbackRows.map(r => ({
      date: r.request.updated_at,
      order_code: r.request.order?.code || '—',
      order_kind: 'Truy thu commission',
      transaction_code: r.request.code || r.request.id,
      payment_method: null,
      customer: null,
      amount: -r.amount,
      type: 'clawback' as const,
    })),
  ].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}

C7) Migration Timestamps

DBLatest existingNew migration(s)
ecommerce17732000000001773300000000_indexes_for_daily_commission (indexes TRƯỚC)
ecommerce1773300000001_search_employee_daily_commission (function SAU)
project17697552370091773300000002_search_employee_daily_tour_income
wallet(verify latest)1773300000003_search_employee_daily_commission_clawback (v1.1)

Timestamp phải lớn hơn latest existing. Giá trị trên là gợi ý — BE Dev dùng timestamp thực tế lúc tạo. Deploy order: Indexes → Functions → Hasura metadata. Indexes phải deploy TRƯỚC functions để query plan tối ưu ngay từ đầu.


C8) Dependency Map

Migration ecommerce (indexes) ──TRƯỚC──→ Migration ecommerce (commission function)
                                            → Hasura metadata ecommerce (track function)

Migration project (tour function)
  → Hasura metadata project (track function)

Migration wallet (clawback function)              ← v1.1
  → Hasura metadata wallet (track function)       ← v1.1

Hasura metadata deploy (ecommerce + project + wallet)
  → GraphQL codegen (pnpm codegen)
    → FE components (table + popup)

Build sequence:

  1. BE: Deploy indexes migration (ecommerce) — TRƯỚC
  2. BE: Deploy function migrations (ecommerce + project + wallet) + Hasura metadata
  3. FE: pnpm codegen → implement components (table + popup) → deploy

C11) Performance Notes

Index Audit

TableIndex hiện cóIndex cần thêmLý do
order_commission_userChỉ PK (id)idx_ocu_invoice_id, idx_ocu_user_idCritical — không có index = seq scan toàn bảng
department_userKhông cóidx_department_user_user_idJOIN filter NV
departmentChỉ PKidx_department_branch_idFilter chi nhánh
invoiceinvoice_paid_at_index (paid_at DESC)Không cầnĐã có
project_task✅ 16 indexesKhông cầnĐã có idx_project_task_done, idx_project_task_is_done
project_task_assignee✅ 6 indexesKhông cầnĐã có task_id, user_id, supervisor, assigner, tour_money

Chiến lược tối ưu

  1. CTE pattern: Filter date range TRƯỚC (dùng index) → thu hẹp dataset → JOIN account/department SAU (dataset nhỏ hơn)
  2. 4 indexes mới: Deploy TRƯỚC functions — benefit cả query hiện có khác
  3. Result set bounded: num_employees × days_in_month (worst case 500 × 31 = 15.500 rows)

Verify sau deploy

sql
-- Commission: mong đợi < 500ms
EXPLAIN ANALYZE SELECT * FROM search_employee_daily_commission('2026-03-01', '2026-03-31');

-- Tour: mong đợi < 500ms
EXPLAIN ANALYZE SELECT * FROM search_employee_daily_tour_income('2026-03-01', '2026-03-31');

-- Verify indexes đã được dùng (không sequential scan)
-- Check: Index Scan on invoice_paid_at_index
-- Check: Index Scan on idx_ocu_invoice_id

Fallback nếu vẫn chậm

  1. Thêm composite index: CREATE INDEX idx_ocu_invoice_user ON order_commission_user(invoice_id, user_id)
  2. Materialized view refresh hàng đêm cho data tháng cũ (giống pattern mv_project_task_ratings)
  3. Frontend: q-virtual-scroll nếu > 200 rows