Appearance
v1.4 — 27/03/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
| Thêm filter loại trừ ví (wallet + wallet_promotion) vào Function 1 (DEC-012) | C3 Function 1 | BE |
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
| Table | Dùng cho | Key columns |
|---|---|---|
order_commission_user | Commission NV | user_id, amount, invoice_id |
invoice | Ngày thanh toán | id, paid_at (timestamptz) |
account | Thông tin NV | id, code, display_name, deleted_at |
department_user | NV → department | user_id, department_id, deleted_at |
department | Department → branch | id, branch_id |
Database: project
| Table | Dùng cho | Key columns |
|---|---|---|
project_task_assignee | Tour money NV | user_id, tour_money (numeric), task_id, supervisor, assigner |
project_task | Ngày hoàn thành | id, done_at (timestamptz), is_done (boolean), parent_id, order_id |
project | Project → branch | id, branch_id, automate |
account | Thông tin NV | (shared across databases) |
department_user | NV → department | (shared across databases) |
department | Department → branch | (shared across databases) |
Database: wallet (v1.1 — truy thu commission)
| Table | Dùng cho | Key columns |
|---|---|---|
transaction | Bút toán truy thu (actual processed) | user_id, amount, wallet_type_id, request_id |
transaction_request | Request hoàn tiền | id, behavior_id, status, updated_at, order_id, code |
wallet_user | Thông tin NV (wallet scope) | id, code, display_name, branch_id, role |
Lưu ý: Wallet DB không có
account,department_user,department. Dùngwallet_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_userhiện chỉ có PK trênid— không có index trêninvoice_idhayuser_id. Không có index → PostgreSQL sequential scan toàn bộ bảng khi JOIN invoice.department_uservàdepartmentcũ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ồmidx_project_task_done,idx_project_task_is_done),project_task_assigneeđã có index trêntask_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_positionsvì 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: publicCập nhật: metadata/databases/ecommerce/functions/functions.yaml — thêm:
yaml
- !include public_search_employee_daily_commission.yamlFunction YAML — project
File: metadata/databases/project/functions/public_search_employee_daily_tour_income.yaml
yaml
function:
name: search_employee_daily_tour_income
schema: publicCập nhật: metadata/databases/project/functions/functions.yaml — thêm:
yaml
- !include public_search_employee_daily_tour_income.yamlFunction 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: publicCập nhật: metadata/databases/wallet/functions/functions.yaml — thêm:
yaml
- !include public_search_employee_daily_commission_clawback.yamlC5) 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
| DB | Latest existing | New migration(s) |
|---|---|---|
| ecommerce | 1773200000000 | 1773300000000_indexes_for_daily_commission (indexes TRƯỚC) |
| ecommerce | 1773300000001_search_employee_daily_commission (function SAU) | |
| project | 1769755237009 | 1773300000002_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:
- BE: Deploy indexes migration (ecommerce) — TRƯỚC
- BE: Deploy function migrations (ecommerce + project + wallet) + Hasura metadata
- FE:
pnpm codegen→ implement components (table + popup) → deploy
C11) Performance Notes
Index Audit
| Table | Index hiện có | Index cần thêm | Lý do |
|---|---|---|---|
order_commission_user | Chỉ PK (id) | idx_ocu_invoice_id, idx_ocu_user_id | Critical — không có index = seq scan toàn bảng |
department_user | Không có | idx_department_user_user_id | JOIN filter NV |
department | Chỉ PK | idx_department_branch_id | Filter chi nhánh |
invoice | ✅ invoice_paid_at_index (paid_at DESC) | Không cần | Đã có |
project_task | ✅ 16 indexes | Không cần | Đã có idx_project_task_done, idx_project_task_is_done |
project_task_assignee | ✅ 6 indexes | Không cần | Đã có task_id, user_id, supervisor, assigner, tour_money |
Chiến lược tối ưu
- CTE pattern: Filter date range TRƯỚC (dùng index) → thu hẹp dataset → JOIN account/department SAU (dataset nhỏ hơn)
- 4 indexes mới: Deploy TRƯỚC functions — benefit cả query hiện có khác
- 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_idFallback nếu vẫn chậm
- Thêm composite index:
CREATE INDEX idx_ocu_invoice_user ON order_commission_user(invoice_id, user_id) - Materialized view refresh hàng đêm cho data tháng cũ (giống pattern
mv_project_task_ratings) - Frontend:
q-virtual-scrollnếu > 200 rows