Skip to content

Prepaid Card Analytics Tab — Design Spec

Date: 2026-03-13 · Last reviewed: 2026-05-04 Status: Draft v3 (post 3-round review — schema corrections applied) Module: Report — Prepaid Card Scale: 70 chi nhánh, 100K+ khách hàng, 1M+ giao dịch

v3.16 — 16/05/2026

Thay đổiSectionẢnh hưởng
Status threshold canonical (ref PRD A10 FORMULA-020): 3 ngưỡng đầy đủ Hiệu quả > 200% / Trung bình 100%–200% / Kém < 100% (trước chỉ định nghĩa > 200%)§7.2 Tab Chiến dịchFE, BE, QA
Tooltip ROI KPI Card: 3 phân loại + edge case marketing_cost = 0 → "—" + tag "Chưa có budget"§16.4 Tooltip MarketingFE, UI/UX
Column "Trạng thái" bảng Chiến dịch: ngưỡng + edge case canonical§16.12 Bảng Chiến dịchFE, QA

v3.15 — 14/05/2026

Thay đổiSectionẢnh hưởng
Customer table contract: bảng KH đổi 12 → 13 cột, tách Dư ví DIVA + Dư ví KM; highlight theo Dư ví DIVA + Dư ví KM > 5tr§16.7 Customer TableBE, FE, QA
V5 wallet balance source mở rộng: BE phải expose exact fields cho wallet_balance_divawallet_balance_km, hoặc reconcile với total-only wallet_balance.balance nếu source chỉ có tổng§6 KPI table · §16.1 Business glossaryBE
GraphQL/plan note cập nhật aggregate wallet balance split; Tổng dư ví vẫn là DIVA + KM để giữ nghĩa vụ nợ phải trả tổng§9 MV/queryBE

v3.14 — 12/05/2026

Thay đổiSectionẢnh hưởng
§4.3 Expanded row: 3 sections → 2 sections (bỏ query "Lịch sử sử dụng") — ref DEC-T07§4.3 Bảng giao dịchBE, FE, QA
§16.6 Cột expanded row "Lịch sử sử dụng" — xoá section§16.6BE, FE
Component PrepaidAnalyticsTransactionRow.tsx: 3 sections → 2 sections, LOC 350 → 300Component tableFE
DEC-T08 Commission source rewrite: Đổi tất cả query KPI/MV Hoa hồng từ transaction_request_user.amount WHERE behavior_id='transaction_commission'invoice_commission.amount WHERE invoice_status='invoice_completed' (codebase finding: transaction_commission đã disabled trong order_commission_user_insert.go:16-58). Refund track riêng qua behavior_id='refund_commission' amount âm. Tab Hoa hồng bỏ cột "Trạng thái" (chỉ 1 status)§6 KPI table, §4.3 expanded row, §9 MV SQL (LATERAL commission), §10 Index, §16.1 Tooltip, §16.6 column spec, §16.7/16.8/16.10/16.14 column defsAll
DEC-U13 Alert payload extension: compute_prepaid_alerts return thêm 5 field mới — sla_label, trend_value + prior_count + trend_direction + trend_compare_label, suggested_action_key, navigate.sort_override. Performance budget < 500ms (cân nhắc cache prior count)§3.3 Alert Box, §9.9 Alert functionBE, FE, QA

0. Schema Mapping (đối chiếu codebase thực tế)

⚠️ BẮT BUỘC ĐỌC TRƯỚC KHI IMPLEMENT. Sau review L3+L4 đối chiếu codebase BE/FE/DB hiện tại, đã phát hiện một số tên cột/bảng trong spec gốc KHÔNG khớp schema thực + logic calculation sai. Bảng dưới đây là canonical mapping. Toàn bộ SQL trong spec đã được update. Khi implement, dev BE phải verify thêm các điểm đánh dấu 🔍 Verify.

0.1 Schema field renames

Tên trong spec gốc (sai)Tên thực tế trong codebaseAction
branch_region (tạo mới)region_branch (đã tồn tại — 1678865967129_region_branch/up.sql) + branch.region_id (đã có)Reuse, KHÔNG tạo migration mới
order_item.value_into_wallet (sai)order_item.prepaid_value_into_wallet (xem public_order_item.yaml:17)Đổi tên trong toàn bộ SQL
order_item.prepaid_card_id (FK trực tiếp)order_item.product_id + JOIN qua prepaid_card_viewĐổi join logic
order.total_amount (sai)order.total (xem public_order.yaml:453)Đổi tên
order.paid_status enumDerive từ paid_amount vs total: paid khi paid_amount = total, debt khi 0 < paid_amount < total, unpaid khi paid_amount = 0Tính derived, không có cột enum
prepaid_card table (trực tiếp)prepaid_card_view (view)Dùng view thay vì table
customer table (KHÔNG tồn tại)ecommerce_user (codebase dùng default account system, KHÔNG có public_customer.yaml). order.customer_id relationship → ecommerce_user.id⏳ V1 BLOCKER — INNER JOIN với prepaid orders đã implicit filter customer; filter explicit chưa apply vì chưa có evidence cột phân loại trong codebase. BE resolve trước khi migration

0.2 Calculation rules — CRITICAL (review L4 + L7 fix)

⚠️⚠️⚠️ BẮT BUỘC tuân thủ — sai 3 rule này → KPI tài chính sai 2-N lần, kế toán không tin số liệu.

Rule 0a (review L8 — invoice status filter, codebase pattern)

Bằng chứng codebase: Report hiện hữu (search_report_service, dashboard revenue) thường filter (status = 'invoice_completed' OR status IS NULL) trên invoice để loại draft/transition invoice. Nếu MV không mirror filter này → có thể ăn invoice chưa hoàn thiện, gây lệch reconciliation với báo cáo cũ.

Rule canonical: Mọi SQL aggregate trên invoice PHẢI thêm filter:

sql
AND (inv.status = 'invoice_completed' OR inv.status IS NULL)

🔍 V13 BLOCKER: BE confirm tên cột status chính xác + danh sách statuses tính revenue.

Rule 0 (review L7 — invoice canonical, parent vs sub_invoices)

Bằng chứng codebase: Khi tạo prepaid order, hệ thống tạo 1 parent invoice + N sub_invoices (mỗi sub_invoice gắn với 1 order_item để phân bổ giá trị). Báo cáo cũ search_report_prepaid_card đã filter parent_id IS NULL để tránh cộng cả cha lẫn con.

Rule canonical cho mọi SQL liên quan invoice:

Mục đích aggregateDùngFilter
Order-level (Tiền thu vào, total_collected)Parent invoiceWHERE parent_id IS NULL AND canceled_at IS NULL
Payment method split (PTTT)Parent invoiceWHERE parent_id IS NULL AND canceled_at IS NULL
DT ghi nhận (wallet_used — chỉ ví chính, ref DEC-B06)Parent invoiceWHERE parent_id IS NULL AND canceled_at IS NULL AND payment_method_id = 'wallet'
KM đã sử dụng (wallet_promo_used — chỉ ví KM, metric phụ FORMULA-005b)Parent invoiceWHERE parent_id IS NULL AND canceled_at IS NULL AND payment_method_id = 'wallet_promotion'
Item-level allocation (phân bổ theo order_item)Sub_invoice (con)WHERE parent_id IS NOT NULL AND canceled_at IS NULL + JOIN order_item
sql
-- ❌ SAI (cộng cả parent + sub_invoices → phóng đại 2x):
SELECT SUM(amount) FROM invoice WHERE order_id = $1 AND canceled_at IS NULL

-- ✅ ĐÚNG (chỉ parent — giá trị tổng đơn):
SELECT SUM(amount) FROM invoice
 WHERE order_id = $1 AND parent_id IS NULL AND canceled_at IS NULL

Áp dụng cho TẤT CẢ MV/SQL trong spec: mv_prepaid_order_daily, mv_prepaid_card_daily, mv_prepaid_customer_stats, mv_prepaid_finance_daily, compute_prepaid_alerts, query expand row chi tiết thanh toán/lịch sử ví.

🔍 V8 (mới) — BLOCKER: BE confirm tên field chính xác (parent_id hay parent_invoice_id) trong codebase metadata public_invoice.yaml. Cần evidence trước khi unlock migration.

Rule 1: prepaid_value_into_wallet ĐÃ LÀ LINE TOTAL — KHÔNG nhân với quantity

Bằng chứng codebase:

  • PrepaidOrderCreate.tsx:252 — set prepaid_value_into_wallet = giá trị nạp ví của cả dòng item (đã nhân quantity ở FE trước khi save)
  • PrepaidOrderPayments.tsx:55 — cộng trực tiếp prepaid_value_into_wallet, KHÔNG nhân thêm quantity

Quy tắc cho mọi SQL/formula:

sql
-- ❌ SAI (làm phóng đại N lần khi quantity > 1):
SUM(oi.prepaid_value_into_wallet * oi.quantity)

-- ✅ ĐÚNG:
SUM(oi.prepaid_value_into_wallet)

Áp dụng cho TẤT CẢ KPIs: Nạp ví · Ví Diva · Ví KM · % Đã dùng ví · KM đã nạp · Lợi nhuận gộp.

Rule 2: Order-level metrics KHÔNG được join trực tiếp với order_item rồi SUM

Vấn đề cũ: MV summary join order × order_item × invoice (LATERAL) rồi GROUP BY product_id. Khi 1 đơn có 3 items → invoice amount cộng 3 lần khi SUM theo product → total_collected sai 3x.

Quy tắc:

  • Order-level metrics (Tiền thu vào, DT ghi nhận, KH unique, đơn count) → aggregate ở MV granularity (date, branch, region) KHÔNG group by product_id
  • Item-level metrics (Số lượng thẻ bán, Nạp ví, Ví Diva/KM theo loại thẻ) → aggregate ở MV granularity (date, branch, region, product_id)

Implementation: 2 MVs riêng biệt (xem Section 9.2 — chi tiết SQL):

  • mv_prepaid_order_daily — order-level (cho KPIs Tổng quan + Tài chính)
  • mv_prepaid_card_daily — item/card-level (cho chart "Phân bố mệnh giá")

0.3 Verify với BE team trước khi implement (5 điểm)

  1. 🛑 V1 BLOCKER — Customer filter rule — Đã đổi FROM customerFROM ecommerce_user eu JOIN với prepaid orders. Codebase metadata KHÔNG có account_type cột trên ecommerce_user → SQL hiện rely vào INNER JOIN với per_order (chỉ user có prepaid order). BE phải confirm:
    • (a) Rule này đủ chính xác để filter customer (có khả năng KH cũ KHÔNG có order nhưng vẫn cần track)
    • (b) Hoặc cần filter explicit qua relationship default_account / role / view report_customer?
    • Cung cấp evidence: link Hasura metadata file hoặc query mẫu confirm rule đúng. KHÔNG được hardcode account_type nếu chưa có bằng chứng.
  2. 🔍 Ví Diva / Ví KM splitorder_item có field riêng prepaid_value_into_wallet_promotion không, hay derive Ví KM = Tổng nạp ví − Tiền thu vào?
  3. 🔍 NV thu ngân (cột 9 bảng Giao dịch dòng 2) — field source: order.created_by? invoice.created_by? cashier_id riêng? Nếu chưa có → bỏ dòng 2 cột Nhân viên
  4. 🔍 prepaid_card_view columns — verify có flexible, name, id (cho join). Nếu thiếu → extend view
  5. 🔍 Wallet balance source — alert function dùng wallet_balance table — codebase có wallet_balance_result. Verify đúng tên + accessibility từ ecommerce DB

Existing baseline bugs trong tab cũ — KHÔNG reuse logic:

  • PrepaidCardReportFilter.tsx:180-185 — offset bị tăng trước first fetch → bỏ 1000 rows đầu khi export. Build mới hoàn toàn cho export, không copy logic này.
  • PrepaidCardReportCard.tsx:37prepaid_type_flexible bị exclude ở một số where condition → summary thiếu thẻ linh hoạt. Build mới phải include cả Cố định + Linh hoạt.

1. Tổng quan

1.1 Vấn đề hiện tại

Báo cáo thẻ trả trước hiện tại (/r/reports/prepaid-card-report) có nhiều vấn đề:

  • Dữ liệu sai/không khớp: Số liệu bán, doanh thu tính chưa chính xác
  • Thiếu thông tin: Không phân biệt thẻ cố định và linh hoạt, không có chi tiết giao dịch, lịch sử sử dụng, hoa hồng
  • Giao diện khó dùng: Filter chưa đủ, export chậm hoặc crash browser với dữ liệu lớn
  • Backend yếu: 1 function duy nhất search_report_prepaid_card JOIN 3 bảng real-time, không cache, không pagination hiệu quả

1.2 Mục tiêu

Xây dựng tab mới hoàn toàn trong báo cáo thẻ trả trước, phục vụ 3 nhóm vai trò:

Vai tròNhu cầu chính
Kế toánĐối soát doanh thu, công nợ, hoa hồng, phương thức thanh toán
MarketingHiệu quả chiến dịch, nguồn khách, phân khúc KH, hành động hàng loạt
Quản lý / Chủ spaTổng quan KPI, so sánh khu vực/CN, xếp hạng NV, cảnh báo vận hành

1.3 Phạm vi

  • Tab mới thay thế tab cũ (ẩn tab cũ qua feature flag, không xóa)
  • 6 sub-tabs bên trong
  • Backend: 6 materialized views, 3-4 search functions, composite indexes
  • Frontend: ~27 components, shared filter, charts, export
  • Feature flag: FEATURE_PREPAID_ANALYTICS_V2 — toggle giữa tab cũ và mới, cho phép rollback nhanh

1.4 Data Model Reference

Hệ thống sử dụng 1 PostgreSQL instance với 2 logical databases trong Hasura:

Database (Hasura)Bảng chính liên quan
ecommerceorder, order_item, invoice, order_commission, prepaid_card, customer, branch, ecommerce_user, campaign
wallettransaction_request, transaction_request_user, transaction, wallet, wallet_balance (view)

Liên kết cross-database:

  • transaction_request.order_id → liên kết về order.id (ecommerce)
  • transaction_request.branch_id → liên kết về branch.id (ecommerce)
  • transaction_request.customer_id → liên kết về customer.id (ecommerce)
  • invoice.reference_amount → ⏳ PROVISIONAL (V11/V12 BLOCKER) Theo PRD A10.0 Canonical Wallet Table: dùng cho Ví Diva ĐÃ DÙNG (filter payment_method_id='wallet', parent only) VÀ Ví KM ĐÃ DÙNG (filter payment_method_id='wallet_promotion', parent only). Đồng thời dùng cho Ví Diva NẠP (parent_invoice của prepaid order — V9). BE phải xác nhận field này phục vụ đúng 3 vai trò trên với codebase evidence.
  • invoice.wallet_promotion_amount → ⏳ PROVISIONAL (V10/V12 BLOCKER) Theo PRD A10.0 Canonical Wallet Table: dùng cho Ví KM NẠP lúc bán thẻ (parent_invoice của prepaid order — V10). CẢNH BÁO: KHÔNG được dùng field này cho phần "ví KM ĐÃ DÙNG" (đó là invoice.reference_amount với filter payment_method_id='wallet_promotion'). Mapping cũ "wallet_promotion_amount = ví KM đã dùng" đã được Review L8 chứng minh SAI — chờ V12 BE confirm.
  • invoice.payment_method_id → ‘cash’, ‘bank’, ‘wallet’, ‘cod’, ‘wallet_promotion’ (V12 — BE verify danh sách enum đầy đủ)

Lưu ý: Vì cùng 1 PG instance, MVs có thể query cross-schema. Tuy nhiên, MVs nên đặt trong ecommerce database (nơi chứa phần lớn dữ liệu) và truy xuất wallet data qua schema-qualified references hoặc derive từ invoice table.

Prepaid card KHÔNG có expiry field. Alert “thẻ sắp hết hạn” sẽ được thay bằng “KH lâu không sử dụng” (dựa trên transaction_request.created_at).


2. Kiến trúc tổng thể

2.1 Route Structure

/r/reports/prepaid-card-report          <- Tab cũ (ẩn qua feature flag)
/r/reports/prepaid-card-analytics       <- Tab MỚI (parent)

  Phase 1 (build trong MVP):
  |-- /overview                         <- Sub-tab 1: Tổng quan (DEFAULT)
  |-- /transactions                     <- Sub-tab 2: Giao dịch
  |-- /customers                        <- Sub-tab 3: Khách hàng
  |-- /finance                          <- Sub-tab 4: Tài chính

  Phase 3+ (TBD — KHÔNG build trong Phase 1):
  |-- /marketing                        <- Sub-tab 5: Marketing
  |-- /staff                            <- Sub-tab 6: Nhân viên

Lưu ý router Phase 1: chỉ register 4 routes P1 trong routes.ts. KHÔNG khai báo /marketing/staff để tránh dead code. Khi nào ưu tiên Phase 3+ → tạo PR mới thêm route.

2.2 Route Constants (thêm vào report/types.ts)

typescript
// Route constants — Phase 1 (4 routes)
export const ROUTE_PREPAID_CARD_ANALYTICS = "/r/reports/prepaid-card-analytics";
export const ROUTE_PREPAID_ANALYTICS_OVERVIEW = "/r/reports/prepaid-card-analytics/overview";
export const ROUTE_PREPAID_ANALYTICS_TRANSACTIONS = "/r/reports/prepaid-card-analytics/transactions";
export const ROUTE_PREPAID_ANALYTICS_CUSTOMERS = "/r/reports/prepaid-card-analytics/customers";
export const ROUTE_PREPAID_ANALYTICS_FINANCE = "/r/reports/prepaid-card-analytics/finance";

// Phase 3+ (TBD — KHÔNG khai báo trong Phase 1):
// export const ROUTE_PREPAID_ANALYTICS_MARKETING = "/r/reports/prepaid-card-analytics/marketing";
// export const ROUTE_PREPAID_ANALYTICS_STAFF      = "/r/reports/prepaid-card-analytics/staff";

// Permission tree (thêm vào REPORT_TREE) — Phase 1
export const PREPAID_CARD_ANALYTICS = "prepaidCard_analytics";
REPORT_TREE[PREPAID_CARD_ANALYTICS] = [
  ROUTE_PREPAID_ANALYTICS_OVERVIEW,
  ROUTE_PREPAID_ANALYTICS_TRANSACTIONS,
  ROUTE_PREPAID_ANALYTICS_CUSTOMERS,
  ROUTE_PREPAID_ANALYTICS_FINANCE,
  // Phase 3+: thêm ROUTE_..._MARKETING và ROUTE_..._STAFF khi build
];

Ghi chú: Giữ nguyên constant cũ ROUTE_PREPAID_cARD_REPORT_GROUP (không fix typo) để tránh breaking change. Tab cũ ẩn/hiện qua feature flag.

2.3 Component Architecture

PrepaidCardAnalytics.tsx (parent — QTabs + shared filter)

  Phase 1:
  |-- PrepaidAnalyticsOverview.tsx        ← Sub-tab 1
  |-- PrepaidAnalyticsTransactions.tsx    ← Sub-tab 2
  |-- PrepaidAnalyticsCustomers.tsx       ← Sub-tab 3
  |-- PrepaidAnalyticsFinance.tsx         ← Sub-tab 4

  Phase 3+ (TBD — KHÔNG build trong Phase 1):
  |-- PrepaidAnalyticsMarketing.tsx       ← Sub-tab 5
  |-- PrepaidAnalyticsStaff.tsx           ← Sub-tab 6
  • Sử dụng QTabs/XTabs component-level (nhất quán với report pattern hiện có như ServiceReport.tsx)
  • KHÔNG dùng XDetailLayout (chỉ dành cho detail pages)
  • Sub-tab components lazy-imported via defineAsyncComponent (KHÔNG child routes — DEC-T06)
  • Sub-tab visibility kiểm tra qua Dynamic Permission v2 fine-grainedhasActionPermission('report.prepaid_analytics', 'view') cho visibility, hasActionPermission(..., 'export') cho export buttons, hasActionPermission(..., 'view_full_phone') cho unmask SDT (xem Section 2.5). Legacy globalStore.reportRoles chỉ dùng làm fallback nếu module v2 chưa seed.

2.4 Shared Filter

Filter bar chỉ có 3 element (ref: prd.md DEC-U04, DEC-U05). Bỏ chế độ "So sánh KV / So sánh CN".

[Chọn chi nhánh ▾]  [Khoảng thời gian ▾ (gộp presets + custom range)]
[🔍 Tìm KH, SDT, mã đơn, NV...]
  • Filter state: route query params (?branch=&from=&to=) + Pinia store sync
  • Branch filter: search + nhóm khu vực + multi-select (phù hợp 70 CN)
  • Date range: dropdown QSelect chứa cả presets (Hôm nay / Hôm qua / 7 ngày / 30 ngày / Tháng này / Tháng trước / Quý này) + option "Tùy chọn..." mở QDate calendar. Mặc định: Tháng này. Max range: 365 ngày.
  • Tra cứu nhanh: search across customer, order, user với pg_trgm

2.5 Phân quyền (Dynamic Permission v2 fine-grained)

Phase 1: 3 actionsview + export + view_full_phone (review L4 fix). Chi tiết matrix per-role ở prd.md Section 5.3.

Module config:

yaml
module_id: report.prepaid_analytics
portal: admin
actions:
  - view                # bật/tắt xem báo cáo (sub-tab visibility)
  - export              # bật/tắt export Excel
  - view_full_phone     # bật/tắt unmask SDT (PII compliance)

# Migration seed mặc định (xem prd.md Section 5.3 matrix):
default_grants:
  admin:           [view, export, view_full_phone]
  area_manager:    [view, export]
  branch_manager:  [view, export]
  accountant:      [view, export, view_full_phone]
  marketing:       [view]
  staff:           []

Hasura permissions:

yaml
# mv_prepaid_order_daily, mv_prepaid_customer_stats, mv_prepaid_finance_daily
# Permission rule:
#   - role 'admin' / 'area_manager' (branch_mode=all): không filter
#   - role với branch_mode=scoped: branch_id = X-Hasura-Branch-Id
#   - role không có module permission: deny tất cả
select_permissions:
  - role: admin
    permission:
      filter: {}
      columns: '*'
  - role: branch_manager
    permission:
      filter:
        branch_id: { _eq: X-Hasura-Branch-Id }
      columns: '*'

FE permission check (action-level):

🛑 V6 BLOCKER: Helper API name (hasActionPermission vs hasModulePermission) chưa được Security Lead confirm. Snippet dưới dùng tên hasActionPermission — BE/Security cung cấp tên đúng + signature trước implement.

typescript
// Replace legacy globalStore.reportRoles với Dynamic Permission v2 helper (action-level)
import { hasActionPermission } from '@/composables/useDynamicPermission'  // 🔍 V6 verify tên helper

// 3 actions của module — TẤT CẢ check với cùng module_id
const MODULE_ID = 'report.prepaid_analytics'

const canView = computed(() => hasActionPermission(MODULE_ID, 'view'))
const canExport = computed(() => hasActionPermission(MODULE_ID, 'export'))
const canViewFullPhone = computed(() => hasActionPermission(MODULE_ID, 'view_full_phone'))

// Route guard:
if (!canView.value) {
  router.push('/r/reports')  // redirect về report list
}

// Export buttons (dùng chung 1 permission cho TẤT CẢ buttons trong module — DEC-U10):
<QBtn v-if="canExport" label="Xuất Excel" />
<QBtn v-if="canExport" label="Xuất Sổ doanh thu" />

// SDT mask:
const displayPhone = computed(() =>
  canViewFullPhone.value ? customer.phone_number : maskPhone(customer.phone_number)
)

SĐT masking (PII) — BE enforce:

  • Mặc định mask 0912***456 ở FE display + BE response
  • Unmask CHỈ khi user có report.prepaid_analytics.view_full_phone (action permission CỦA module này, KHÔNG dùng customer.view_full_phone chung)
  • Hasura permission rule trên mv_prepaid_customer_stats: dùng computed column phone_number_masked cho role không có action view_full_phone
  • Export endpoint cũng phải mask theo cùng rule (BE đọc permission JWT, không trust FE)

Export audit:

  • Bảng export_job (Section 9.x) lưu: user_id, module_id='report.prepaid_analytics', export_type (sổ DT / công nợ / HH / dư ví / KH list / transactions), filter_params (jsonb), phone_unmask (bool), row_count, created_at, completed_at, file_url, downloaded_at
  • Retention 90 ngày, sau đó archive vào audit DB
  • Permission gate ở entry (1 action export cover tất cả export trong module — DEC-U10), audit chi tiết per-export-type ở data

Phase 3+ enhancements (KHÔNG làm Phase 1):

  • Module permission UI trong Settings → Phân quyền cho admin tự cấp/thu hồi
  • Granular per-export-type permission (nếu compliance yêu cầu)

2.6 Feature Flag & Rollback

// Trong report/module.ts
const FEATURE_PREPAID_ANALYTICS_V2 = true; // Toggle

// Route config:
if (FEATURE_PREPAID_ANALYTICS_V2) {
  // Show new analytics tab, hide old report tab
} else {
  // Show old report tab only
}

Cho phép rollback ngay lập tức khi gặp vấn đề production.

2.7 UI State Matrix

StateXử lý
LoadingSkeleton loader cho KPI cards + charts. Table hiển thị shimmer rows.
EmptyHiển thị message “Không có dữ liệu trong khoảng thời gian này” + gợi ý điều chỉnh filter
ErrorToast error + retry button. Log error về monitoring.
No PermissionẨn sub-tab không có quyền. Nếu không có quyền nào → redirect về report list
Partial / StaleHiển thị badge “Dữ liệu cập nhật lúc: HH:mm”. Nếu MV refresh fail → hiển thị warning “Dữ liệu có thể chưa cập nhật”

3. Sub-tab 1: Tổng quan (Default Landing)

3.1 KPI Cards (8 cards, 2 hàng)

Tên KPI khớp 100% với prd.md A9 Glossary. Header bảng/cell luôn dùng tên đầy đủ.

Hàng 1 — Tài chính:

KPI (label hiển thị)Công thức chính xácSo sánh kỳ trước
Tiền thu vàoSUM(invoice.amount) WHERE invoice.canceled_at IS NULL AND order.order_kind=‘prepaid’↑↓ %
DT ghi nhậnSUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id = 'wallet' AND canceled_at IS NULL — chỉ ví chính (DEC-B06, KHÔNG tính ví KM)↑↓ %
Tổng dư víSUM(wallet_balance_diva + wallet_balance_km) cho tất cả KH có ví — là nợ phải trả. Nếu source BE chỉ có wallet_balance.balance, V5 phải reconcile balance = diva + kmGiá trị tuyệt đối
Công nợSUM(order.total_amount - order.paid_amount) WHERE order.paid_amount < order.total_amount AND order_kind=‘prepaid’Cảnh báo đỏ nếu > ngưỡng

Hàng 2 — Vận hành:

KPI (label hiển thị)Công thức chính xácSo sánh kỳ trước
Thẻ đã bánSUM(order_item.quantity) WHERE order.order_kind=‘prepaid’ AND order.paid_at IS NOT NULL. Dòng phụ dưới giá trị chính: "X Cố định + Y Linh hoạt" (tách theo prepaid_card.flexible). Font 12px, màu gray-6.↑↓ %
Tỷ lệ KH đã dùng víCOUNT(DISTINCT customer WHERE has_wallet_usage) / COUNT(DISTINCT customer WHERE bought_prepaid) × 100↑↓ %
Tỷ lệ KH tái nạpCOUNT(DISTINCT customer WHERE prepaid_order_count >= 2) / COUNT(DISTINCT customer WHERE prepaid_order_count >= 1) × 100↑↓ %
KH mớiCOUNT(DISTINCT customer) WHERE lần đầu mua prepaid trong kỳ xem↑↓ %

Click vào card → nhảy sang sub-tab liên quan.

3.2 Biểu đồ (4 charts, grid 2x2)

ChartLoạiDữ liệu
Doanh thu theo thời gianLine chartTiền thu vào theo ngày/tuần/tháng + đường nét đứt kỳ trước
So sánh khu vựcGrouped bar chart (không dùng heatmap — Chart.js không có native heatmap)Doanh thu + thẻ bán theo khu vực, mỗi khu vực 1 nhóm cột
Phân bố mệnh giá thẻDonutTỷ lệ thẻ theo giá trị (1tr, 5tr, 10tr, 20tr) + 1 segment riêng “Nạp linh hoạt” gộp tất cả flexible cards. Hover segment → tooltip hiện khoảng mệnh giá phổ biến (VD: “Nạp linh hoạt: 35 thẻ, TB 7.2tr, khoảng 2tr–15tr”)
Tỷ lệ sử dụng trendArea chartTỷ lệ KH đã dùng ví + % Đã dùng ví theo thời gian

3.3 Alert Box — "Cần chú ý" (3 mức độ) — Rich Context v3.14 (DEC-U13)

Update v3.14 (DEC-U13): Backend payload mở rộng để hỗ trợ rich context UX (xem ui-spec §3.3). Mỗi alert object trả về phải có 5 field mới: sla_label, trend_value, prior_count, suggested_action_key, navigate.sort_override.

Alert payload schema (compute_prepaid_alerts return type):

typescript
interface PrepaidAlert {
  alert_id: string;              // unique ID — VD: "critical-overdue-DALAT-20260512"
  type: AlertType;               // 'overdue_gt30d' | 'inactive_gt60d' | 'revenue_drop' | 'vip_inactive' | 'card_sales_drop'
  severity: 'critical' | 'warning' | 'info';

  // === NEW v3.14 (DEC-U13) ===
  sla_label: string;             // VD: "⚠️ Cần xử lý trong 24h" | "📅 Cần xử lý trong 7 ngày" | "👀 Chỉ thông tin"
  trend_value: number | null;    // số thay đổi vs prior period — VD: 15 (tăng 15 đơn). null nếu không có history
  prior_count: number | null;    // count kỳ trước (để FE tính lại nếu cần) — VD: 105
  trend_direction: 'up' | 'down' | 'flat' | null;
  trend_compare_label: string | null;  // VD: "vs 7d qua" — render trong chip ↑15 (vs 7d qua)
  suggested_action_key: string;        // i18n key — VD: "alert.suggest.call_overdue_debt"

  // === Existing (v3.13 trở xuống) ===
  title: string;                 // banner top text — VD: "CN Đà Lạt — 120 đơn quá hạn > 30 ngày"
  metric_text: string;           // Card line 1 — VD: "120 đơn quá hạn > 30 ngày — Công nợ 45 tr"
  count: number;                 // primary count cho button label — VD: 120
  branch_id: string | null;
  branch_name: string | null;
  card_code: string | null;      // chỉ cho alert type 'card_sales_drop'
  card_name: string | null;

  navigate: {
    target_tab: 'overview' | 'transactions' | 'customers' | 'finance';
    target_inner_tab: string | null;     // VD: 'debt' cho Tài chính
    filter_chips: Array<{key: string; value: string; label: string}>;
    range_override: string | null;       // VD: 'last_365d'. null = giữ shared range
    sort_override: {field: string; direction: 'asc' | 'desc'} | null;  // NEW v3.14
    scroll_to_anchor: string | null;     // VD: '#chart-revenue-trend' (chỉ cho revenue_drop)
  };
}

Suggested action i18n keys (mapping):

typescript
const SUGGESTED_ACTION_I18N = {
  'overdue_gt30d': 'alert.suggest.call_overdue_debt',
    // VI: "Gọi KH hoặc gửi SMS nhắc thanh toán"
  'inactive_gt60d': 'alert.suggest.send_reactivation_sms',
    // VI: "Gửi SMS/ZNS kích hoạt lại"
  'revenue_drop': 'alert.suggest.analyze_reason',
    // VI: "Phân tích lý do (nhân sự / cạnh tranh / mùa vụ)"
  'vip_inactive': 'alert.suggest.call_vip',
    // VI: "Lên DS gọi điện hỏi thăm"
  'card_sales_drop': 'alert.suggest.compare_pricing',
    // VI: "So sánh giá / promotion với kỳ trước"
};

Primary action label templates:

typescript
const ACTION_LABEL_TEMPLATES = {
  'overdue_gt30d': '📋 Xem {count} đơn',
  'inactive_gt60d': '👥 Xem {count} KH',
  'revenue_drop': '📊 Xem chart doanh thu',
  'vip_inactive': '⭐ Xem {count} KH VIP',
  'card_sales_drop': '💳 Xem chi tiết thẻ {card_name}',
};

Visual sample (FE render từ payload):

🔴 Khẩn cấp (2)  ⚠️ Cần xử lý trong 24h
  ┌─ Alert card 1 ────────────────────────────────────────────┐
  │ 📍 CN Đà Lạt                                              │
  │ 120 đơn quá hạn > 30 ngày — Công nợ 45 tr  ↑15 (vs 7d qua)│
  │ 💡 Gợi ý: Gọi KH hoặc gửi SMS nhắc thanh toán            │
  │ [📋 Xem 120 đơn]                                          │
  └───────────────────────────────────────────────────────────┘

Lưu ý: Không có tính năng "thẻ hết hạn" vì prepaid_card không có expiry field. Thay bằng alert dựa trên hành vi sử dụng (KH lâu không dùng + còn dư ví).

Alert backend function — UPDATED v3.14: Xem Section 9.9 — function compute_prepaid_alerts phải:

  1. Tính trend_value bằng cách so sánh count kỳ này vs kỳ trước (sliding window theo last_7d cho overdue/inactive/vip_inactive, FORMULA-019 cho revenue_drop)
  2. Return sla_label hardcoded theo severity (mapping severity → sla_label)
  3. Return suggested_action_key mapping theo type
  4. Return sort_override mapping theo type (overdue → overdue DESC, inactive → last_wallet_usage_at ASC, vip_inactive → total_paid DESC, card_sales_drop → null (dùng default paid_at DESC của Sub-tab Giao dịch))
  5. Performance budget: < 500ms cho toàn bộ alert list (compute trend cần extra query — cân nhắc cache hoặc store prior count trong MV).

3.4 Bảng xếp hạng nhanh (3 mini-tables)

  • Top 5 thẻ bán chạy: Tên thẻ · Số đã bán · Doanh thu · Trend
  • Top 5 nhân viên giỏi: Nhân viên · Chi nhánh · Số đơn · Doanh thu · Hoa hồng
  • Top 5 khách VIP: Khách hàng · Tổng tiền đã nạp · Dư ví · Tần suất nạp / tháng

Section 3.5 Dòng chảy khách hàng đã được loại bỏ (ref: prd.md DEC-U04). MV mv_prepaid_branch_flow không còn cần thiết — đã xóa khỏi Section 9.


4. Sub-tab 2: Giao dịch

Tiêu đề: "Chi tiết giao dịch nạp thẻ"

4.1 Local Filter (2 element — DEC-U12)

Update 2026-05-04 (DEC-U12): BỎ HẲN local search input. Search dùng từ shared filter top (single source). Local filter chỉ giữ 2 dropdown structured.

FilterGiá trịImplementation
Loại thẻTất cả · Cố định · Linh hoạtprepaid_card_view.flexible
Trạng thái thanh toánTất cả · Đã thanh toán đủ · Còn nợ · Chưa thanh toánDerive paid_amount vs total
Tìm kiếm❌ Removed (DEC-U12)Dùng shared qusePrepaidAnalyticsFilter().filter.q. GraphQL where._or trên order.code, customer.display_name, customer.phone_search, prepaid_card.code. Min 2 chars, debounce 300ms ở shared input

URL sync: ?card_type=fixed&status=debt (q ở root URL: ?q=Nguyen — persist cross-tab)

4.2 Sum Cards (7 cards)

Đổi từ summary bar 1 dòng → grid 7 sum cards (mỗi card có label + value + ↑↓% so kỳ trước). Layout responsive: desktop 7 cột auto-fit → tablet 4+3 → mobile 1 cột.

#Card (label)Source / FormulaNotes
1Tổng đơnCOUNT(DISTINCT order.id) WHERE filterSố đơn prepaid trong kỳ + filter
2Tổng thuSUM(invoice.amount) WHERE canceled_at IS NULL= Tiền thu vào
3Tổng nạp víSUM(order_item.prepaid_value_into_wallet) ⚠️ KHÔNG × quantity (Section 0.2 Rule 1)= Ví Diva + Ví KM
4Ví DivaPROVISIONAL (V9 BLOCKER) Theo PRD A10.0 hàng #2: SUM(parent_invoice.reference_amount) cho prepaid order. CẢNH BÁO: KHÔNG dùng Tổng thu hoặc Tổng nạp ví − Ví KM (Review L8 chứng minh SAI khi có discount/base≠sell).
5Ví KMPROVISIONAL (V10 BLOCKER) Theo PRD A10.0 hàng #3: SUM(parent_invoice.wallet_promotion_amount) cho prepaid order. CẢNH BÁO: KHÔNG dùng Tổng nạp ví − Tổng thu (Review L8 chứng minh SAI).
6Tổng nợSUM(order.total − order.paid_amount) WHERE paid_amount < totalCông nợ chưa thu
7Tổng hoa hồng ⭐ DEC-T08SUM(invoice_commission.amount) WHERE invoice_status='invoice_completed' AND order_id IN (prepaid orders trong kỳ). Net = gross − SUM(ABS(transaction_request.amount)) WHERE behavior_id='refund_commission'Source mới: invoice_commission. Bỏ filter transaction_commission (đã disabled trong code)

⚠️ Schema verification (V9-V10 BLOCKER) — bám PRD A10.0 Canonical Wallet Table:

  • KHÔNG được implement Ví Diva / Ví KM theo công thức cũ (= Tổng thu / = Tổng nạp − Tổng thu). Review L8 đã chứng minh SAI.
  • BE phải confirm parent_invoice.reference_amount (Ví Diva nạp) + parent_invoice.wallet_promotion_amount (Ví KM nạp) là source đúng cho prepaid order — kèm code path từ usePrepaidOrderItem.getValueIntoWalletpayment_order.goinvoice_insert_update.go.
  • Sau khi V9-V10 confirmed → update MV mv_prepaid_order_daily thêm 2 cột total_vi_diva_napped, total_vi_km_napped (JOIN sang invoice với parent_id IS NULL).
  • Trước khi V9-V10 confirmed: 2 cột này có thể stub NULL hoặc fallback hiển thị "—" trên UI (KHÔNG hiển thị giá trị derived sai).

Trend (so sánh kỳ trước): mỗi card hiển thị ↑↓% — tính từ kỳ liền kề trước (N ngày trước = kỳ trước).

4.3 Bảng giao dịch — Expandable Rows (10 cột)

Thay đổi quan trọng: Gộp 2 cột Loại thẻ + Tên thẻ thành 1 cột "Tên thẻ" với badge inline. Format multi-line cho 3 cột (Ngày TT, Khách hàng, Nhân viên).

Cột mặc định (10 cột):

#Cột (label header)FormatSource
1Mã đơnLink TT-XXXXXXXXorder.code
2Ngày TTMulti-line: HH:mm / DD/MM/YYYYorder.paid_at
3Khách hàngMulti-line: tên (link CRM) / SDTcustomer.display_name + customer.phone_number
4Tên thẻBadge [Cố định]/[Linh hoạt] inline + tên thẻprepaid_card.flexible (badge) + prepaid_card.name (text). Khi flexible=truename rỗng: "Nạp linh hoạt {prepaid_value_into_wallet}"
5SLNumber, centerorder_item.quantity
6Tiền thuVND right, sortableSUM(invoice.amount) per order
7Nạp víVND right, sortableorder_item.prepaid_value_into_wallet (per item, đã là line total — Section 0.2 Rule 1)
8Trạng tháiQBadge pill (xanh/vàng/đỏ)Derived từ paid_amount vs total
9Nhân viênMulti-line: NV bán (avatar + tên link) / NV thu ngân (TBD verify)order_commission.user_id (NV bán); NV thu ngân TBD — verify schema
10Chi nhánhTextorder.branch_idbranch.name

Schema TBD — NV thu ngân: verify field source. Có thể là order.created_by, invoice.created_by, hoặc field riêng cashier_id. Hỏi backend team trước khi implement.

Expanded row (2 section, lazy load):

  1. Chi tiết thanh toán: Query invoice WHERE order_id = ? → Phương thức · Số tiền · Ngày (multi-line HH:mm DD/MM/YYYY) · Trạng thái
  2. Hoa hồng ⭐ DEC-T08: Query invoice_commission WHERE order_id = ? AND invoice_status = 'invoice_completed' → Nhân viên (user_idecommerce_user.display_name) · Số tiền (amount). Nếu đơn có refund: union với transaction_request WHERE order_id = ? AND behavior_id = 'refund_commission' → hiển thị row số âm với badge ↩️ Hoàn HH

⚠️ DEC-T07 — Đã loại bỏ tab "Lịch sử sử dụng" khỏi expanded row đơn nạp.

Codebase finding (verify 2026-05-12):

  • Schema transaction_request chỉ có order_id (= đơn dịch vụ đang tiêu ví), KHÔNG có field prepaid_card_id / source_prepaid_order_id / from_prepaid_order_id
  • Topup wallet khi mua thẻ: tạo invoice, KHÔNG tạo transaction_request (file ecommerce-api/action/order_confirm.go)
  • Consume wallet: FIFO từ pool chung (wallet.amount), không có wallet_topup_lot

Kết quả: Query gốc transaction_request WHERE order_id = ? (với ? = id đơn thẻ nạp) trả 0 rows. Kể cả khi đảo ngược semantics, vẫn không thể attribute usage về thẻ cụ thể khi KH có ≥2 thẻ.

Lịch sử biến động ví → hiển thị ở Sub-tab Khách hàng → expanded row KH (Section 5.x — per customer, đúng level pool). FIFO lot accounting defer Phase 3+ nếu Finance yêu cầu audit nghiêm ngặt.

ClickĐiều hướng
Mã đơn/e/prepaid-order/:id
Tên Khách hàngCRM profile
Tên Nhân viênSub-tab Nhân viên (Phase 3+ defer; tạm thời disable click hoặc navigate đến CRM staff profile)
Trạng thái "Còn nợ" / "Chưa thanh toán"Dialog xác nhận thanh toán

4.5 Pagination

Pagination strategy
Keyset cursor trên (order.paid_at DESC, order.id DESC) — flat list. 20 rows/page (configurable 10/20/50).

5. Sub-tab 3: Khách hàng

5.1 Segment Cards — Phân khúc tự động

Phân khúcĐiều kiện (SQL logic)Hành động gợi ý
🟢 Hoạt độnglast_wallet_usage_at >= now() - interval '30 days'Upsell thẻ lớn hơn
🟡 Ngủ đônglast_wallet_usage_at BETWEEN now()-60d AND now()-30d AND wallet_balance > 0Gửi nhắc nhở, ưu đãi kích hoạt
🔴 Rủi ro mấtlast_wallet_usage_at < now() - 60d AND wallet_balance > 0Gọi điện, ưu đãi đặc biệt
🔵 Mớifirst_prepaid_order_at trong kỳ xemChào đón, hướng dẫn sử dụng

last_wallet_usage_at = MAX(transaction_request.created_at) WHERE type='T' (Transfer = sử dụng ví)

5.2 Bảng khách hàng — Expandable Rows

Cột mặc định (label đầy đủ): Khách hàng (link CRM) · SĐT · Phân khúc (tag màu) · Tổng tiền đã nạp · Dư ví DIVA · Dư ví KM · Đã dùng ví · % Đã dùng ví (progress bar) · Số lần mua thẻ · Lần dùng ví cuối · CN mua · CN sử dụng · Số CN đã dùng

Expanded row:

  1. Danh sách thẻ/gói đã mua: Loại thẻ, tên thẻ, CN mua, ngày, giá trị, trạng thái
  2. Hành vi sử dụng: Tần suất, dịch vụ hay dùng, giờ hay đến, CN hay đến (quan trọng với 70 CN)
  3. Gợi ý hành động: Giảm tần suất → ưu đãi kích hoạt, dư ví thấp → gợi ý tái nạp

5.3 Chi nhánh mua vs Chi nhánh sử dụng

  • CN mua (branch_sold): order.branch_id — CN nơi bán thẻ
  • CN sử dụng (branch_used): transaction_request.branch_id WHERE type='T' — CN nơi KH dùng ví
  • Cả 2 đều hiển thị trong bảng Khách hàng (cột "CN mua" + "CN sử dụng")
  • branch_count > 1 → KH cross-branch (badge nổi bật)

5.4 Export (read-only — KHÔNG bulk actions)

Update 2026-05-04 (review L5): Sub-tab Khách hàng = pure read-only. BỎ HẲN bulk actions (SMS/ZNS/Gán NV) khỏi tab Analytics. Chỉ giữ export Excel ở header bảng.

Implementation:

  • Component CustomerTableHeader.tsx: title trái + button [📥 Tải Excel] phải
  • Click → trigger server-side export job (async, qua export-api)
  • Export TOÀN BỘ KH theo filter hiện tại (không cần multi-select)
  • Permission check: report.prepaid_analytics.export ở BE endpoint
  • SDT mask trong file theo view_full_phone permission (BE enforce — không trust FE)
  • Audit log vào export_job table (xem Section 9.x)

Out of scope (KHÔNG implement trong Analytics):

  • notification-api integration cho bulk SMS/ZNS
  • ❌ Gán NV chăm sóc bulk
  • selection prop trên QTable

5.5 Chỉ số hành vi khách hàng (đổi từ "CLV Bar")

Quyết định 2026-05-04: Đổi từ "CLV Bar" (4 metrics phức tạp) → "Chỉ số hành vi khách hàng" (3 metrics đơn giản, AOV-based). Lý do: Phase 1 MVP chưa có data tích lũy dài → CLV thuần không meaningful. AOV + Tỷ lệ tái nạp + Chu kỳ TB cover đủ 3 góc behavior (độ lớn đơn · retention · frequency). Tránh trùng lặp với Segment Cards (5.1) và Customer Table (5.2).

Giá trị đơn TB: 2,4 tr/đơn | Tỷ lệ tái nạp: 68% | Chu kỳ trung bình: 24 ngày
Trường (label hiển thị)Công thứcĐơn vị
Giá trị đơn hàng trung bình (AOV)SUM(invoice.amount) / COUNT(DISTINCT order.id) cho đơn prepaid trong kỳ + filterđ/đơn
Tỷ lệ tái nạpCOUNT(DISTINCT customer WHERE prepaid_order_count >= 2) / COUNT(DISTINCT customer WHERE prepaid_order_count >= 1) × 100%
Chu kỳ trung bìnhCustomer-weighted mean (2 bước) — (1) per-customer AVG(paid_at[n+1] − paid_at[n]), (2) AVG(per_customer_avg) across customers. Scope: cycle-closes-in-period + cap khoảng > 180d. Implementation: query AVG(avg_cycle_days) từ mv_prepaid_customer_stats WHERE cycle_count >= 1. Ref: PRD A10 FORMULA-013 + DEC-T09. Aggregate SQL: §9.2 ngay sau MV definitionngày

Tỷ lệ rời bỏ (Churn) — bỏ khỏi UI (giữ formula trong A9 Glossary làm reference). Lý do: trùng tín hiệu với phân khúc "🔴 Rủi ro mất" ở Segment Cards (5.1). Marketing nhìn segment "Rủi ro mất" + "Ngủ đông" là biết churn signal mà không cần metric riêng.

Trend so sánh kỳ trước: dynamic label theo filter "Khoảng thời gian" (xem ui-spec 5.5 bảng chi tiết).


6. Sub-tab 4: Tài chính

6.1 KPI Cards Tài chính (8 cards, 2 hàng)

Hàng 1: Tiền thu vào · Nạp ví · Tổng dư ví (nợ phải trả) · Công nợ

Hàng 2: DT ghi nhận · Hoa hồng (% Doanh thu) · KM đã nạp · Lợi nhuận gộp

Định nghĩa chính xác:

KPI (label hiển thị)SourceCông thứcV-ref
Tiền thu vàoinvoice.amount (ecommerce)SUM WHERE parent_id IS NULL, order_kind=‘prepaid’, canceled_at IS NULL✅ V8 LOCKED
Nạp víorder_item.prepaid_value_into_wallet (đã là line total — KHÔNG × quantity)SUM — giá trị thực nạp vào ví KH✅ LOCKED
Tổng dư víwallet_balance_diva + wallet_balance_km (exact fields chờ V5 confirm; wallet DB)SUM cho tất cả KH có prepaid order; reconcile với total-only wallet_balance.balance nếu BE expose sẵn total⏳ V5
Công nợorder.total - order.paid_amountSUM WHERE paid_amount < total_amount✅ LOCKED
DT ghi nhậninvoice.reference_amount filter payment_method_id='wallet' (PRD A10.0 hàng #4 — DEC-B06 PROVISIONAL)SUM WHERE parent_id IS NULL AND payment_method_id='wallet' AND canceled_at IS NULL⏳ V11 PROVISIONAL
KM đã sử dụnginvoice.reference_amount filter payment_method_id='wallet_promotion' (PRD A10.0 hàng #5 — V12). ❌ KHÔNG dùng wallet_promotion_amount — Review L8 chứng minh field này là KM NẠP, không phải usageSUM WHERE parent_id IS NULL AND payment_method_id='wallet_promotion' AND canceled_at IS NULL⏳ V12 PROVISIONAL
Hoa hồng ⭐ DEC-T08invoice_commission.amount (ecommerce DB)SUM WHERE invoice_status='invoice_completed' AND order_id IN (prepaid orders trong kỳ). Net = gross − refund✅ LOCKED (DEC-T08)
KM đã nạpSUM(parent_invoice.wallet_promotion_amount) cho prepaid order (PRD A10.0 hàng #3 — V10). ❌ KHÔNG derive bằng total_wallet_topup − total_collected (Review L8)SUM WHERE parent_id IS NULL, order_kind='prepaid', canceled_at IS NULL⏳ V10 PROVISIONAL
Lợi nhuận gộpDerived sau khi V10 confirmedTiền thu vào − Hoa hồng − KM đã nạp⏳ V10 dependency

⚠️ Cảnh báo BE: 4 hàng ⏳ PROVISIONAL trên PHẢI bám PRD A10.0 Canonical Wallet Table. Đoạn SQL trong MV mv_prepaid_finance_daily hiện đang wrap trong 🔒 PROVISIONAL — DO NOT IMPLEMENT block (xem Section 9.2 dev-spec) — chờ V10/V11/V12 unlock trước khi enable.

Lưu ý: "Lợi nhuận gộp" (Gross margin) — KHÔNG phải lãi ròng. Chỉ tính chi phí trực tiếp từ bán thẻ (Hoa hồng + KM đã nạp). Không bao gồm chi phí vận hành, nhân sự, mặt bằng.

Công thức dòng tiền (Phase 1 — chờ V10 unlock):

Tiền thu vào (850tr) − Hoa hồng (38tr) − KM đã nạp (70tr⏳) = Lợi nhuận gộp (742tr⏳)
Tổng dư ví (2.1 tỷ) = Nợ phải trả (nghĩa vụ dịch vụ tương lai)

⏳ V10 PROVISIONAL — KM đã nạp lấy CANONICAL từ field gốc:
   KM đã nạp = SUM(parent_invoice.wallet_promotion_amount) cho prepaid order
             WHERE parent_id IS NULL
               AND canceled_at IS NULL
               AND (status='invoice_completed' OR status IS NULL)   -- Rule 0a
   Giá trị 70tr trong ví dụ trên = INPUT đã verify từ V10 (KHÔNG được derive)

❌ FORBIDDEN (Review L8 chứng minh SAI):
   ~~KM đã nạp = Nạp ví − Tiền thu vào~~
   Lý do: SAI khi giá bán ≠ base value (discount/phụ thu)
   VD: thẻ 10tr bán 8tr discount, KM 2tr
       → derive cũ:  12 − 8 = 4tr ❌ (sai 2x)
       → canonical:  parent_invoice.wallet_promotion_amount = 2tr ✅
   Xem PRD A10.0 + FORMULA-009.

Overview KPI cards là summary link — click vào → nhảy sang sub-tab Tài chính để xem chi tiết đầy đủ.

6.2 4 Tabs con (component-level)

Tab con 1: Tổng hợp doanh thu

  • Bảng doanh thu theo thời gian (ngày/tuần/tháng)
  • Cột: Thời gian · Tiền thu vào · Nạp ví · KM đã nạp · Hoa hồng · Lợi nhuận gộp · Số đơn
  • Line chart doanh thu + kỳ trước (so sánh tự động)

Tab con 2: Công nợ

  • Danh sách đơn còn nợ với phân loại quá hạn
  • Mức độ: Bình thường (< 15 ngày) | Cảnh báo (15-30) | Nghiêm trọng (> 30)
  • Hành động: Gọi KH · Xác nhận thanh toán · Ghi chú
  • Summary: Tổng công nợ, phân theo mức độ

Tab con 3: Hoa hồng ⭐ DEC-T08

  • Tổng hợp Hoa hồng theo Nhân viên từ bán thẻ trả trước
  • Data source (UPDATED DEC-T08): invoice_commission JOIN order (qua order_id) WHERE invoice_status = 'invoice_completed' AND order_kind = 'prepaid'. Refund track riêng qua transaction_request WHERE behavior_id = 'refund_commission'
  • Cột: Nhân viên · Chi nhánh · Số đơn · Doanh thu đơn bán · Hoa hồng (gross) · Hoàn HH · Hoa hồng net · % Hoa hồng / Doanh thu
  • Expand: chi tiết từng đơn — bỏ cột Trạng thái (1 status thực tế), thay bằng cột Loại (Đã chi / ↩️ Hoàn HH): Mã đơn · Khách hàng · Ngày TT · Doanh thu · Hoa hồng · Loại

Tab con 4: Phương thức thanh toán

  • Doanh thu theo cách KH trả tiền
  • Data source: invoice GROUP BY payment_method_id
  • Cột: Phương thức · Số đơn · Số tiền · % Tổng · Trend
  • Donut chart tỷ lệ phương thức thanh toán

6.3 Export đặc biệt cho Kế toán

  • Sổ doanh thu thẻ trả trước (Excel, có tổng cộng)
  • Danh sách công nợ (Excel)
  • Bảng hoa hồng (Excel, kế toán lương cần)
  • Đối soát chi nhánh (Excel, mỗi CN 1 sheet)
  • Báo cáo dư ví (Excel)

Tất cả xử lý server-side qua export-api, async với progress bar.


7. Sub-tab 5: Marketing

🚫 Phase 3+ — Defer indefinitely (TBD). Section này KHÔNG build trong Phase 1 (ref: prd.md DEC-B05). Giữ trong spec để team có context tổng thể; sẽ ưu tiên lại khi team Marketing yêu cầu báo cáo chuyên sâu vượt quá Sub-tab Khách hàng (Phase 1).

7.1 KPI Cards (8 cards, 2 hàng)

Hàng 1: Đơn từ chiến dịch, DT từ chiến dịch, KH mới từ CD, Tỷ lệ chuyển đổi Hàng 2: Đơn từ affiliate, DT từ affiliate, Chi phí MKT, ROI

ROI tính như thế nào:

  • Chi phí MKT = SUM(hoa hồng affiliate). Nếu campaign có field budget → cộng thêm.
  • ROI = (DT từ CD + affiliate - Chi phí) / Chi phí * 100%
  • Nếu campaign CHƯA có field budget → ROI chỉ dựa trên hoa hồng affiliate (ghi chú trên UI)

7.2 4 Tabs con

Tab con 1: Chiến dịch

  • Bảng hiệu quả từng chiến dịch
  • Data source: order WHERE campaign_ids IS NOT NULL, JOIN campaign
  • Cột: Chiến dịch, Thời gian, Đơn, DT, KH mới, Thẻ bán chạy, KV hiệu quả
  • Trạng thái (ref PRD A10 FORMULA-020): Hiệu quả ROI > 200% | Trung bình 100% ≤ ROI ≤ 200% | Kém ROI < 100%. Edge: marketing_cost = 0 → hiển thị "—" + tag "Chưa có budget" (KHÔNG gán Kém)
  • Expand: hiệu quả theo khu vực, DT theo ngày, loại thẻ bán, danh sách KH

Tab con 2: Affiliate

  • Bảng hiệu quả từng nguồn giới thiệu
  • Data source: order WHERE ref_code IS NOT NULL
  • Phân loại: NV nội bộ, KH cũ, Đối tác

Tab con 3: Nguồn khách hàng

  • Donut chart: Walk-in / Chiến dịch / Affiliate / Tái nạp
  • Stacked area chart trend theo tháng
  • Logic phân loại: campaign_ids NOT NULL → Chiến dịch, ref_code NOT NULL → Affiliate, KH đã mua trước → Tái nạp, còn lại → Walk-in

Tab con 4: So sánh hiệu quả

  • Bảng so sánh tất cả kênh marketing
  • Cột: Kênh · Số đơn · Doanh thu · Chi phí · ROI
  • Insight tự động: kênh nào ROI cao nhất, kênh nào cần cải thiện

7.3 Hành động Marketing

  • Tạo chiến dịch mới (link module settings)
  • Xem KH từ chiến dịch (link sub-tab Khách hàng)
  • Xuất báo cáo chiến dịch (Excel)
  • Nhân bản chiến dịch thành công

8. Sub-tab 6: Nhân viên

🚫 Phase 3+ — Defer indefinitely (TBD). Section này KHÔNG build trong Phase 1 (ref: prd.md DEC-B05). Phase 1 đã có Hoa hồng theo NV ở Sub-tab Tài chính (Tab con 3) cho Kế toán đối soát lương — đủ nghiệp vụ cấp thiết. Section này (ranking, chi tiết NV, so sánh CN) ưu tiên lại khi có nhu cầu coaching từ team Quản lý vùng.

8.1 KPI Cards (4 cards)

KPI (label hiển thị)Công thức
Nhân viên có đơnCOUNT DISTINCT seller / Tổng NV × 100%
Số đơn TB / NVTổng số đơn / Số NV có đơn
Doanh thu TB / NVTổng Doanh thu / Số NV có đơn
Tổng Hoa hồng đã chiSUM commission (% Doanh thu)

8.2 3 Tabs con

Tab con 1: Xếp hạng

  • Bảng xếp hạng toàn hệ thống (70 CN)
  • Filter: Xếp theo (Doanh thu / Số đơn / Doanh thu TB / KH mới / Hoa hồng) · Khu vực · Top (10/20/50)
  • Cột: # · Nhân viên · Chi nhánh · Khu vực · Số đơn · Doanh thu · Hoa hồng · Doanh thu TB / đơn · KH mới
  • Highlight: 🟢 Xuất sắc (> 150% TB), 🔴 Cần cải thiện (< 50% TB)
  • Chỉ số quan trọng: "77% NV chưa bán thẻ → cơ hội đào tạo"

Tab con 2: Chi tiết nhân viên

  • Profile: KPI của 1 NV cụ thể
  • Trend Doanh thu 6 tháng (line chart vs TB hệ thống)
  • Danh sách đơn đã bán (paginated)
  • Phân tích: loại thẻ hay bán, giờ bán tốt, KH tái nạp qua NV, chiến dịch tham gia

Tab con 3: So sánh chi nhánh

  • Bảng hiệu suất NV theo Chi nhánh / Khu vực
  • Cột: Chi nhánh / Khu vực · Tổng NV · NV có đơn · % Tham gia · Số đơn TB / NV · Doanh thu TB / NV
  • Nhóm 2 cấp: Khu vực (collapsed) → Chi nhánh (expand)
  • Insight tự động: "CN mẫu" / "Cần đào tạo"

9. Tối ưu hiệu năng cho 70 CN

9.1 Materialized Views (Concurrent Refresh)

Tất cả MVs đặt trong ecommerce database. Wallet data derive từ invoice.reference_amount + invoice.wallet_promotion_amount (ecommerce) thay vì query cross-schema sang wallet DB.

MVPhaseMục đíchGROUP BYRefreshEst. size
mv_prepaid_order_dailyP1KPIs Tổng quan + Tài chính (order-level metrics)date, branch_id, region_id15 min~150K rows
mv_prepaid_card_dailyP1Chart "Phân bố mệnh giá" + Top thẻ bán chạy (card-level metrics)date, branch_id, product_id15 min~500K rows
mv_prepaid_customer_statsP1Sub-tab Khách hàngcustomer_id30 min~100K rows
mv_prepaid_finance_dailyP1Tab Tài chính (theo PTTT)date, branch_id, payment_method_id15 min~500K rows
mv_prepaid_staff_statsP3+Nhân viênuser_id, branch_id30 min~50K rows
mv_prepaid_campaign_statsP3+Marketingcampaign_id, branch_id30 min~10K rows

⚠️ Thay đổi v3.0 (review L4 fix double-count): MV cũ mv_prepaid_summary_daily group by (date, branch, region, product_id) đã bị TÁCH thành 2 MVs:

  • mv_prepaid_order_daily ⭐ (order-level — group by (date, branch, region) KHÔNG có product_id) → đảm bảo total_collected không bị nhân với số order_item
  • mv_prepaid_card_daily ⭐ (card-level — group by (date, branch, product_id)) → cho chart phân bố mệnh giá

Phase 1 deploy 4 MVs. 2 MVs P3+ (staff_stats, campaign_stats) giữ làm reference, KHÔNG chạy migration. mv_prepaid_branch_flow đã loại bỏ (DEC-U04).

Riêng wallet_balance (tổng dư ví): Query trực tiếp từ wallet DB qua Hasura remote relationship, cache kết quả vào Redis (TTL 10 min). Không tạo MV cho metric này vì nó cần real-time hơn.

9.2 MV SQL Definitions

mv_prepaid_order_daily ⭐ (order-level — KPIs Tổng quan + Tài chính)

Granularity: (report_date, branch_id, region_id) — KHÔNG group theo product_id để tránh double-count total_collected khi 1 order có nhiều order_item.

sql
CREATE MATERIALIZED VIEW mv_prepaid_order_daily AS
WITH per_order AS (
  -- Một row per order với metric cấp đơn (đã dedupe invoice + item aggregate)
  SELECT
    o.id AS order_id,
    DATE(o.paid_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS report_date,
    o.branch_id,
    br.region_id,
    o.customer_id,
    o.paid_at,
    -- Invoice aggregate per order (LATERAL guarantees 1 row per order)
    COALESCE(inv.total_paid, 0) AS total_paid,
    COALESCE(inv.wallet_used, 0) AS wallet_used,
    COALESCE(inv.wallet_promo_used, 0) AS wallet_promo_used,
    -- Order_item aggregate per order
    COALESCE(item.wallet_topup, 0) AS wallet_topup,
    COALESCE(item.qty, 0) AS qty,
    COALESCE(item.card_value, 0) AS card_value
  FROM "order" o
    JOIN branch br ON br.id = o.branch_id
    LEFT JOIN LATERAL (
      -- ⚠️ Section 0.2 Rule 0: CHỈ aggregate parent invoices (parent_id IS NULL)
      -- ⚠️ Section 0.2 Rule 0a: status filter (status='invoice_completed' OR status IS NULL) — V13 BLOCKER
      -- ⏳ PROVISIONAL (V11/V12): wallet_used + wallet_promo_used field source chờ BE confirm
      --     Theo PRD A10.0 hàng #4 + #5: CẢ HAI dùng `invoice.reference_amount`, KHÁC ở filter `payment_method_id`.
      --     ❌ FORBIDDEN: wallet_promo_used = SUM(wallet_promotion_amount) — Review L8/V12 chứng minh SAI
      --        (field này là KM NẠP lúc bán thẻ — FORMULA-004, KHÔNG phải usage)
      SELECT
        SUM(inv.amount) FILTER (
          WHERE inv.canceled_at IS NULL
            AND (inv.status = 'invoice_completed' OR inv.status IS NULL)   -- ⭐ Rule 0a (V13)
        ) AS total_paid,
        SUM(inv.reference_amount) FILTER (
          WHERE inv.canceled_at IS NULL
            AND (inv.status = 'invoice_completed' OR inv.status IS NULL)   -- ⭐ Rule 0a (V13)
            AND inv.payment_method_id = 'wallet'
        ) AS wallet_used,                              -- ⏳ V11: DT ghi nhận = chỉ ví chính (PRD A10.0 #4)
        SUM(inv.reference_amount) FILTER (             -- ⭐ V12: REVERTED — dùng reference_amount, KHÔNG phải wallet_promotion_amount
          WHERE inv.canceled_at IS NULL
            AND (inv.status = 'invoice_completed' OR inv.status IS NULL)   -- ⭐ Rule 0a (V13)
            AND inv.payment_method_id = 'wallet_promotion'
        ) AS wallet_promo_used                         -- ⏳ V12: KM ĐÃ DÙNG (PRD A10.0 #5)
      FROM invoice inv WHERE inv.order_id = o.id
        AND inv.parent_id IS NULL                      -- ⭐ Parent only (Rule 0)
    ) inv ON true
    LEFT JOIN LATERAL (
      -- ⚠️ KHÔNG × quantity: prepaid_value_into_wallet đã là line total (Section 0.2 Rule 1)
      SELECT
        SUM(oi.prepaid_value_into_wallet) AS wallet_topup,
        SUM(oi.quantity) AS qty,
        SUM(oi.price * oi.quantity) AS card_value     -- price là đơn giá → × quantity
      FROM order_item oi WHERE oi.order_id = o.id
    ) item ON true
  WHERE o.order_kind = 'prepaid'
    AND o.paid_at IS NOT NULL
    AND o.deleted_at IS NULL
)
SELECT
  report_date,
  branch_id,
  region_id,
  COUNT(*) AS order_count,
  SUM(qty) AS sold_quantity,
  SUM(total_paid) AS total_collected,         -- ✅ Không bị nhân, mỗi order count 1 lần
  SUM(card_value) AS total_card_value,
  SUM(wallet_topup) AS total_wallet_topup,    -- = Ví Diva + Ví KM
  SUM(wallet_used) AS wallet_used_amount,
  SUM(wallet_promo_used) AS wallet_promo_amount,
  COUNT(DISTINCT customer_id) AS unique_customers,
  COUNT(DISTINCT customer_id) FILTER (
    WHERE NOT EXISTS (
      SELECT 1 FROM "order" prev
      WHERE prev.customer_id = per_order.customer_id
        AND prev.order_kind = 'prepaid'
        AND prev.paid_at < per_order.paid_at
        AND prev.deleted_at IS NULL
    )
  ) AS new_customers
FROM per_order
GROUP BY 1, 2, 3;

CREATE UNIQUE INDEX idx_mv_prepaid_order_daily_pk
  ON mv_prepaid_order_daily (report_date, branch_id);

Sum Cards Ví Diva / Ví KM (Section 4.2):

🔒 PROVISIONAL — DO NOT IMPLEMENT (V9-V10 BLOCKER, Review L8)

Đoạn dưới đây mô tả công thức CŨ đã được Review L8 chứng minh SAI khi giá bán ≠ base value hoặc có discount. KHÔNG copy đoạn SQL bên dưới vào migration thực tế. Trước khi triển khai, tham chiếu PRD A10.0 Canonical Wallet Table hàng #2 (Ví Diva nạp) + #3 (Ví KM nạp) và chờ BE confirm V9-V10 với codebase evidence.

sql
-- ❌ FORBIDDEN (Review L8 đã chứng minh SAI — không dùng)
-- ví_diva = total_collected           -- SAI khi discount/base≠sell
-- ví_km   = total_wallet_topup - total_collected  -- SAI cùng lý do

-- ✅ CANONICAL (PRD A10.0 — chờ V9-V10 confirm trước khi enable):
-- ví_diva = SUM(parent_invoice.reference_amount)  cho prepaid order
-- ví_km   = SUM(parent_invoice.wallet_promotion_amount)  cho prepaid order
-- → MV cần JOIN sang invoice (parent_id IS NULL) thay vì derive từ order_item.
-- → Sau khi BE confirm V9-V10, update mv_prepaid_order_daily thêm 2 cột:
--     SUM(pi.reference_amount)         FILTER (...) AS total_vi_diva_napped,
--     SUM(pi.wallet_promotion_amount)  FILTER (...) AS total_vi_km_napped
--   với pi = parent invoice của prepaid order (`parent_id IS NULL`).

🔍 Schema verification (V9-V10 BLOCKER): BE phải xác nhận:

  1. parent_invoice.reference_amount của prepaid order = giá trị Ví Diva nạp (theo usePrepaidOrderItem.getValueIntoWallet)?
  2. parent_invoice.wallet_promotion_amount của prepaid order = giá trị Ví KM nạp lúc bán?
  3. Có cần lọc thêm payment_method_id không (ví dụ chỉ payment_method_id IN ('cash','bank','wallet') cho phần nạp, exclude wallet_promotion)?

Nếu BE phát hiện schema khác (VD: có field prepaid_value_into_wallet_main / prepaid_value_into_wallet_promotion trên order_item) → update PRD A10.0 trước, sau đó cascade vào MV này.

Tham chiếu công thức cũ (chỉ để compare khi reconcile, KHÔNG implement):

sql
SUM(oi.prepaid_value_into_wallet) FILTER (WHERE oi.wallet_type = 'main') AS total_wallet_main,
SUM(oi.prepaid_value_into_wallet) FILTER (WHERE oi.wallet_type = 'promotion') AS total_wallet_promo

(Vẫn KHÔNG × quantity.) Verify với BE team trước migration.

mv_prepaid_card_daily ⭐ (card-level — chart Phân bố mệnh giá + Top thẻ bán chạy)

Granularity: (report_date, branch_id, product_id) — group theo card để build chart "Phân bố mệnh giá thẻ" + Top 5 thẻ bán chạy. KHÔNG include order-level metrics (sẽ double-count khi 1 order có nhiều card).

sql
CREATE MATERIALIZED VIEW mv_prepaid_card_daily AS
SELECT
  DATE(o.paid_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS report_date,
  o.branch_id,
  oi.product_id,
  pcv.name AS card_name,
  pcv.flexible,
  -- Item-level metrics only (an toàn không double-count)
  SUM(oi.quantity) AS sold_quantity,
  SUM(oi.prepaid_value_into_wallet) AS total_wallet_topup,  -- ⚠️ KHÔNG × quantity
  SUM(oi.price * oi.quantity) AS total_card_value
FROM "order" o
  JOIN order_item oi ON oi.order_id = o.id
  JOIN prepaid_card_view pcv ON pcv.id = oi.product_id      -- 🔍 Verify: prepaid_card_view có id, name, flexible
WHERE o.order_kind = 'prepaid'
  AND o.paid_at IS NOT NULL
  AND o.deleted_at IS NULL
GROUP BY 1, 2, 3, 4, 5;

CREATE UNIQUE INDEX idx_mv_prepaid_card_daily_pk
  ON mv_prepaid_card_daily (report_date, branch_id, product_id);

Lưu ý: MV này KHÔNG có total_collected, unique_customers, new_customers — các metric này thuộc cấp order, query từ mv_prepaid_order_daily. FE/BE KHÔNG được SUM total_wallet_topup từ cả 2 MV cùng lúc (sẽ double).

mv_prepaid_customer_stats

Fix v3.0 (review L4):

  • Bỏ × quantity trên prepaid_value_into_wallet (Section 0.2 Rule 1)
  • Tách per-order CTE để tránh double-count total_paid khi 1 order có nhiều order_item
  • Đổi FROM customer c → 🔍 verify table (codebase dùng ecommerce_user cho default account; customer có thể là table riêng — BE confirm)
sql
CREATE MATERIALIZED VIEW mv_prepaid_customer_stats AS
WITH per_order AS (
  -- 1 row per order — dedupe invoice + item aggregate
  SELECT
    o.id AS order_id,
    o.customer_id,
    o.paid_at,
    o.branch_id,
    COALESCE(inv.total_paid, 0) AS total_paid,
    COALESCE(item.wallet_topup, 0) AS wallet_topup
  FROM "order" o
    LEFT JOIN LATERAL (
      -- ⚠️ Rule 0: Parent invoice only
      SELECT SUM(inv.amount) FILTER (WHERE inv.canceled_at IS NULL) AS total_paid
      FROM invoice inv WHERE inv.order_id = o.id
        AND inv.parent_id IS NULL                    -- ⭐ Parent only
    ) inv ON true
    LEFT JOIN LATERAL (
      -- ⚠️ KHÔNG × quantity (Section 0.2 Rule 1)
      SELECT SUM(oi.prepaid_value_into_wallet) AS wallet_topup
      FROM order_item oi WHERE oi.order_id = o.id
    ) item ON true
  WHERE o.order_kind = 'prepaid'
    AND o.paid_at IS NOT NULL
    AND o.deleted_at IS NULL
),
per_customer_cycle AS (
  -- FORMULA-013 Chu kỳ trung bình — Option 2 (Customer-weighted, ref DEC-T09 2026-05-14)
  -- Per-customer: AVG khoảng (paid_at[n+1] − paid_at[n]), cap khoảng > 180 ngày
  -- ⚠️ Scope filter (cycle-closes-in-period) áp dụng ở aggregate query layer (Section 5.5
  --     dev-spec / query frontend), KHÔNG ở MV — vì MV pre-compute lũy kế toàn lịch sử KH,
  --     filter `paid_at[n+1] ∈ [from, to]` phụ thuộc filter user.
  -- → Aggregate query JOIN po lại để re-filter cycle theo period (xem §9.2 sau MV definition).
  SELECT
    customer_id,
    AVG(gap_days)::numeric(6,2) AS avg_cycle_days,
    COUNT(*)                     AS cycle_count,
    MAX(closing_paid_at)         AS last_cycle_closing_at   -- để aggregate filter "cycle nằm trong period"
  FROM (
    SELECT
      customer_id,
      paid_at AS closing_paid_at,
      EXTRACT(EPOCH FROM (paid_at - LAG(paid_at) OVER (
        PARTITION BY customer_id ORDER BY paid_at
      ))) / 86400 AS gap_days
    FROM per_order
  ) gaps
  WHERE gap_days IS NOT NULL
    AND gap_days <= 180                  -- cap outlier reactivation (DEC-T09)
  GROUP BY customer_id
),
per_customer_usage AS (
  -- 🔒 PROVISIONAL — DO NOT IMPLEMENT (V11/V12 BLOCKER, Review L8)
  -- Wallet usage per customer. **CẢNH BÁO QUAN TRỌNG:**
  --   Phiên bản trước dùng `inv.wallet_promotion_amount` cho `total_used_promo` — Review L8
  --   chứng minh SAI: field này là phần KM NẠP lúc bán thẻ (FORMULA-004), KHÔNG phải usage.
  --   Theo PRD A10.0 Canonical Wallet Table hàng #5, "Ví KM ĐÃ DÙNG" phải lấy
  --   `invoice.reference_amount` filter `payment_method_id='wallet_promotion'`.
  -- → BE phải confirm V11 (semantics DT) + V12 (mapping ví KM dùng) trước khi enable MV này.
  SELECT
    o2.customer_id,
    -- total_used = cả 2 ví (đo behavior "đã dùng ví") — CHỜ V11/V12
    SUM(inv2.reference_amount) FILTER (
      WHERE inv2.payment_method_id IN ('wallet', 'wallet_promotion')
    ) AS total_used,
    -- total_used_main = chỉ ví chính (PRD A10.0 hàng #4 — V11)
    SUM(inv2.reference_amount) FILTER (
      WHERE inv2.payment_method_id = 'wallet'
    ) AS total_used_main,
    -- total_used_promo = chỉ ví KM (PRD A10.0 hàng #5 — V12)
    -- ⚠️ DÙNG reference_amount, KHÔNG phải wallet_promotion_amount (xem cảnh báo trên)
    SUM(inv2.reference_amount) FILTER (
      WHERE inv2.payment_method_id = 'wallet_promotion'
    ) AS total_used_promo,
    MAX(inv2.paid_at) AS last_used_at
  FROM invoice inv2
    JOIN "order" o2 ON o2.id = inv2.order_id
  WHERE inv2.parent_id IS NULL                       -- ⭐ Rule 0: Parent only
    AND inv2.payment_method_id IN ('wallet', 'wallet_promotion')
    AND inv2.canceled_at IS NULL
    AND o2.deleted_at IS NULL
  GROUP BY o2.customer_id
)
-- 🔒 END PROVISIONAL — block trên CHỜ V11+V12 BE confirm với baseline reconciliation
--    (báo cáo cũ search_report_service vẫn split 2 bucket — đối chiếu chênh lệch trước khi unlock)
-- ⚠️ CUSTOMER SOURCE — V1 BLOCKER chưa được resolve
-- Codebase metadata KHÔNG có `account_type` cột trên `ecommerce_user`. SQL dưới đây dùng
-- placeholder `<CUSTOMER_FILTER>` — BE PHẢI thay bằng filter đúng theo schema thực tế trước
-- khi chạy migration. Khả năng cao là 1 trong các option sau:
--   (a) Relationship qua `default_account` table với role/type
--   (b) Implicit filter: customer = ecommerce_user xuất hiện trong order.customer_id (rule "có đơn = KH")
--   (c) Field flag riêng (cần BE confirm tên cột thực)
-- BE resolve V1 trước → update placeholder + remove warning.
SELECT
  eu.id AS customer_id,
  eu.display_name,
  eu.phone_number,
  -- Aggregated từ per_order (không bị nhân do đã dedupe)
  COUNT(DISTINCT po.order_id) AS order_count,
  SUM(po.total_paid) AS total_paid,                 -- ✅ chính xác, không double
  SUM(po.wallet_topup) AS total_wallet_topup,       -- ✅ không × quantity
  -- Wallet usage (DEC-B06: tách 3 fields)
  COALESCE(usage.total_used, 0) AS total_wallet_used,            -- Cả 2 ví — cho consumption_rate behavior
  COALESCE(usage.total_used_main, 0) AS total_wallet_used_main,  -- Chỉ ví chính — DT ghi nhận FORMULA-005
  COALESCE(usage.total_used_promo, 0) AS total_wallet_used_promo, -- Chỉ ví KM — KM đã sử dụng FORMULA-005b
  CASE
    WHEN SUM(po.wallet_topup) > 0
    THEN COALESCE(usage.total_used, 0)::numeric / SUM(po.wallet_topup) * 100
    ELSE 0
  END AS consumption_rate,
  MIN(po.paid_at) AS first_order_at,
  MAX(po.paid_at) AS last_order_at,
  usage.last_used_at,
  -- FORMULA-013 — pre-computed per-customer cycle (DEC-T09)
  cycle.avg_cycle_days,
  cycle.cycle_count,
  cycle.last_cycle_closing_at,
  MODE() WITHIN GROUP (ORDER BY po.branch_id) AS primary_buy_branch_id,
  COUNT(DISTINCT po.branch_id) AS buy_branch_count,
  -- Segment (Frontend filter thêm wallet_balance > 0 từ Redis cache)
  -- 'new' check ĐẦU TIÊN — KH mới chưa dùng ví không bị rơi vào 'inactive'
  CASE
    WHEN MIN(po.paid_at) >= NOW() - INTERVAL '30 days' THEN 'new'
    WHEN usage.last_used_at >= NOW() - INTERVAL '30 days' THEN 'active'
    WHEN usage.last_used_at >= NOW() - INTERVAL '60 days' THEN 'dormant'
    WHEN usage.last_used_at < NOW() - INTERVAL '60 days' THEN 'at_risk'
    ELSE 'inactive'
  END AS segment
FROM ecommerce_user eu
  JOIN per_order po ON po.customer_id = eu.id::text   -- ⚠️ INNER JOIN bản chất đã limit eu = customer (chỉ user có order_kind='prepaid')
  LEFT JOIN per_customer_usage usage ON usage.customer_id = eu.id::text
  LEFT JOIN per_customer_cycle cycle ON cycle.customer_id = eu.id::text   -- FORMULA-013 (DEC-T09)
-- 🛑 V1 BLOCKER — BE confirm filter đúng. Default rely on INNER JOIN above (rule b).
-- Nếu cần filter staff explicit:
-- WHERE <CUSTOMER_FILTER>   -- e.g., eu.role = 'customer' / eu.is_staff = false / NOT EXISTS staff_account
GROUP BY eu.id, eu.display_name, eu.phone_number,
         usage.total_used, usage.total_used_main, usage.total_used_promo, usage.last_used_at,
         cycle.avg_cycle_days, cycle.cycle_count, cycle.last_cycle_closing_at;

CREATE UNIQUE INDEX idx_mv_prepaid_customer_stats_pk
  ON mv_prepaid_customer_stats (customer_id);

-- Index hỗ trợ aggregate query FORMULA-013 (cycle-closes-in-period filter)
CREATE INDEX idx_mv_prepaid_customer_stats_cycle_closing
  ON mv_prepaid_customer_stats (last_cycle_closing_at)
  WHERE cycle_count >= 1;
Aggregate query — KPI Section 5.5 "Chu kỳ trung bình" (FORMULA-013, DEC-T09)

Đọc TRƯỚC khi BE implement: pre-computed avg_cycle_days trong MV chứa toàn bộ lịch sử KH (kể cả khoảng đã đóng trước filter period). Aggregate query phải re-filter cycle theo period bằng last_cycle_closing_at ∈ [from, to] để đảm bảo trend % so kỳ trước fair (mỗi period chỉ tính KH có cycle closing trong period đó).

sql
-- Aggregate cho KPI "Chu kỳ trung bình" — Customer-weighted mean of pre-computed per-customer averages
SELECT
  AVG(avg_cycle_days)::numeric(6,2) AS overall_avg_cycle_days,
  COUNT(*)                          AS contributing_customers   -- denominator cho edge case "—"
FROM mv_prepaid_customer_stats
WHERE cycle_count >= 1
  AND last_cycle_closing_at >= $from::date                       -- half-open range (CLAUDE.md SQL gotcha)
  AND last_cycle_closing_at <  ($to::date + interval '1 day')
  AND ($branch_id::uuid IS NULL OR primary_buy_branch_id = $branch_id);

Display rule (FE):

  • contributing_customers = 0 → render (KHÔNG hiện 0 ngày hay NaN)
  • contributing_customers >= 1 → render {overall_avg_cycle_days} ngày (làm tròn 0 decimal cho display, giữ 2 decimal cho export Excel)
  • Trend so kỳ trước: chạy cùng query với $from/$to của kỳ trước (FORMULA-019). Nếu cả 2 kỳ đều → ẩn label trend.

Hasura binding (Phase 1 — sub-tab Khách hàng):

  • Tạo Hasura action report_prepaid_behavior_metrics nhận from, to, branch_id → trả { aov, repurchase_rate, cycle_days, contributing_customers, trend_pct }
  • Permission: action có check report.prepaid_analytics.view + branch scoping
  • Cache: Redis TTL 5 phút (đồng nhịp MV refresh 15-30 phút)

Hiệu năng:

  • Index idx_mv_prepaid_customer_stats_cycle_closing (partial WHERE cycle_count >= 1) → cover 100% query path. Với 100K KH → estimated 30-50ms.
  • KHÔNG dùng OFFSET pagination — query này trả 1 row aggregate, scan partial index.

Alternative (Phase 3+): đổi sang PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY avg_cycle_days) (median of means) — robust hơn với behavioral data right-skewed. Phase 1 dùng MEAN + cap 180d đủ; nếu reconciliation phát hiện skew lớn → switch.

mv_prepaid_finance_daily

⚠️ FIX P1 review (2026-05-04 — Finance MV double count): Phiên bản gốc trộn SUM(oi_agg.wallet_topup) GROUP BY payment_method_id → khi 1 order có nhiều invoice với phương thức khác nhau, wallet_topup bị nhân lên N lần (N = số invoice). Fix: CHỈ giữ field invoice-level trong finance MV (paid_amount, wallet_amount, promo_amount). total_wallet_topup lấy từ mv_prepaid_order_daily (đã group đúng theo order/product).

🔒 PROVISIONAL — DO NOT IMPLEMENT (V12-V13 BLOCKER, Review L8)

Cột promo_amount = SUM(inv.wallet_promotion_amount) DƯỚI ĐÂY trộn 2 semantics khác nhau (KM nạp lúc bán thẻ vs KM dùng thanh toán dịch vụ) — Review L8 chứng minh SAI. Theo PRD A10.0 Canonical Wallet Table, finance MV phải tách rõ:

  • Phần NẠP (FORMULA-003 + 004 — V9/V10): từ parent_invoice của order order_kind='prepaid'
  • Phần ĐÃ DÙNG (FORMULA-005 + 005b — V11/V12): từ invoice.reference_amount filter theo payment_method_id

Sau khi BE confirm V9-V13, MV này phải refactor: bỏ cột promo_amount từ wallet_promotion_amount, thay bằng split rõ ràng theo payment method.

sql
-- 🔒 PROVISIONAL — block dưới đây CHỜ V12-V13 trước khi enable
CREATE MATERIALIZED VIEW mv_prepaid_finance_daily AS
SELECT
  DATE(inv.paid_at AT TIME ZONE 'Asia/Ho_Chi_Minh') AS report_date,
  o.branch_id,
  br.region_id,
  inv.payment_method_id,
  COUNT(DISTINCT o.id) AS order_count,
  SUM(inv.amount) AS paid_amount,                 -- Tiền KH thực trả (per payment method)
  SUM(inv.reference_amount) AS wallet_amount,     -- ⏳ V11/V12: Phần ĐÃ DÙNG ví (cả wallet + wallet_promotion qua filter)
  -- ❌ FORBIDDEN — không dùng `wallet_promotion_amount` ở finance MV cho phần ĐÃ DÙNG
  -- (đó là field NẠP — FORMULA-004). Phần ĐÃ DÙNG ví KM = reference_amount filter
  -- payment_method_id='wallet_promotion' (xem A10.0 hàng #5).
  -- SUM(inv.wallet_promotion_amount) AS promo_amount,   -- ❌ REMOVED (Review L8)
  SUM(inv.customer_paid_amount) AS cash_received
  -- ❌ KHÔNG include total_wallet_topup ở MV này (gây double count khi group theo payment_method).
  -- Lấy total_wallet_topup từ mv_prepaid_order_daily (đã group đúng theo order/product).
FROM invoice inv
  JOIN "order" o ON o.id = inv.order_id
  JOIN branch br ON br.id = o.branch_id
WHERE o.order_kind = 'prepaid'
  AND o.deleted_at IS NULL
  AND inv.canceled_at IS NULL
  AND inv.paid_at IS NOT NULL
  AND inv.parent_id IS NULL                          -- ⭐ Rule 0: Parent invoice only (PTTT split chỉ trên parent)
GROUP BY 1, 2, 3, 4;
-- 🔒 END PROVISIONAL — chờ BE confirm V12-V13 + reconcile với baseline finance report
--    (search_report_service hiện đang split wallet vs wallet_promotion thành 2 bucket DT — phải đối chiếu)

CREATE UNIQUE INDEX idx_mv_prepaid_finance_daily_pk
  ON mv_prepaid_finance_daily (report_date, branch_id, payment_method_id);

Query path cho "Giá trị KM đã nạp" và "Tổng nạp ví":

  • Tổng nạp ví = SUM(total_wallet_topup) từ mv_prepaid_order_daily (đã đúng — group theo order/product)
  • KM đã nạp = SUM(total_wallet_topup) − SUM(total_collected) từ mv_prepaid_order_daily (cả 2 field cùng MV — không cross-MV). KHÔNG lấy từ finance_daily (đã bỏ total_wallet_topup để tránh double count)

Khi query, FE join 2 MVs theo (report_date, branch_id, region_id) để compute KM đã nạp. Hoặc tạo Hasura view phụ gộp 2 MVs (Phase 1 không cần — query tách 2 lần OK).

mv_prepaid_staff_stats

CREATE MATERIALIZED VIEW mv_prepaid_staff_stats AS
SELECT
  oc.user_id AS staff_id,
  eu.display_name AS staff_name,
  o.branch_id,
  br.region_id,
  COUNT(DISTINCT o.id) AS order_count,
  SUM(inv_total.amount) AS revenue,
  -- Commission from wallet DB via order_commission_refund view
  COALESCE(comm.total_commission, 0) AS commission,
  COUNT(DISTINCT o.customer_id) AS unique_customers,
  COUNT(DISTINCT o.customer_id) FILTER (
    WHERE NOT EXISTS (
      SELECT 1 FROM "order" prev
      WHERE prev.customer_id = o.customer_id
        AND prev.order_kind = 'prepaid'
        AND prev.paid_at < o.paid_at
    )
  ) AS new_customers
FROM order_commission oc
  JOIN "order" o ON o.id = oc.order_id
  JOIN ecommerce_user eu ON eu.id = oc.user_id::text
  JOIN branch br ON br.id = o.branch_id
  LEFT JOIN LATERAL (
    SELECT SUM(inv.amount) AS amount
    FROM invoice inv WHERE inv.order_id = o.id
      AND inv.canceled_at IS NULL
      AND inv.parent_id IS NULL                      -- ⭐ Rule 0: Parent only
  ) inv_total ON true
  LEFT JOIN LATERAL (
    -- ⭐ DEC-T08 (2026-05-12): Commission source = invoice_commission (ecommerce schema), KHÔNG phải transaction_request
    -- behavior_id='transaction_commission' đã bị disable code path (order_commission_user_insert.go:16-58 commented out)
    -- Gross commission only (refund tracked separately bằng transaction_request.behavior_id='refund_commission')
    SELECT SUM(ic.amount) AS total_commission
    FROM invoice_commission ic
    WHERE ic.order_id = o.id
      AND ic.invoice_status = 'invoice_completed'
      AND ic.user_id = oc.user_id::text
  ) comm ON true
  LEFT JOIN LATERAL (
    -- ⭐ DEC-T08: Refund commission (amount âm) — để tính net nếu cần
    SELECT COALESCE(SUM(ABS(tr.amount)), 0) AS total_refund_commission
    FROM transaction_request tr
    WHERE tr.order_id = o.id
      AND tr.behavior_id = 'refund_commission'
  ) refund_comm ON true
WHERE o.order_kind = 'prepaid'
  AND o.paid_at IS NOT NULL
  AND o.deleted_at IS NULL
GROUP BY 1, 2, 3, 4;

CREATE UNIQUE INDEX idx_mv_prepaid_staff_stats_pk
  ON mv_prepaid_staff_stats (staff_id, branch_id);

Lưu ý về cross-schema (DEC-T08 update):

  • invoice_commission nằm trong ecommerce schema (cùng schema với order, invoice) — KHÔNG cần cross-schema query
  • transaction_request (cho refund_commission) nằm trong wallet schema. Cùng PG instance nên truy xuất được bằng wallet_schema.transaction_request
  • Trước đây spec dùng transaction_request_user (wallet) — đã chuyển sang invoice_commission (ecommerce) theo DEC-T08 để khớp code thực tế

mv_prepaid_campaign_stats

CREATE MATERIALIZED VIEW mv_prepaid_campaign_stats AS
SELECT
  unnest(o.campaign_ids) AS campaign_id,
  o.branch_id,
  br.region_id,
  COUNT(DISTINCT o.id) AS order_count,
  SUM(inv_total.amount) AS revenue,
  COUNT(DISTINCT o.customer_id) AS unique_customers,
  COUNT(DISTINCT o.customer_id) FILTER (
    WHERE NOT EXISTS (
      SELECT 1 FROM "order" prev
      WHERE prev.customer_id = o.customer_id
        AND prev.order_kind = 'prepaid'
        AND prev.paid_at < o.paid_at
    )
  ) AS new_customers
FROM "order" o
  JOIN branch br ON br.id = o.branch_id
  LEFT JOIN LATERAL (
    SELECT SUM(inv.amount) AS amount
    FROM invoice inv WHERE inv.order_id = o.id
      AND inv.canceled_at IS NULL
      AND inv.parent_id IS NULL                      -- ⭐ Rule 0: Parent only
  ) inv_total ON true
WHERE o.order_kind = 'prepaid'
  AND o.paid_at IS NOT NULL
  AND o.deleted_at IS NULL
  AND o.campaign_ids IS NOT NULL
GROUP BY 1, 2, 3;

CREATE UNIQUE INDEX idx_mv_prepaid_campaign_stats_pk
  ON mv_prepaid_campaign_stats (campaign_id, branch_id);

mv_prepaid_branch_flow đã bị loại bỏ (ref: prd.md DEC-U04). SQL tạo MV này không còn cần thiết.

9.3 Composite Indexes

-- Query chính báo cáo (ecommerce DB)
CREATE INDEX idx_order_prepaid_report
  ON "order" (branch_id, paid_at DESC, id DESC)
  WHERE order_kind = 'prepaid' AND deleted_at IS NULL;

-- Lookup công nợ (ecommerce DB) — derive trạng thái thay vì dùng paid_status enum (không tồn tại)
CREATE INDEX idx_order_prepaid_debt
  ON "order" (branch_id, created_at DESC)
  WHERE order_kind = 'prepaid' AND paid_amount < total AND deleted_at IS NULL;

-- Invoice aggregation (ecommerce DB)
CREATE INDEX idx_invoice_order_paid
  ON invoice (order_id, paid_at DESC)
  WHERE canceled_at IS NULL;

-- Invoice wallet usage (ecommerce DB)
CREATE INDEX idx_invoice_wallet_usage
  ON invoice (order_id)
  WHERE payment_method_id IN ('wallet', 'wallet_promotion') AND canceled_at IS NULL;

-- Commission lookup — DEC-T08 (2026-05-12)
-- ⭐ DEC-T08: Commission gross source = invoice_commission (ecommerce DB)
CREATE INDEX idx_invoice_commission_order
  ON invoice_commission (order_id, invoice_status)
  WHERE invoice_status = 'invoice_completed';

-- ⭐ DEC-T08: Refund commission tracking (wallet DB) — chỉ refund_commission còn active
CREATE INDEX idx_txreq_refund_commission_order
  ON transaction_request (order_id, behavior_id)
  WHERE behavior_id = 'refund_commission';

-- Tra cứu nhanh (ecommerce DB, pg_trgm)
CREATE INDEX idx_order_prepaid_search
  ON "order" USING gin (keywords gin_trgm_ops)
  WHERE order_kind = 'prepaid';

9.4 Keyset Pagination

-- Luôn nhanh bất kể offset:
SELECT * FROM "order" WHERE ...
  AND (paid_at, id) < ($last_paid_at, $last_id)
  ORDER BY paid_at DESC, id DESC LIMIT 20;

Pagination strategy: Keyset cursor (flat list) — duy nhất 1 chiến lược (ref: prd.md DEC-U04, đã loại chế độ So sánh).

Frontend: "Trang trước/sau" cho keyset. QTable virtual-scroll cho bảng lớn.

9.5 PostgreSQL Tuning

postgres:
  command:
    - "-c"
    - "shared_buffers=2GB"                          # 256MB → 2GB
    - "-c"
    - "max_connections=200"                          # Giữ 200, PgBouncer xử lý multiplexing
    - "-c"
    - "work_mem=16MB"                                # 16MB an toàn (16MB * 200conn * 4sort = 12.8GB worst case)
    - "-c"
    - "effective_cache_size=6GB"                     # Query planner hint
    - "-c"
    - "statement_timeout=30000"                      # 30s max query — QUAN TRỌNG
    - "-c"
    - "idle_in_transaction_session_timeout=60000"    # 60s idle kill
    - "-c"
    - "random_page_cost=1.1"                         # SSD optimization

9.6 PgBouncer Connection Pooling

pgbouncer:
  image: edoburu/pgbouncer
  environment:
    DATABASE_URL: postgres://user:pass@postgres:5432/ecommerce
    POOL_MODE: transaction
    MAX_CLIENT_CONN: 1000
    DEFAULT_POOL_SIZE: 100                          # 100 server connections

Hasura connect qua PgBouncer thay vì trực tiếp PostgreSQL.

9.7 Caching 3 tầng

TầngCông nghệTTLÁp dụng
Hasura response cacheHasura annotation5-15 minKPI cards, charts
Redis cacheRedis 7 (đã có)10 minAggregate nặng (tổng dư ví toàn hệ thống — query wallet DB)
Frontend cacheURQL cache-and-networkRealtimeHiển thị stale data ngay, sau đó replace bằng fresh data

9.8 Export Streaming

1. User click "Xuất Excel"
2. Frontend tạo `export_job` (status: pending)
3. export-api query theo chunk (1000 rows/lần) với keyset cursor
4. Stream vào Excel file (excelize library — đã có)
5. Upload lên MinIO/GCS (đã có infra)
6. Notification → user download (qua notification-api)

Progress bar trên frontend: “65% (32K/50K rows)”

9.9 Alert Backend Function

CREATE OR REPLACE FUNCTION compute_prepaid_alerts(
  _branch_ids UUID[] DEFAULT NULL,
  _overdue_critical_days INT DEFAULT 30,
  _overdue_warning_days INT DEFAULT 15,
  _inactive_warning_days INT DEFAULT 60,
  _inactive_watch_days INT DEFAULT 90,
  _revenue_drop_warning NUMERIC DEFAULT 0.15,   -- 15% giảm → warning
  _revenue_drop_critical NUMERIC DEFAULT 0.30    -- 30% giảm → critical
)
RETURNS TABLE (
  severity TEXT,        -- 'critical', 'warning', 'info'
  alert_type TEXT,      -- 'overdue_payment', 'inactive_customer', 'revenue_drop', 'slow_card'
  branch_id UUID,
  branch_name TEXT,
  region_id UUID,
  title TEXT,
  detail_count INT,
  detail_amount BIGINT
) AS $$
BEGIN
  -- 1. Đơn quá hạn thanh toán
  RETURN QUERY
  SELECT
    CASE WHEN AGE(NOW(), o.created_at) > (_overdue_critical_days || ' days')::interval
         THEN 'critical' ELSE 'warning' END,
    'overdue_payment',
    o.branch_id, br.name, br.region_id,
    'Đơn quá hạn thanh toán',
    COUNT(*)::int,
    SUM(o.total - o.paid_amount)
  FROM "order" o JOIN branch br ON br.id = o.branch_id
  WHERE o.order_kind = 'prepaid'
    AND o.paid_amount < o.total
    AND o.deleted_at IS NULL
    AND AGE(NOW(), o.created_at) > (_overdue_warning_days || ' days')::interval
    AND (_branch_ids IS NULL OR o.branch_id = ANY(_branch_ids))
  GROUP BY o.branch_id, br.name, br.region_id,
    CASE WHEN AGE(NOW(), o.created_at) > (_overdue_critical_days || ' days')::interval
         THEN 'critical' ELSE 'warning' END;

  -- 2. KH lâu không sử dụng (thay thế "thẻ hết hạn" vì không có expiry field)
  -- ⚠️ Fix review L4: Filter wb.balance > 0 (chỉ alert KH còn dư ví thực sự — match tooltip "vẫn còn dư ví")
  -- 🔍 Verify table name: codebase có wallet_balance_result; cross-schema access via Hasura remote relationship
  RETURN QUERY
  SELECT 'warning', 'inactive_customer',
    mcs.primary_buy_branch_id, br.name, br.region_id,
    'KH lâu không sử dụng ví (còn dư > 0)',
    COUNT(*)::int,
    SUM(wb.balance)
  FROM mv_prepaid_customer_stats mcs
    JOIN branch br ON br.id = mcs.primary_buy_branch_id
    -- 🔍 Verify exact table name: wallet_balance vs wallet_balance_result (xem Section 0.3 V5)
    -- Cross-schema: dùng Hasura remote relationship hoặc PostgreSQL FDW
    JOIN wallet_schema.wallet_balance_result wb ON wb.user_id = mcs.customer_id::text
  WHERE mcs.segment IN ('dormant', 'at_risk')
    AND wb.balance > 0   -- ⭐ Filter chỉ KH còn dư ví thực sự
    AND (_branch_ids IS NULL OR mcs.primary_buy_branch_id = ANY(_branch_ids))
  GROUP BY mcs.primary_buy_branch_id, br.name, br.region_id;

  -- 3. Chi nhánh doanh thu giảm (so sánh current 30d vs previous 30d)
  RETURN QUERY
  WITH current_period AS (
    SELECT branch_id, region_id, SUM(total_collected) AS revenue
    FROM mv_prepaid_order_daily
    WHERE report_date >= CURRENT_DATE - INTERVAL '30 days'
      AND (_branch_ids IS NULL OR branch_id = ANY(_branch_ids))
    GROUP BY branch_id, region_id
  ),
  previous_period AS (
    SELECT branch_id, SUM(total_collected) AS revenue
    FROM mv_prepaid_order_daily
    WHERE report_date >= CURRENT_DATE - INTERVAL '60 days'
      AND report_date < CURRENT_DATE - INTERVAL '30 days'
      AND (_branch_ids IS NULL OR branch_id = ANY(_branch_ids))
    GROUP BY branch_id
  )
  SELECT
    CASE
      WHEN pp.revenue > 0 AND (pp.revenue - cp.revenue) / pp.revenue >= _revenue_drop_critical
        THEN 'critical'
      WHEN pp.revenue > 0 AND (pp.revenue - cp.revenue) / pp.revenue >= _revenue_drop_warning
        THEN 'warning'
    END,
    'revenue_drop'::text,
    cp.branch_id, br.name, cp.region_id,
    'Doanh thu giảm ' || ROUND((pp.revenue - cp.revenue) / pp.revenue * 100) || '% so với kỳ trước',
    1::int,   -- detail_count: 1 per branch
    (pp.revenue - cp.revenue)
  FROM current_period cp
    JOIN previous_period pp ON pp.branch_id = cp.branch_id
    JOIN branch br ON br.id = cp.branch_id
  WHERE pp.revenue > 0
    AND (pp.revenue - cp.revenue) / pp.revenue >= _revenue_drop_warning;

  -- 4. Thẻ bán giảm mạnh (info level) — query mv_prepaid_card_daily (đã group by product_id, có card_name)
  RETURN QUERY
  WITH current_cards AS (
    SELECT product_id, card_name, SUM(sold_quantity) AS qty
    FROM mv_prepaid_card_daily
    WHERE report_date >= CURRENT_DATE - INTERVAL '30 days'
      AND (_branch_ids IS NULL OR branch_id = ANY(_branch_ids))
    GROUP BY product_id, card_name
  ),
  previous_cards AS (
    SELECT product_id, SUM(sold_quantity) AS qty
    FROM mv_prepaid_card_daily
    WHERE report_date >= CURRENT_DATE - INTERVAL '60 days'
      AND report_date < CURRENT_DATE - INTERVAL '30 days'
      AND (_branch_ids IS NULL OR branch_id = ANY(_branch_ids))
    GROUP BY product_id
  )
  SELECT
    'info'::text,
    'slow_card'::text,
    NULL::uuid, -- branch_id: toàn hệ thống
    NULL::text,
    NULL::uuid,
    cc.card_name || ' giảm ' || ROUND((pc.qty - cc.qty)::numeric / pc.qty * 100) || '% lượng bán',
    cc.qty::int,
    0::bigint
  FROM current_cards cc
    JOIN previous_cards pc ON pc.product_id = cc.product_id
  WHERE pc.qty > 0
    AND (pc.qty - cc.qty)::numeric / pc.qty >= 0.30;  -- giảm >= 30%

END;
$$ LANGUAGE plpgsql VOLATILE;
-- VOLATILE vì query MV (refresh định kỳ) + wallet_balance (real-time), kết quả thay đổi theo thời gian

Ngưỡng alert có thể cấu hình: Truyền qua params, mặc định là hardcoded. Phase 2 có thể lưu vào settings table.

9.10 Branch Hierarchy

✅ Reuse existing schema — KHÔNG tạo migration mới (ref: Section 0 Schema Mapping).

Hệ thống đã có sẵn:

  • Bảng region_branch — migration đã chạy 1678865967129_region_branch/up.sql
  • Cột branch.region_id — đã có FK đến region_branch
sql
-- KHÔNG chạy migration tạo region_branch — đã có sẵn.
-- Chỉ verify dữ liệu trước khi enable analytics:

SELECT COUNT(*) FROM region_branch;          -- ≥ 4 (HCM, HN, ĐN, CT, ...)
SELECT COUNT(*) FROM branch WHERE region_id IS NULL;  -- = 0 (tất cả 70 CN phải có region)

Pre-check Phase 1 deployment: nếu có CN chưa gán region_id, chạy script gán thủ công trước khi launch tab.


10.1 UI

🔍 [Tìm: KH, SDT, mã đơn, NV, thẻ...]

Kết quả gợi ý (real-time, debounce 300ms):
  👤 Nguyễn Văn A — 0901xxx — Q.1 — Dư ví: 7tr
  📋 PO-0412 — 12/03 — VIP 10tr — Đã TT
  👨‍💼 Lan — Q.1 — 25 đơn — 180tr DT

10.2 Backend

CREATE OR REPLACE FUNCTION search_prepaid_global(
  _keyword TEXT,
  _branch_ids UUID[] DEFAULT NULL
)
RETURNS TABLE (
  result_type TEXT,
  id UUID,
  display_text TEXT,
  subtitle TEXT,
  branch_name TEXT
) AS $$
  -- Mỗi nhánh PHẢI bọc trong subquery () để ORDER BY + LIMIT
  -- hoạt động đúng per-type. Không bọc → PG áp ORDER BY cho toàn bộ UNION.

  -- Top 5 customers
  (
    SELECT 'customer'::text, c.id, c.display_name, c.phone_number, b.name
    FROM customer c
      JOIN branch b ON b.id = c.branch_id
    WHERE (c.keywords % _keyword OR c.phone_number LIKE _keyword || '%')
      AND (_branch_ids IS NULL OR c.branch_id = ANY(_branch_ids))
    ORDER BY similarity(c.keywords, _keyword) DESC
    LIMIT 5
  )

  UNION ALL

  -- Top 5 orders
  (
    SELECT 'order'::text, o.id, o.code,
      TO_CHAR(o.paid_at, 'DD/MM/YYYY'),
      b.name
    FROM "order" o
      JOIN branch b ON b.id = o.branch_id
    WHERE o.order_kind = 'prepaid'
      AND (o.code ILIKE _keyword || '%' OR o.keywords % _keyword)
      AND o.deleted_at IS NULL
      AND (_branch_ids IS NULL OR o.branch_id = ANY(_branch_ids))
    ORDER BY o.paid_at DESC
    LIMIT 5
  )

  UNION ALL

  -- Top 5 staff
  (
    SELECT 'staff'::text, u.id::uuid, u.display_name,
      b.name,
      b.name
    FROM ecommerce_user u
      JOIN branch b ON b.id = u.branch_id
    WHERE u.keywords % _keyword
      AND u.deleted_at IS NULL
      AND (_branch_ids IS NULL OR u.branch_id = ANY(_branch_ids))
    ORDER BY similarity(u.keywords, _keyword) DESC
    LIMIT 5
  )
$$ LANGUAGE sql VOLATILE;
-- VOLATILE vì query live tables (customer, order, ecommerce_user)

Quan trọng: Mỗi nhánh SELECT bọc trong () để PostgreSQL áp ORDER BY + LIMIT 5 riêng cho từng loại (customer/order/staff). Không bọc → ORDER BY chỉ áp cho nhánh cuối hoặc toàn bộ UNION.

Sử dụng % operator (pg_trgm similarity) thay vì ILIKE '%keyword%' để tận dụng GIN index. Fallback sang ILIKE prefix% cho phone number và order code.


11. Backend tổng hợp

11.1 Database Migration mới

#MigrationPhaseDatabaseNội dung
1create_branch_regionKHÔNG TẠOregion_branch + branch.region_id đã có sẵn (Section 9.10). Chỉ chạy data check
2create_mv_prepaid_order_dailyP1ecommerceMV + UNIQUE INDEX (xem SQL Section 9.2)
3create_mv_prepaid_customer_statsP1ecommerceMV + segment logic (xem SQL Section 9.2)
4create_mv_prepaid_finance_dailyP1ecommerceMV + UNIQUE INDEX
5create_indexes_prepaid_reportP1ecommerce + walletComposite + partial indexes (Section 9.3). Phase 1 KHÔNG cần GIN index pg_trgm — chỉ thêm khi build Global search Phase 2
6create_compute_prepaid_alertsP1ecommerceFunction alert (Section 9.9)
7create_export_job_tableP1ecommerceBảng theo dõi export async
8create_search_prepaid_globalP2ecommerceFunction tra cứu nhanh global (Section 10.2) — defer Phase 2
9create_mv_prepaid_staff_statsP3+ecommerceMV (cross-schema ref wallet) — defer Phase 3+
10create_mv_prepaid_campaign_statsP3+ecommerceMV + UNIQUE INDEX — defer Phase 3+

Phase 1 chỉ chạy migration #2–#7 (6 migrations). Migration #1 đã loại bỏ vì schema đã có. Migration #8–#10 giữ trong spec làm reference, KHÔNG add vào release Phase 1.

11.2 Hasura Metadata

Phase 1 — Track 4 MVs + 1 function:

  • Track 4 MVs P1 (ecommerce database):
    • mv_prepaid_order_daily (order-level — Tổng quan + Tài chính KPIs)
    • mv_prepaid_card_daily (card-level — chart Phân bố mệnh giá + Top thẻ)
    • mv_prepaid_customer_stats (Khách hàng segment + behavior)
    • mv_prepaid_finance_daily (PTTT split, KHÔNG có total_wallet_topup — fix double count)
  • Track 1 function P1: compute_prepaid_alerts
  • Permission rules per MV (theo Dynamic Permission v2 fine-grained):
    • role có report.prepaid_analytics.view mới được SELECT
    • branch_id filter theo X-Hasura-Branch-Id cho non-admin
    • SDT column trong mv_prepaid_customer_stats: dùng computed column phone_number_masked cho role không có view_full_phone
  • Add remote relationship cho wallet_balance_result (wallet DB → ecommerce queries) — verify V5

Phase 2 (sau MVP): thêm track function search_prepaid_global + pg_trgm extension.

Phase 3+ (TBD): thêm track 2 MVs staff_stats, campaign_stats + permissions tương ứng.

11.3 GraphQL Queries mới

QueryPhaseSub-tabPagination
PrepaidAnalyticsOverviewP1Tổng quanNo (aggregate from MV)
PrepaidAnalyticsAlertsP1Tổng quanNo (function call)
PrepaidAnalyticsTransactionsP1Giao dịchKeyset cursor (flat list, ref: DEC-U04)
PrepaidAnalyticsTransactionDetailP1Giao dịch (expand)No (lazy, single order)
PrepaidAnalyticsCustomersP1Khách hàngKeyset cursor
PrepaidAnalyticsCustomerDetailP1Khách hàng (expand)No (lazy, single customer)
PrepaidAnalyticsFinanceSummaryP1Tài chính (Tổng hợp)No (aggregate từ mv_prepaid_order_daily cho KPIs order-level + mv_prepaid_finance_daily cho PTTT split)
PrepaidAnalyticsDebtListP1Tài chính (Công nợ)Keyset cursor
PrepaidAnalyticsCommissionsP1Tài chính (Hoa hồng)Offset (small set)
PrepaidAnalyticsPaymentMethodsP1Tài chính (PTTT)No (small set)
GetWalletBalanceAggregateP1Tổng quan + Tài chínhNo (Redis cached, from wallet DB)
SearchPrepaidGlobalP2Tra cứu nhanhNo (limit 5/type)
PrepaidAnalyticsCampaignsP3+MarketingOffset (small set)
PrepaidAnalyticsAffiliatesP3+AffiliateOffset (small set)
PrepaidAnalyticsStaffRankingP3+Nhân viênOffset (top N)
PrepaidAnalyticsStaffDetailP3+NV chi tiếtNo (single)
PrepaidAnalyticsStaffBranchP3+NV so sánh CNNo (aggregate)

11.4 Export-API bổ sung

  • Endpoint mới: handle export prepaid report (action handler)
  • Chunked query với keyset cursor (1000 rows/lần)
  • Upload MinIO/GCS (đã có infra)
  • Notification callback khi xong (qua notification-api)

11.5 Scripts Service — MV Refresh Cron

# Mỗi MV một lệnh riêng (không gộp được trong 1 statement)
# Phase 1 cron — 15-minute refresh
*/15 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_order_daily;"
*/15 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_finance_daily;"

# Phase 1 cron — 30-minute refresh
*/30 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_customer_stats;"

# Phase 3+ cron — KHÔNG add vào Phase 1 release:
# */30 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_staff_stats;"
# */30 * * * * psql -c "REFRESH MATERIALIZED VIEW CONCURRENTLY mv_prepaid_campaign_stats;"

12. Frontend tổng hợp

12.1 Components mới

ComponentPhaseTypeEst. lines
PrepaidCardAnalytics.tsxP1Parent page200
PrepaidAnalyticsFilter.tsxP1Shared filter + branch selector300
PrepaidAnalyticsOverview.tsxP1Sub-tab 1 page500
PrepaidAnalyticsOverviewCards.tsxP1KPI cards (8)300
PrepaidAnalyticsOverviewCharts.tsxP14 Charts + toggle400
PrepaidAnalyticsOverviewAlerts.tsxP1Alert box 3 levels200
PrepaidAnalyticsOverviewRankings.tsxP13 Mini tables300
PrepaidAnalyticsTransactions.tsxP1Sub-tab 2 page400
PrepaidAnalyticsTransactionRow.tsxP1Expandable row (2 sections — Chi tiết TT + Hoa hồng; DEC-T07)300
PrepaidAnalyticsCustomers.tsxP1Sub-tab 3 page400
PrepaidAnalyticsCustomerRow.tsxP1Expandable row (3 sections)400
PrepaidAnalyticsCustomerSegments.tsxP14 Segment cards200
PrepaidAnalyticsFinance.tsxP1Sub-tab 4 page + 4 tab con250
PrepaidAnalyticsFinanceRevenue.tsxP1Tab con Tổng hợp DT300
PrepaidAnalyticsFinanceDebt.tsxP1Tab con Công nợ300
PrepaidAnalyticsFinanceCommission.tsxP1Tab con Hoa hồng250
PrepaidAnalyticsFinancePayment.tsxP1Tab con Phương thức thanh toán250
PrepaidAnalyticsGlobalSearch.tsxP2Tra cứu nhanh200
PrepaidAnalyticsMarketing.tsxP3+Sub-tab 5 page + 4 tab con250
PrepaidAnalyticsMktCampaigns.tsxP3+Tab con Chiến dịch400
PrepaidAnalyticsMktAffiliate.tsxP3+Tab con Affiliate250
PrepaidAnalyticsMktSources.tsxP3+Tab con Nguồn KH250
PrepaidAnalyticsMktCompare.tsxP3+Tab con So sánh kênh250
PrepaidAnalyticsStaff.tsxP3+Sub-tab 6 page + 3 tab con250
PrepaidAnalyticsStaffRanking.tsxP3+Tab con Xếp hạng350
PrepaidAnalyticsStaffDetail.tsxP3+Tab con Chi tiết NV400
PrepaidAnalyticsStaffBranch.tsxP3+Tab con So sánh CN300

Phase 1 components: 17 (~5.300 lines)Phase 2 components: 1 (~200 lines)Phase 3+ components: 9 (~2.700 lines)

Phase 1 chưa tính:

  • Composables (~600 lines): usePrepaidAnalyticsFilter, usePrepaidExport
  • GraphQL definitions (~350 lines): prepaid_analytics.graphql (chỉ queries P1)
  • Type definitions (~250 lines): types.ts additions
  • Pinia store (~200 lines): prepaid analytics store
  • i18n translations (~150 lines)
  • Route config (~80 lines): module.ts additions

Tổng Phase 1: ~6.930 lines ước tính

12.2 Shared Utilities (Phase 1)

  • usePrepaidAnalyticsFilter() — composable đọc filter từ route params + Pinia
  • usePrepaidExport() — composable tạo export job + theo dõi progress
  • prepaidAnalyticsRoutes — route config cho module.ts (chỉ 4 routes P1)
  • prepaid_analytics.graphql — GraphQL queries P1
  • Pinia store: usePrepaidAnalyticsStore

12.3 Charts

Sử dụng Chart.js (đã có trong project — chart.js@^3.9.1, vue-chart-3@^3.1.8):

  • Line chart: doanh thu theo thời gian
  • Grouped bar chart: so sánh khu vực (thay thế heatmap — Chart.js không hỗ trợ native)
  • Bar chart: so sánh CN (khi <= 10)
  • Donut chart: phân bố mệnh giá, PTTT, nguồn KH
  • Area chart: tỷ lệ sử dụng trend

12.4 i18n Keys chính

report.prepaid_analytics.tab_name = "Phân tích thẻ trả trước"

# Phase 1 sub-tab labels (theo thứ tự render):
report.prepaid_analytics.overview = "Tổng quan"
report.prepaid_analytics.transactions = "Giao dịch"
report.prepaid_analytics.customers = "Khách hàng"
report.prepaid_analytics.finance = "Tài chính"          # Đã rename từ "Doanh thu & Công nợ" (DEC-U06)

# Phase 3+ sub-tab labels (KHÔNG add vào Phase 1):
# report.prepaid_analytics.marketing = "Marketing"      # Đã rename từ "Marketing & Chiến dịch" (DEC-U06)
# report.prepaid_analytics.staff = "Nhân viên"

report.prepaid_analytics.kpi.collected = "Tiền thu vào"
report.prepaid_analytics.kpi.recognized = "Doanh thu ghi nhận"
report.prepaid_analytics.kpi.wallet_balance = "Tổng dư ví"
report.prepaid_analytics.kpi.debt = "Công nợ"
report.prepaid_analytics.kpi.sold = "Thẻ đã bán"
report.prepaid_analytics.kpi.activation_rate = "Tỷ lệ KH đã dùng ví"
report.prepaid_analytics.kpi.repurchase_rate = "Tỷ lệ KH tái nạp"
report.prepaid_analytics.kpi.new_customers = "KH mới"
report.prepaid_analytics.kpi.gross_margin = "Lợi nhuận gộp"

report.prepaid_analytics.filter.all_branches = "Tất cả chi nhánh"
report.prepaid_analytics.filter.branch = "Chọn chi nhánh"
report.prepaid_analytics.filter.date_range = "Khoảng thời gian"
report.prepaid_analytics.filter.search = "Tìm KH, SDT, mã đơn, NV..."

report.prepaid_analytics.date_range.today = "Hôm nay"
report.prepaid_analytics.date_range.yesterday = "Hôm qua"
report.prepaid_analytics.date_range.last_7d = "7 ngày qua"
report.prepaid_analytics.date_range.last_30d = "30 ngày qua"
report.prepaid_analytics.date_range.this_month = "Tháng này"
report.prepaid_analytics.date_range.last_month = "Tháng trước"
report.prepaid_analytics.date_range.this_quarter = "Quý này"
report.prepaid_analytics.date_range.custom = "Tùy chọn..."

report.prepaid_analytics.alert.critical = "Khẩn cấp"
report.prepaid_analytics.alert.warning = "Cảnh báo"
report.prepaid_analytics.alert.info = "Theo dõi"
report.prepaid_analytics.stale_data = "Dữ liệu cập nhật lúc: {time}"

report.prepaid_analytics.segment.active = "KH Hoạt động"
report.prepaid_analytics.segment.dormant = "KH Ngủ đông"
report.prepaid_analytics.segment.at_risk = "KH Rủi ro mất"
report.prepaid_analytics.segment.new = "KH Mới"

report.prepaid_analytics.branch.buy = "CN mua"
report.prepaid_analytics.branch.use = "CN sử dụng"

# Chỉ số hành vi KH (Section 5.5) — 3 metrics, đổi từ CLV Bar:
report.prepaid_analytics.behavior.aov = "Giá trị đơn hàng trung bình"
report.prepaid_analytics.behavior.repurchase = "Tỷ lệ tái nạp"
report.prepaid_analytics.behavior.cycle = "Chu kỳ trung bình"

# Phase 3+ (reference, không dùng Phase 1):
# report.prepaid_analytics.clv.average = "CLV trung bình"
# report.prepaid_analytics.clv.churn = "Tỷ lệ rời bỏ"

report.prepaid_analytics.export.exporting = "Đang xuất..."
report.prepaid_analytics.export.progress = "{percent}% ({current}/{total} dòng)"
report.prepaid_analytics.export.complete = "Xuất thành công"
report.prepaid_analytics.export.download = "Tải xuống"

report.prepaid_analytics.empty = "Không có dữ liệu trong khoảng thời gian này"
report.prepaid_analytics.error = "Đã xảy ra lỗi, vui lòng thử lại"

13. Performance Targets

MetricTargetStrategy
Tổng quan load< 500msMV pre-aggregated + Hasura cache
Bảng giao dịch (20 rows)< 300msKeyset pagination + composite index
Expand row< 200msLazy load, indexed queries
Tra cứu nhanh< 300mspg_trgm GIN index + % operator, debounce 300ms
Export 50K rows< 60sServer-side chunked, async
MV refresh< 30s eachCONCURRENT, staggered cron
Branch filter render< 100msGrouped tree, search, max 70 items
Chart render< 500msPre-aggregated data, Chart.js
Alert computation< 500msFunction với indexed queries

14. Risks & Mitigations

RiskImpactMitigation
MV refresh conflict với queryUser thấy data cũCONCURRENT refresh + “Cập nhật lúc: HH:mm” badge
70 CN x 365 ngày x N thẻ = data lớnQuery chậmPartial indexes + MV + keyset pagination
Export OOMServer crashChunked streaming + file size limit
Branch không có regionKhông nhóm được KVPre-deploy data check: đảm bảo tất cả 70 CN đã gán branch.region_id (xem Section 9.10). Schema đã có sẵn
Cross-schema wallet query chậmMV refresh chậmDerive wallet usage từ invoice (ecommerce) thay vì query wallet DB
Campaign không có budget fieldKhông tính ROI chính xácTạm tính ROI từ HH affiliate, ghi chú trên UI
New tab có bugUser không dùng đượcFeature flag FEATURE_PREPAID_ANALYTICS_V2 → rollback 1 dòng
prepaid_card không có expiryKhông alert “thẻ hết hạn”Thay bằng alert “KH lâu không sử dụng” (behavior-based)
wallet_balance cần real-timeMV không đủQuery trực tiếp wallet DB + Redis cache (TTL 10min)

15. Dependencies & Assumptions

Dependencies:

  • region_branch table (đã có sẵn — không cần migration)
  • pg_trgm extension (đã có)
  • export-api service (đã có, cần bổ sung endpoint)
  • notification-api (đã có, dùng cho async export)
  • Chart.js + vue-chart-3 (đã có trong package.json)
  • URQL + Hasura (đã có)
  • Redis (đã có, dùng cho wallet_balance cache)
  • MinIO/GCS (đã có, dùng cho export files)

Assumptions:

  • Ecommerce và wallet databases nằm trong cùng 1 PostgreSQL instance (verified từ docker-compose)
  • order table có các field: customer_id, branch_id, campaign_ids, order_kind, paid_at, paid_amount, total
  • invoice table có: reference_amount (⏳ V9/V11/V12 — Ví Diva NẠP khi parent của prepaid order, Ví Diva/KM ĐÃ DÙNG khi filter payment_method_id), wallet_promotion_amount (⏳ V10 — Ví KM NẠP lúc bán thẻ — KHÔNG phải Ví KM ĐÃ DÙNG), payment_method_id (enum: 'cash'/'bank'/'wallet'/'wallet_promotion'/'cod'), parent_id (Rule 0), status (Rule 0a — V13)
  • transaction_request có: order_id, branch_id, customer_id, behavior_id
  • order_commission liên kết NV với đơn hàng
  • Hệ thống có khoảng 100K KH và 1M+ giao dịch tích lũy (70 CN x 2-3 năm)
  • prepaid_card KHÔNG có expiry field
  • ROUTE_PREPAID_cARD_REPORT_GROUP giữ nguyên typo (backward compatibility)

16. Tooltip & Định nghĩa cột (Tiếng Việt)

Mục này quy định nội dung tooltip cho mỗi KPI card và định nghĩa cho mỗi cột bảng. Tooltip hiển thị khi hover (desktop) hoặc long-press (mobile). Mục tiêu: người dùng không cần hỏi IT “số này lấy ở đâu”.

16.1 Phân biệt các khái niệm dễ nhầm lẫn

Bảng này PHẢI được hiển thị ở dạng info-popover hoặc link “Giải thích thuật ngữ” trên mỗi sub-tab.

Thuật ngữÝ nghĩaNguồn dữ liệuVí dụ
Tiền thu vào ✅ V8Số tiền thực tế KH trả (tiền mặt + chuyển khoản + quẹt thẻ) khi mua thẻ trả trướcSUM(invoice.amount) WHERE parent_id IS NULL AND canceled_at IS NULL AND (status='invoice_completed' OR status IS NULL) (Rule 0+0a)KH mua thẻ 10tr, trả đủ 10tr → Tiền thu = 10tr
Nạp ví KHGiá trị thực nạp vào ví điện tử của KH (= Ví Diva + Ví KM)SUM(order_item.prepaid_value_into_wallet) (đã là line total — KHÔNG × quantity, Rule 1)KH mua thẻ 10tr, KM thêm 2tr → Nạp ví = 12tr
Ví Diva nạp ⏳ V9Phần nạp vào ví chính (KH dùng được, có thể rút theo policy) — KHÔNG bằng "Tiền thu" khi giá bán ≠ base valueSUM(parent_invoice.reference_amount) cho prepaid order (PRD A10.0 hàng #2 — chờ V9 confirm)Thẻ base 10tr, bán 8tr (discount), policy nạp đủ → Ví Diva = 10tr (≠ Tiền thu 8tr)
Ví KM nạp ⏳ V10Phần KM spa bù vào ví khuyến mãi (KHÔNG rút được) — KHÔNG derive bằng Nạp ví − Tiền thuSUM(parent_invoice.wallet_promotion_amount) cho prepaid order (PRD A10.0 hàng #3 — chờ V10 confirm)Thẻ 10tr + KM 2tr → Ví KM = 2tr (lấy thẳng field, không derive)
Doanh thu ghi nhận ⏳ V11Số tiền KH đã sử dụng từ ví chính để thanh toán dịch vụ — DEC-B06 PROVISIONAL chờ PO + Kế toán reconcile với baseline cũSUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id='wallet' AND canceled_at IS NULL AND Rule 0a (PRD A10.0 hàng #4)KH dùng 500K từ ví chính trả dịch vụ → DT = 500K
KM đã sử dụng ⏳ V12Phần ví khuyến mãi KH đã dùng thanh toán dịch vụ. ❌ KHÔNG dùng wallet_promotion_amount (field này là KM NẠP — Review L8)SUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id='wallet_promotion' AND canceled_at IS NULL AND Rule 0a (PRD A10.0 hàng #5)KH dùng 300K từ ví KM trả dịch vụ → KM đã SD = 300K
Tổng dư ví ⏳ V5Tổng số dư ví của tất cả KH — là nợ phải trả (nghĩa vụ cung cấp dịch vụ tương lai)wallet_balance_diva + wallet_balance_km (exact fields chờ V5 confirm; Redis cache). Nếu BE expose total-only wallet_balance.balance, phải reconcile balance = diva + km5000 KH còn dư tổng 2.1 tỷ → Tổng dư ví = 2.1 tỷ
Công nợSố tiền KH chưa trả đủ cho đơn mua thẻ (mua trả góp / nợ)SUM(order.total - order.paid_amount) WHERE paid_amount < totalĐơn 10tr, KH mới trả 7tr → Công nợ = 3tr
Hoa hồng ⭐ DEC-T08Tiền thưởng cho NV bán thẻ, lưu trong invoice_commission. Chỉ tính HH đã chi (invoice_status = 'invoice_completed') — KHÔNG có pending. Refund track riêngGross: SUM(invoice_commission.amount) WHERE invoice_status = 'invoice_completed'. Net: gross − SUM(ABS(refund_commission))NV bán đơn 10tr, HH 5% → invoice_commission insert 500K
KM đã nạp ⏳ V10Phần giá trị spa bù thêm vào ví KH lúc mua thẻ (= FORMULA-004 alias). ❌ KHÔNG derive bằng Nạp ví − Tiền thu (Review L8 — sai khi discount/base≠sell)SUM(parent_invoice.wallet_promotion_amount) cho prepaid order (= Ví KM nạp, ref FORMULA-004 — chờ V10)Mua 10tr, KM 2tr → KM đã nạp = 2tr
Hoàn ví (Refund Order)Tiền hoàn lại vào ví KH khi hủy đơn/dịch vụ. Không tính vào doanh thutransaction_request WHERE behavior_id = 'refund_order'Hủy đơn 500K → Hoàn ví KH 500K
Hoàn hoa hồng (Refund Commission) ⭐ DEC-T08Khi đơn bị hủy → tạo transaction_request mới với behavior_id='refund_commission'amount âm (ghi sổ thu hồi). KHÔNG đổi status invoice_commission gốcSUM(ABS(transaction_request.amount)) WHERE behavior_id='refund_commission'NV nhận HH 500K, đơn bị hủy → tạo refund_commission amount=-500000
Lợi nhuận gộp ⏳ depends V10Tiền thu − Hoa hồng − KM đã nạp. KHÔNG phải lãi ròng (chưa trừ chi phí vận hành)Derived sau V10 confirmedThu 850tr − HH 38tr − KM đã nạp 70tr = LN gộp 742tr (giả định V10)

16.2 Tooltip KPI Cards — Sub-tab Tổng quan

Định dạng tooltip 3-phần: (1) Định nghĩa ngắn → (2) Công thức / điều kiện → (3) Ví dụ số cụ thể.

KPI Card (label hiển thị)Tooltip (hiển thị cho user)
Tiền thu vào"Tổng tiền thực tế thu được từ bán thẻ trả trước trong kỳ (tiền mặt + CK + quẹt thẻ). Không bao gồm phần khuyến mãi nạp thêm. Công thức: SUM(invoice.amount) của đơn prepaid đã thanh toán."
DT ghi nhận ⏳ PROVISIONAL"Tiền KH đã dùng từ ví trả dịch vụ. Semantics chưa khóa (review L8 V11 BLOCKER): codebase report cũ split wallet vs wallet_promotion thành 2 bucket — DEC-B06 đề xuất chỉ ví chính → conflict. PO + Kế toán cần reconcile với báo cáo cũ trước khi khóa. Tooltip thực tế sẽ update sau khi V11 confirmed."
Tổng dư ví"Tổng số dư ví của tất cả KH có thẻ trả trước. Con số này đại diện cho NỢ PHẢI TRẢ — nghĩa vụ cung cấp dịch vụ tương lai. Dữ liệu real-time (cache 10 phút)."
Công nợ"Tổng số tiền KH chưa trả đủ cho đơn mua thẻ. Bao gồm đơn trả góp và đơn chưa thanh toán. Cảnh báo đỏ khi vượt ngưỡng cấu hình."
Thẻ đã bán"Tổng số lượng thẻ (và gói nạp linh hoạt) đã bán trong kỳ. Dòng phụ "X Cố định + Y Linh hoạt" tách theo prepaid_card.flexible. Tính theo số lượng trên order_item, không phải số đơn hàng. 1 đơn có thể mua nhiều thẻ."
Tỷ lệ KH đã dùng ví"Phần trăm KH đã mua thẻ VÀ đã sử dụng ít nhất 1 lần so với tổng KH đã mua. Công thức: KH có sử dụng ví ÷ KH đã mua thẻ × 100%. Tỷ lệ thấp = KH mua nhưng không dùng → cần chăm sóc."
Tỷ lệ KH tái nạp"Phần trăm KH quay lại mua thẻ lần 2 trở lên. Công thức: KH có ≥ 2 đơn prepaid ÷ KH có ≥ 1 đơn × 100%. Tỷ lệ cao = KH hài lòng, mô hình thẻ hiệu quả."
KH mới"Số KH lần đầu mua thẻ trả trước trong kỳ xem. Không tính KH đã mua trước đó rồi mua lại."

16.3 Tooltip KPI Cards — Sub-tab Tài chính

KPI Card (label hiển thị)Tooltip
Tiền thu vào(Giống 16.2)
Nạp ví"Tổng giá trị thực tế nạp vào ví KH (= Ví Diva nạp + Ví KM nạp). Luôn ≥ Tiền thu vào khi có KM. Công thức: SUM(prepaid_value_into_wallet) (KHÔNG × quantity — đã là line total, Rule 1). ⚠️ KHÔNG được derive KM đã nạp = Nạp ví − Tiền thu vào — Review L8 chứng minh SAI khi giá bán ≠ base value (xem PRD A10.0 hàng #3 — V10)."
Tổng dư ví ⏳ V5(Giống 16.2)
Công nợ(Giống 16.2)
DT ghi nhận ⏳ V11 PROVISIONAL"Tiền KH đã dùng từ ví chính trả dịch vụ (DEC-B06 PROVISIONAL — KHÔNG tính ví KM). Công thức: SUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id='wallet' AND canceled_at IS NULL AND (status='invoice_completed' OR status IS NULL) (Rule 0+0a). Semantics chưa khóa — PO + Kế toán cần reconcile với baseline search_report_service (V11) trước khi LOCK."
KM đã sử dụng ⏳ V12 PROVISIONAL"Phần ví KM mà KH đã dùng trả dịch vụ (= chi phí KM tiêu thực sự, đo hiệu quả khuyến mãi). Công thức: SUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id='wallet_promotion' AND canceled_at IS NULL AND (status='invoice_completed' OR status IS NULL) (PRD A10.0 hàng #5). ❌ KHÔNG dùng wallet_promotion_amount — Review L8/V12 chứng minh field này là KM NẠP lúc bán thẻ (FORMULA-004), KHÔNG phải usage. Khác 'KM đã nạp' (= phần spa bù lúc bán thẻ — có thể chưa dùng hết)."
Hoa hồng ⭐ DEC-T08"Tổng hoa hồng đã chi cho NV từ bán thẻ trả trước. Chỉ 1 trạng thái: 'Đã chi' (invoice_status = 'invoice_completed'). KHÔNG có pending. Refund (khi đơn bị hủy) track riêng. Công thức gross: SUM(invoice_commission.amount) WHERE invoice_status='invoice_completed'. Net = gross − refund."
KM đã nạp ⏳ V10 PROVISIONAL"Phần giá trị spa bù thêm vào ví KH lúc mua thẻ (= FORMULA-004 alias — đồng nghĩa Ví KM nạp). Công thức: SUM(parent_invoice.wallet_promotion_amount) cho prepaid order (PRD A10.0 hàng #3 — chờ V10 confirm). ❌ KHÔNG derive bằng Nạp ví − Tiền thu vào — Review L8 chứng minh SAI khi discount/base≠sell. Ví dụ: thẻ 10tr giá bán 8tr (discount), KM 2tr → derive cũ ra 4tr ❌, canonical lấy thẳng field = 2tr ✅. Khác với 'KM đã sử dụng' (= phần ví KM mà KH đã dùng trả dịch vụ)."
Lợi nhuận gộp ⏳ depends V10"Lợi nhuận sơ bộ từ bán thẻ trả trước. Công thức: Tiền thu vào − Hoa hồng − KM đã nạp (KM đã nạp lấy từ FORMULA-004 canonical, KHÔNG derive). LƯU Ý: Đây KHÔNG phải lãi ròng — chưa bao gồm chi phí nhân sự, mặt bằng, vận hành. KM đã nạp ≠ KM đã sử dụng."

16.4 Tooltip KPI Cards — Sub-tab Marketing

KPI Card (label hiển thị)Tooltip
Đơn từ chiến dịch"Số đơn mua thẻ trả trước có gắn mã chiến dịch (campaign_ids). Cho biết chiến dịch tạo được bao nhiêu đơn."
Doanh thu từ chiến dịch"Tổng doanh thu từ đơn có chiến dịch. Công thức: SUM(invoice.amount) WHERE order.campaign_ids IS NOT NULL."
KH mới từ chiến dịch"Số KH lần đầu mua thẻ VÀ đơn đầu tiên có gắn chiến dịch. Đo lường khả năng thu hút KH mới của chiến dịch."
Tỷ lệ chuyển đổi"% đơn thành công từ chiến dịch ÷ tổng tiếp cận (nếu có dữ liệu reach). Nếu không có dữ liệu reach → hiển thị tỷ lệ KH mới / tổng KH từ chiến dịch."
Đơn từ affiliate"Số đơn mua thẻ có mã giới thiệu (ref_code). Bao gồm: NV nội bộ, KH cũ giới thiệu, đối tác."
Doanh thu từ affiliate"Tổng doanh thu từ đơn có mã giới thiệu. Công thức: SUM(invoice.amount) WHERE order.ref_code IS NOT NULL."
Chi phí marketing"Tổng chi phí marketing = Hoa hồng affiliate + Budget chiến dịch (nếu có). Nếu chiến dịch chưa có field budget → chỉ tính hoa hồng affiliate (ghi chú trên UI)."
ROI"Tỷ suất lợi nhuận marketing. Công thức: (Doanh thu từ chiến dịch + affiliate − Chi phí) ÷ Chi phí × 100%. Phân loại: 🟢 Hiệu quả ROI > 200% · 🟡 Trung bình 100%–200% · 🔴 Kém ROI < 100%. Lưu ý: nếu chiến dịch chưa có budget → hiển thị '—' (Chưa có budget), ROI có thể chưa phản ánh chính xác chi phí thực."

16.5 Tooltip KPI Cards — Sub-tab Nhân viên

KPI Card (label hiển thị)Tooltip
Nhân viên có đơn"Số NV đã bán ít nhất 1 đơn thẻ trả trước trong kỳ, kèm % so với tổng NV. NV có đơn thấp = nhiều NV chưa tham gia bán thẻ → cơ hội đào tạo."
Số đơn TB / NV"Số đơn trung bình mỗi NV có bán. Công thức: Tổng đơn ÷ Số NV có đơn. Dùng để đánh giá năng suất trung bình."
Doanh thu TB / NV"Doanh thu trung bình mỗi NV có bán. Công thức: Tổng Doanh thu ÷ Số NV có đơn."
Tổng Hoa hồng đã chi"Tổng hoa hồng đã chi cho tất cả NV từ bán thẻ, kèm % so với doanh thu. Dùng để đánh giá chi phí bán hàng."

16.6 Định nghĩa cột — Bảng Giao dịch (Sub-tab 2)

10 cột (gộp cũ 11 cột → 10 cột bằng cách merge Loại thẻ + Tên thẻ thành 1 cột với badge inline). 3 cột format multi-line (Ngày TT, Khách hàng, Nhân viên).

#Cột (label header)FormatMô tảNguồn
1Mã đơnLink 1 dòngMã định danh đơn hàng (VD: TT-1232MNGA)order.code → link /e/prepaid-order/:id
2Ngày TTMulti-line: HH:mm / DD/MM/YYYYNgày đơn được thanh toán (không phải ngày tạo đơn)order.paid_at (timezone Asia/Ho_Chi_Minh). Sortable default desc
3Khách hàngMulti-line: tên / SDTTên KH (link CRM) + Số điện thoại (text-secondary)customer.display_name + customer.phone_number (masked theo role)
4Tên thẻBadge inline + textBadge [Cố định] (xanh) hoặc [Linh hoạt] (tím) + tên gói. Khi flexible=truename rỗng → hiển thị "Nạp linh hoạt {prepaid_value_into_wallet}". Nếu name có giá trị → "{name} (linh hoạt {value})".prepaid_card.flexible (badge) + prepaid_card.name (text)
5SLNumber centerSố lượng thẻ trong đơn (thường = 1)order_item.quantity
6Tiền thuVND right, sortableSố tiền thực tế KH đã trảSUM(invoice.amount) WHERE canceled_at IS NULL
7Nạp víVND right, sortableGiá trị thực nạp vào ví KH (= Ví Diva + Ví KM)order_item.prepaid_value_into_wallet (đã là line total — KHÔNG × quantity)
8Trạng tháiQBadge pillTrạng thái thanh toánDerived: 🟢 Đã thanh toán (paid_amount = total) / 🟡 Còn nợ (0 < paid_amount < total) / 🔴 Chưa thanh toán (paid_amount = 0)
9Nhân viênMulti-line + Avatar: NV bán / NV thu ngân (TBD)Dòng 1: NV bán (avatar tròn + tên link). Dòng 2: NV thu ngân (text-secondary)NV bán: order_commission.user_idecommerce_user.display_name. NV thu ngân: TBD — verify field source (order.created_by hoặc invoice.created_by hoặc cashier_id)
10Chi nhánhText 1 dòngCN bán thẻ (= CN mua)order.branch_idbranch.name

⚠️ Schema verification trước implement:

  1. NV thu ngân (cột 9 dòng 2): xác định field source chính xác. Hỏi backend team xem schema có cashier riêng không.
  2. Ví Diva / Ví KM (sum cards 4-5): verify order_item có field riêng prepaid_value_into_wallet_promotion không, hoặc derive từ rule Ví KM = Tổng nạp ví − Tiền thu.

Cột expanded row — Chi tiết thanh toán:

CộtMô tảNguồn
Phương thứcCách KH trả tiền (Tiền mặt / CK / Quẹt thẻ / Ví)invoice.payment_method_id
Số tiềnSố tiền cho lần thanh toán nàyinvoice.amount
Ngày TTNgày thực hiện thanh toáninvoice.paid_at
Trạng tháiĐã thanh toán / Đã hủyinvoice.canceled_at IS NULL → Đã TT

⚠️ Section "Cột expanded row — Lịch sử sử dụng" đã bị xoá (DEC-T07 — 2026-05-12). Wallet pool chung per-customer, không track per-card. Định nghĩa cột Lịch sử biến động ví xem ở Section 16.7+ (Sub-tab Khách hàng).

Cột expanded row — Hoa hồng (DEC-T08 — 2026-05-12):

Bỏ cột "Trạng thái" — codebase chỉ có 1 status (invoice_completed). Refund hiển thị thành row riêng với amount âm + badge ↩️ Hoàn HH (gây nhiễu hơn cột trạng thái cho 1 giá trị duy nhất).

CộtMô tảNguồn
Nhân viênNV nhận hoa hồnginvoice_commission.user_idecommerce_user.display_name
Số tiền HHHoa hồng (dương = đã chi, âm = hoàn)Row HH gốc: invoice_commission.amount WHERE invoice_status = 'invoice_completed'. Row refund: transaction_request.amount WHERE behavior_id = 'refund_commission' (đã lưu sẵn dạng âm)
LoạiBadge: Đã chi (default) hoặc ↩️ Hoàn HH (refund row)Derived theo nguồn row

16.7 Định nghĩa cột — Bảng Khách hàng (Sub-tab 3)

Cột (label header)Mô tảNguồnGhi chú
Khách hàngTên KH, click để xem CRMcustomer.display_nameLink CRM profile
SĐTSố điện thoạicustomer.phone_numberMasked: 0912***456 (trừ role có quyền)
Phân khúcNhóm KH tự độngDerived từ last_wallet_usage_atTag màu: 🟢 KH Hoạt động, 🟡 KH Ngủ đông, 🔴 KH Rủi ro mất, 🔵 KH Mới
Tổng tiền đã nạpTổng tiền KH đã trả mua thẻ (tất cả các lần)SUM(invoice.amount)Tổng lũy kế, không theo kỳ filter
Dư ví DIVA ⏳ V5aSố dư ví chính hiện tại của KHExact field chờ V5 confirm (wallet_balance_diva / field tương đương trong wallet_balance_result)Real-time/cache 10p; dùng để tính tổng dư ví KH
Dư ví KM ⏳ V5bSố dư ví khuyến mãi hiện tại của KHExact field chờ V5 confirm (wallet_balance_km / field tương đương trong wallet_balance_result)Real-time/cache 10p; không rút; highlight đỏ theo Dư ví DIVA + Dư ví KM > 5tr
Đã dùng ví ⏳ V11+V12Tổng giá trị đã dùng từ ví (cả ví chính + ví KM) — đo behavior "KH có quay lại dùng"SUM(invoice.reference_amount) FILTER (WHERE payment_method_id IN ('wallet','wallet_promotion')) WHERE parent_id IS NULL AND canceled_at IS NULL AND (status='invoice_completed' OR status IS NULL) (PRD A10.0 hàng #4 + #5). ❌ KHÔNG dùng wallet_promotion_amount — Review L8/V12 chứng minh field này là KM NẠP lúc bán thẻ (FORMULA-004), KHÔNG phải usageLũy kế
% Đã dùng ví ⏳ V11+V12Tỷ lệ KH đã dùng ví so với tổng nạp (cả ví chính + ví KM). VD: KH trả 10tr, nạp ví 12tr (10 chính + 2 KM), dùng reference_amount 10tr (8 chính + 2 KM) → % = 10/12 = 83%. KHÁC "DT ghi nhận" (chỉ ví chính, ref DEC-B06) — đây là metric BEHAVIOR, không phải accountingtotal_wallet_used / total_wallet_topup × 100 (numerator = Đã dùng ví ở hàng trên, dùng reference_amount filter — KHÔNG dùng wallet_promotion_amount)Progress bar: Xanh > 70%, Vàng 30–70%, Đỏ < 30%
Số lần mua thẻSố đơn prepaid đã muaCOUNT(DISTINCT order.id)Mua nhiều lần = KH trung thành
Lần dùng ví cuốiNgày sử dụng ví gần nhấtMAX(transaction_request.created_at) WHERE type=‘T’Highlight đỏ nếu > 60 ngày
CN muaCN mà KH mua thẻ nhiều nhấtMODE(order.branch_id)Nếu mua nhiều CN → hiển thị CN nhiều nhất + "(+N CN)"
CN sử dụngCN mà KH sử dụng dịch vụ nhiều nhấtMODE(transaction_request.branch_id) WHERE type=‘T’Quan trọng: có thể khác CN mua
Số CN đã dùngSố CN khác nhau KH đã sử dụngCOUNT(DISTINCT transaction_request.branch_id)> 1 = KH cross-branch (badge nổi bật)

Cột expanded row — Danh sách thẻ/gói đã mua:

CộtMô tảNguồn
LoạiThẻ cố định hoặc Nạp linh hoạtprepaid_card.flexible
Tên thẻTên gói thẻprepaid_card.name
CN muaChi nhánh bán thẻorder.branch_idbranch.name
Ngày muaNgày thanh toánorder.paid_at
Giá trịGiá trị nạp vào víorder_item.prepaid_value_into_wallet (KHÔNG × quantity)
Trạng tháiĐang dùng / Hết / Còn nợDerived

Cột expanded row — Hành vi sử dụng:

CộtMô tảNguồn
Tần suấtSố lần sử dụng ví / thángCOUNT(transaction_request) WHERE type=‘T’ / số tháng
Dịch vụ hay dùngDịch vụ KH dùng ví thanh toán nhiều nhấtTừ order_item của đơn dịch vụ thanh toán bằng ví
Giờ hay đếnKhung giờ KH thường đặt lịchTừ transaction_request.created_at → extract hour
CN hay đếnCN KH sử dụng dịch vụ nhiều nhấtMODE(transaction_request.branch_id) WHERE type=‘T’

Cột expanded row — Gợi ý hành động:

Điều kiệnGợi ý hiển thị
Tần suất giảm > 50% so với 3 tháng trước“Tần suất sử dụng giảm — cân nhắc gửi ưu đãi kích hoạt”
Dư ví < 20% giá trị nạp“Dư ví thấp — gợi ý tái nạp khi KH đến spa”
> 30 ngày không dùng, dư > 0“KH chưa quay lại — cân nhắc liên hệ nhắc nhở”
KH mới, chưa dùng lần nào“KH mới — hướng dẫn sử dụng và booking lần đầu”

16.8 Định nghĩa cột — Bảng Doanh thu tổng hợp (Sub-tab 4, Tab con 1)

Cột (label header)Mô tảNguồnGhi chú
Thời gianNgày (DEC-U08 — KHÔNG có toggle Tuần/Tháng)report_date từ mv_prepaid_order_dailySortable, format DD/MM/YYYY
Tiền thu vàoTổng tiền thu từ bán thẻSUM(total_collected) từ mv_prepaid_order_daily= Tiền thực tế KH trả
Nạp víTổng giá trị nạp vào ví KH (= Ví Diva nạp + Ví KM nạp)SUM(total_wallet_topup)≥ Tiền thu vào khi có KM. ❌ KHÔNG được derive KM đã nạp = Nạp ví − Tiền thu ở client (Review L8)
KM đã nạp ⏳ V10Giá trị khuyến mãi spa bù thêm vào ví KH lúc bán thẻ (= FORMULA-004 alias)SUM(parent_invoice.wallet_promotion_amount) cho prepaid order WHERE parent_id IS NULL AND canceled_at IS NULL AND Rule 0a (PRD A10.0 hàng #3 — chờ V10). ❌ FORBIDDEN derivation: total_wallet_topup − total_collected → SAI khi giá bán ≠ base/discount/phụ thuChi phí spa bù thêm. Lấy thẳng từ field, KHÔNG derive
Hoa hồng ⭐ DEC-T08Tổng hoa hồng NV (net = gross − refund)Gross: SUM(invoice_commission.amount) WHERE invoice_status='invoice_completed'. Refund: SUM(ABS(transaction_request.amount)) WHERE behavior_id='refund_commission'. (KHÔNG aggregate từ mv_prepaid_staff_stats — MV này defer P3+)Chi phí bán hàng
Lợi nhuận gộp ⏳ depends V10Tiền thu vào − Hoa hồng − KM đã nạpDerived sau khi V10 confirmed (KM đã nạp lấy từ FORMULA-004 canonical)KHÔNG phải lãi ròng
Số đơnSố đơn hàngSUM(order_count)Tổng đơn prepaid

16.9 Định nghĩa cột — Bảng Công nợ (Sub-tab 4, Tab con 2)

Cột (label header)Mô tảNguồnGhi chú
Mã đơnMã đơn hàngorder.codeLink chi tiết đơn
Khách hàngTên KHcustomer.display_nameLink CRM
Ngày muaNgày thanh toán đơnorder.paid_at
Tổng đơnGiá trị đơn hàngorder.total
Đã trảSố tiền KH đã thanh toánorder.paid_amount
Còn nợChênh lệch chưa trảtotal - paid_amountHighlight theo mức độ
Ngày quá hạnSố ngày kể từ ngày tạo đơn chưa trả đủCURRENT_DATE - DATE(order.created_at)Màu: < 15 ngày Xanh, 15–30 ngày Vàng, > 30 ngày Đỏ. Dùng created_at (ngày phát sinh nghĩa vụ) thay vì paid_at — chuẩn kế toán
Mức độPhân loại quá hạnDerivedBình thường / Cảnh báo / Nghiêm trọng
Chi nhánhChi nhánh bánorder.branch_idbranch.name
Nhân viên bánNhân viên bánorder_commission.user_id
Hành độngChỉ 1 nút icon-only [📞]UI[📞 Gọi] = tel: link / copy SDT (đọc trực tiếp từ customer.phone_number masked theo permission). Xác nhận TT + Ghi chú = OUT OF SCOPE Phase 1

16.10 Định nghĩa cột — Bảng Hoa hồng (Sub-tab 4, Tab con 3)

Cột (label header)Mô tảNguồnGhi chú
Nhân viênTên NV bánecommerce_user.display_name
Chi nhánhCN của NVbranch.name
Số đơnSố đơn prepaid NV đã bánCOUNT(DISTINCT order.id)
Doanh thu đơn bánTổng doanh thu từ đơn NV bánSUM(invoice.amount)
Hoa hồng ⭐ DEC-T08Tổng Hoa hồng đã chi (1 status duy nhất)SUM(invoice_commission.amount)WHERE invoice_status = 'invoice_completed' AND order_id IN (prepaid orders). Refund tách riêng
% Hoa hồng / Doanh thuTỷ lệ hoa hồng trên doanh thuHoa hồng / Doanh thu × 100Dùng để kiểm tra chính sách HH

16.11 Định nghĩa cột — Bảng Phương thức thanh toán (Sub-tab 4, Tab con 4)

Cột (label header)Mô tảNguồnGhi chú
Phương thứcCách KH thanh toán khi mua thẻinvoice.payment_method_idTiền mặt / Chuyển khoản / Quẹt thẻ / Ví / COD
Số đơnSố đơn dùng phương thức nàyCOUNT(DISTINCT order.id)
Số tiềnTổng tiền thu qua phương thứcSUM(invoice.amount)
% TổngTỷ lệ trên tổng doanh thuamount / SUM(amount) × 100Donut chart kèm
TrendSo sánh với kỳ trướcDerived↑↓ % so với kỳ trước cùng thời gian

16.12 Định nghĩa cột — Bảng Chiến dịch (Sub-tab 5, Tab con 1)

Cột (label header)Mô tảNguồnGhi chú
Tên chiến dịchTên chiến dịchcampaign.name
Thời gianKhoảng thời gian chạycampaign.start_dateend_date
Số đơnSố đơn từ chiến dịch. Lưu ý: 1 đơn có thể thuộc nhiều CD (campaign_ids là array) → DT có thể bị tính trùng giữa các CDCOUNT WHERE campaign_ids @> idHiển thị cảnh báo nếu đơn thuộc > 1 CD
Doanh thuDoanh thu từ chiến dịchSUM(invoice.amount)
KH mớiKH lần đầu mua thẻ qua chiến dịchDerivedChỉ tính KH chưa có đơn prepaid trước đó
Thẻ bán chạyLoại thẻ bán nhiều nhất trong CDMODE(prepaid_card.name)
Khu vực hiệu quảKhu vực có Doanh thu cao nhất từ CDDerived từ branch.region_id
Trạng tháiĐánh giá hiệu quả CD (canonical PRD A10 FORMULA-020)Derived từ ROI🟢 Hiệu quả ROI > 200% / 🟡 Trung bình 100% ≤ ROI ≤ 200% / 🔴 Kém ROI < 100%. Edge: chi phí = 0 → "—" + tag "Chưa có budget"

16.13 Định nghĩa cột — Bảng Xếp hạng nhân viên (Sub-tab 6, Tab con 1)

Cột (label header)Mô tảNguồnGhi chú
#Thứ hạngDerived (ORDER BY tiêu chí chọn)
Nhân viênTên NVecommerce_user.display_name
Chi nhánhCN làm việcbranch.name
Khu vựcKhu vực của CNregion_branch.name
Số đơnSố đơn đã bánmv_prepaid_staff_stats.order_count
Doanh thuTổng doanh thumv_prepaid_staff_stats.revenue
Hoa hồngHoa hồng nhậnmv_prepaid_staff_stats.commission
Doanh thu TB / đơnDoanh thu trung bình mỗi đơnrevenue / order_countĐánh giá chất lượng đơn (bán thẻ lớn vs nhỏ)
KH mớiSố KH mới NV mang vềmv_prepaid_staff_stats.new_customersKH lần đầu mua thẻ qua NV này
Đánh giáHiệu suất so với TBDerivedXuất sắc (> 150% TB) / Tốt / Cần cải thiện (< 50% TB)

16.14 Định nghĩa cột — Bảng So sánh chi nhánh (Sub-tab 6, Tab con 3)

Cột (label header)Mô tảNguồnGhi chú
Chi nhánh / Khu vựcTên CN hoặc Khu vực (nhóm 2 cấp)branch.name / region_branch.nameCollapsed = Khu vực, Expand = Chi nhánh
Tổng NVSố NV thuộc CN/KVCOUNT(ecommerce_user) WHERE branch_id
NV có đơnSố NV đã bán ≥ 1 đơn prepaidCOUNT DISTINCT user_id từ MV
% Tham giaTỷ lệ NV bán thẻNV có đơn / Tổng NV × 100Thấp = cơ hội đào tạo, thúc đẩy
Số đơn TB / NVĐơn trung bình mỗi NV có bánTổng đơn / NV có đơn
Doanh thu TB / NVDoanh thu trung bình mỗi NV có bánTổng DT / NV có đơn
Đánh giáNhận xét tự độngDerived"CN mẫu" / "Cần đào tạo" / "Trung bình"

16.15 Định nghĩa cột — Bảng Affiliate (Sub-tab 5, Tab con 2)

Cột (label header)Mô tảNguồnGhi chú
Nguồn giới thiệuTên NV/KH/đối tác giới thiệuorder.ref_code → lookup
LoạiNV nội bộ / KH cũ / Đối tácDerived từ ref_code prefix hoặc user type
Số đơnSố đơn từ nguồn giới thiệuCOUNT(DISTINCT order.id)
Doanh thuDoanh thu từ nguồnSUM(invoice.amount)
KH mớiKH lần đầu mua qua nguồnDerived
Hoa hồng đã chi ⭐ DEC-T08Hoa hồng affiliate đã trảSUM(invoice_commission.amount) WHERE invoice_status = 'invoice_completed' AND order.ref_code IS NOT NULL (JOIN với order qua order_id)Source: invoice_commission (ecommerce)

16.16 Định nghĩa nguồn khách hàng — Charts (Sub-tab 5, Tab con 3)

Tab con này hiển thị biểu đồ (donut + stacked area), không có bảng. Định nghĩa các phân loại:

NguồnĐiều kiệnTooltip
Chiến dịchorder.campaign_ids IS NOT NULL“KH đến từ chiến dịch marketing (có gắn mã CD)”
Affiliateorder.ref_code IS NOT NULL (và campaign_ids IS NULL)“KH đến từ giới thiệu (NV/KH cũ/đối tác)”
Tái nạpKH đã có đơn prepaid trước đó (campaign_ids IS NULL, ref_code IS NULL)“KH cũ quay lại mua thêm — không qua CD hay affiliate”
Walk-inCòn lại (không CD, không ref, lần đầu mua)“KH tự đến mua lần đầu — không qua kênh nào”

Ưu tiên phân loại: Chiến dịch > Affiliate > Tái nạp > Walk-in. Nếu 1 đơn vừa có campaign_ids vừa có ref_code → tính vào Chiến dịch.

16.17 Định nghĩa cột — Bảng So sánh hiệu quả kênh marketing (Sub-tab 5, Tab con 4)

Cột (label header)Mô tảNguồnGhi chú
KênhTên kênh marketing (Chiến dịch / Affiliate / Walk-in / Tái nạp)Derived từ logic phân loại nguồn (xem 16.16)
Số đơnSố đơn từ kênhCOUNT(DISTINCT order.id)
Doanh thuDoanh thu từ kênhSUM(invoice.amount)
Chi phíHoa hồng affiliate + budget chiến dịch (nếu có)DerivedGhi chú nếu thiếu budget
ROI(Doanh thu − Chi phí) / Chi phí × 100%DerivedChỉ chính xác khi có đầy đủ chi phí
Đánh giáNhận xét tự độngDerived"ROI cao nhất" / "Cần cải thiện" / "Ổn định"

16.18 Định nghĩa cột — Chi tiết nhân viên (Sub-tab 6, Tab con 2)

Cột / KPI (label hiển thị)Mô tảNguồnGhi chú
Tổng số đơnSố đơn prepaid NV đã bánmv_prepaid_staff_stats.order_count
Tổng Doanh thuDoanh thu từ đơn NV bánmv_prepaid_staff_stats.revenue
Tổng Hoa hồngHoa hồng đã nhậnmv_prepaid_staff_stats.commission
KH mớiKH mới NV mang vềmv_prepaid_staff_stats.new_customers
Trend Doanh thu 6 thángLine chart DT theo tháng vs TB hệ thốngAggregated từ MV theo thángĐường xanh = NV, đường xám = TB
Loại thẻ hay bánThẻ NV bán nhiều nhấtMODE(prepaid_card.name) per staff
Giờ bán tốtKhung giờ NV bán được nhiều đơn nhấtextract(hour FROM order.paid_at)
KH tái nạp qua NVSố KH quay lại mua lần 2+ qua NV nàyDerivedĐánh giá chất lượng quan hệ KH

Bảng đơn đã bán (paginated):

Cột (label header)Mô tảNguồn
Mã đơnLink chi tiết đơnorder.code
Ngày thanh toánNgày thanh toánorder.paid_at
Khách hàngTên khách hàngcustomer.display_name
Tên thẻTên thẻ/góiprepaid_card.name
Doanh thuDoanh thu đơninvoice.amount
Hoa hồngHoa hồng đơntransaction_request_user.amount

16.19 Định nghĩa “KH VIP”

KH VIP được xác định khi thỏa ít nhất 1 điều kiện:

  • Tổng nạp lũy kế ≥ 20 triệu VND (total_paid >= 20000000)
  • Số lần nạp ≥ 5 (order_count >= 5)
  • Phân khúc “Hoạt động” + Dư ví ≥ 5 triệu

Lưu ý: Ngưỡng VIP có thể cấu hình (Phase 2 lưu vào settings). Hiện tại hardcode giá trị mặc định. Hiển thị badge “VIP” bên cạnh tên KH trên các bảng.

16.20 Phân khúc khách hàng — Định nghĩa chi tiết

Phân khúcĐiều kiệnTooltip cho userHành động gợi ý (hiện trên UI)
🟢 KH Hoạt độngSử dụng ví trong 30 ngày gần nhất"KH đang tích cực sử dụng dịch vụ và thanh toán bằng ví. Đây là nhóm KH tốt nhất."Upsell thẻ lớn hơn, giới thiệu dịch vụ mới
🟡 KH Ngủ đôngKhông dùng ví 30–60 ngày, còn dư ví > 0"KH chưa quay lại 1–2 tháng nhưng vẫn còn tiền trong ví. Cần nhắc nhở trước khi mất KH."Gửi SMS/ZNS nhắc nhở, ưu đãi kích hoạt
🔴 KH Rủi ro mấtKhông dùng ví > 60 ngày, còn dư ví > 0"KH đã lâu không sử dụng dịch vụ nhưng vẫn còn tiền ví. Nguy cơ mất KH cao — cần hành động ngay."Gọi điện trực tiếp, ưu đãi đặc biệt, gán NV chăm sóc
🔵 KH MớiLần đầu mua thẻ trong kỳ xem"KH mới mua thẻ trả trước lần đầu. Cần chào đón và hướng dẫn sử dụng để tạo ấn tượng tốt."Gửi tin chào đón, hướng dẫn dùng ví, booking lần đầu

16.21 Mức độ cảnh báo — Định nghĩa

Mức độĐiều kiện mặc địnhNgưỡng backendTooltipIcon
🔴 Khẩn cấpĐơn quá hạn > 30 ngày, hoặc doanh thu giảm > 30%_overdue_critical_days = 30, _revenue_drop_critical = 0.30"Cần xử lý NGAY. Ảnh hưởng trực tiếp đến tài chính hoặc vận hành."Badge đỏ + số lượng
🟡 Cảnh báoKH > 60 ngày không dùng (còn dư > 1tr), DT giảm 15-30%, đơn quá hạn 15-30 ngày_overdue_warning_days = 15, _revenue_drop_warning = 0.15, _inactive_warning_days = 60"Cần theo dõi và lên kế hoạch xử lý trong tuần."Badge vàng + số lượng
🔵 Theo dõiKH VIP (xem 16.19) > 90 ngày không hoạt động, thẻ giảm ≥ 30% lượng bán so với kỳ trước_inactive_watch_days = 90"Thông tin cần biết, không cần hành động ngay."Badge xanh + số lượng

16.22 Tooltip Filter Bar (Shared)

Element (label)Tooltip
Chọn chi nhánh"Chọn các CN bạn muốn xem báo cáo. Mặc định hiện CN bạn có quyền. Nhân viên chỉ xem CN đang làm; QL CN chỉ xem CN mình quản; QL vùng/Admin xem nhiều CN."
Khoảng thời gian"Khoảng ngày để tính KPI. Mặc định 'Tháng này'. Tối đa 1 năm (365 ngày). Dữ liệu so sánh kỳ trước được tính tự động (kỳ này N ngày → kỳ trước = N ngày liền kề trước đó)."
Tìm kiếm (Tra cứu nhanh)"Tìm theo tên KH, SĐT, mã đơn (PO-xxxx), tên NV. Tối thiểu 2 ký tự. Click kết quả để chuyển sang sub-tab tương ứng."

16.23 Tooltip Segment Cards (Sub-tab Khách hàng — Section 5.1)

Segment (label)Tooltip
🟢 KH Hoạt động"Khách có dùng ví trong 30 ngày gần nhất. Đây là nhóm tiếp tục mang doanh thu — duy trì chăm sóc, gợi ý dịch vụ mới."
🟡 KH Ngủ đông"Khách không dùng ví 30–60 ngày, vẫn còn dư ví. Cần nhắc nhở qua SMS/ZNS để kích hoạt lại trước khi rơi sang Rủi ro mất."
🔴 KH Rủi ro mất"Khách không dùng ví > 60 ngày, vẫn còn dư ví. Spa đang giữ tiền của KH (nợ phải trả) nhưng KH không quay lại — cần gọi điện trực tiếp."
🔵 KH Mới"Khách lần đầu mua thẻ trả trước trong khoảng thời gian đang xem. Cần chào đón, hướng dẫn sử dụng ví."
Dư ví (dòng phụ)"Tổng dư ví của nhóm = nợ phải trả của spa cho nhóm KH này. VD: 456 KH ngủ đông đang giữ tổng 2.1 tỷ trong ví — nếu họ không quay lại thì spa vẫn đang nợ họ số tiền này."
% tổng"Tỷ lệ trên tổng KH có thẻ trả trước trong kỳ xem = (Số KH nhóm này / Tổng KH có thẻ trả trước) × 100%."

16.24 Tooltip Chỉ số hành vi khách hàng (Sub-tab Khách hàng — Section 5.5)

Đổi từ "CLV Bar" (4 metrics) → 3 metrics behavior-based (AOV + Tỷ lệ tái nạp + Chu kỳ TB).

Trường (label)Tooltip
Giá trị đơn hàng trung bình"Average Order Value (AOV) — Doanh thu TB mỗi đơn mua thẻ. Công thức: Tổng tiền thu vào / Số đơn. VD: 1.000 đơn, Tổng thu 2,4 tỷ → AOV = 2,4 tr/đơn. Tăng AOV = KH chi mạnh hơn, mua thẻ giá trị lớn (chiến dịch upsell hiệu quả). Giảm AOV = chuyển dịch sang thẻ giá trị nhỏ — cần kiểm tra mix sản phẩm."
Tỷ lệ tái nạp"Tỷ lệ KH mua thẻ trả trước ≥ 2 lần / Tổng KH có thẻ trả trước trong kỳ. VD: 1.000 KH có thẻ, 680 mua ≥ 2 lần → 68%. Cao = KH trung thành; thấp = cần campaign giữ chân."
Chu kỳ trung bình"Khoảng cách TB giữa 2 lần KH mua thẻ liên tiếp. Tính trung bình theo khách (mean of means — ref DEC-T09): mỗi KH có ≥2 lần nạp đóng góp 1 giá trị chu kỳ TB của riêng họ, hệ thống AVG các giá trị đó. Loại khoảng > 180 ngày (reactivation). VD: KH mua lần 1 ngày 01/03, lần 2 ngày 25/03 → chu kỳ = 24 ngày. Dùng để chọn timing gửi nhắc nhở: gửi ưu đãi tái nạp trước khi hết chu kỳ ~5 ngày."

Tỷ lệ rời bỏ (Churn) — không hiển thị tooltip vì metric này đã bỏ khỏi UI (DEC ngày 2026-05-04). Marketing dùng phân khúc "🔴 Rủi ro mất" ở Segment Cards (Section 5.1) để biết churn signal. Formula churn vẫn giữ trong A9 Glossary (prd.md) làm reference cho Phase 3+.

16.25 Quy tắc đặt label (canonical)

Quy tắc bắt buộc cho FE/BE khi triển khai. Ref: prd.md A9 Glossary.

  1. Header bảng / KPI label: luôn viết đầy đủ, không viết tắt. Khớp 100% với A9 Glossary.
  2. Cell body / wireframe ASCII: được phép rút gọn để tiết kiệm chỗ, KÈM tooltip giải thích.
  3. Tránh dùng "CN" cho cả "Chi nhánh" và "Cố định" — Cố định luôn viết đầy đủ; CN chỉ dùng cho Chi nhánh.
  4. Khẩu ngữ ("xài", "dùng cuối") → dùng văn bản chuẩn ("sử dụng", "lần dùng ví cuối").
  5. Tooltip 3-phần: (1) Định nghĩa ngắn → (2) Công thức / điều kiện → (3) Ví dụ số.

17. QA Test Cases — Nạp linh hoạt (Flexible Card)

Hệ thống có 2 loại nạp tiền: Thẻ cố định (prepaid_card.flexible = false) và Nạp linh hoạt (prepaid_card.flexible = true). Các test cases dưới đây đảm bảo tính năng linh hoạt được cover đầy đủ.

17.1 Filter & Data

TC IDMô tảBước thực hiệnKết quả mong đợi
TC-FLEX-01Filter loại thẻ = “Linh hoạt”Sub-tab Giao dịch → Filter “Loại thẻ” → chọn “Linh hoạt”Bảng chỉ hiển thị đơn có prepaid_card.flexible = true. Summary bar cập nhật đúng (tổng đơn, thu, nạp ví, nợ, HH chỉ tính flexible).
TC-FLEX-02Filter loại thẻ = “Cố định”Sub-tab Giao dịch → Filter “Loại thẻ” → chọn “Cố định”Bảng chỉ hiển thị đơn có prepaid_card.flexible = false. Không có dòng nào tag “Linh hoạt”.
TC-FLEX-03Filter loại thẻ = “Tất cả”Sub-tab Giao dịch → Filter “Loại thẻ” → chọn “Tất cả”Bảng hiển thị cả 2 loại, cột “Loại” hiện tag đúng màu (xanh/tím).
TC-FLEX-04URL sync card_typeTruy cập URL ?card_type=flexible trực tiếpFilter tự động chọn “Linh hoạt”, bảng chỉ hiện đơn flexible.

17.2 Hiển thị UI

TC IDMô tảBước thực hiệnKết quả mong đợi
TC-FLEX-05Tag màu cột “Loại”Xem bảng giao dịch có cả 2 loạiCố định = QBadge xanh ($primary tint), Linh hoạt = QBadge tím ($purple-6).
TC-FLEX-06Tên thẻ flexible có số tiềnTạo đơn nạp linh hoạt 7,500,000đ → xem bảng giao dịchCột “Tên thẻ” hiển thị “Nạp linh hoạt 7,500,000đ”. Nếu prepaid_card.name có giá trị (VD: “Gói tùy chọn”) → hiển thị “Gói tùy chọn (linh hoạt 7,500,000đ)”.
TC-FLEX-07Tên thẻ cố định bình thườngXem đơn thẻ cố định VIP 20trCột “Tên thẻ” hiển thị “VIP 20tr” (tên từ prepaid_card.name), không kèm suffix.
TC-FLEX-08KPI “Thẻ đã bán” có breakdownSub-tab Tổng quan → xem KPI “Thẻ đã bán”Giá trị chính: tổng (VD: “156”). Dòng phụ bên dưới: “120 Cố định + 36 Linh hoạt” (font 12px, gray-6).
TC-FLEX-09Donut chart phân bố mệnh giáSub-tab Tổng quan → xem Donut chartCác segment mệnh giá cố định (1tr, 5tr, 10tr, 20tr) + 1 segment riêng “Nạp linh hoạt” màu tím. Hover “Nạp linh hoạt” → tooltip: số thẻ, giá trị TB, khoảng min–max.
TC-FLEX-10Expanded row KH — Thẻ đã muaSub-tab Khách hàng → expand 1 KH có cả cố định + linh hoạtTab “Thẻ đã mua”: cột “Loại” hiện đúng tag cho từng dòng (Cố định xanh, Linh hoạt tím).

17.3 Export

TC IDMô tảBước thực hiệnKết quả mong đợi
TC-FLEX-11Export Excel có cột LoạiExport danh sách giao dịchFile Excel có cột “Loại” với giá trị “Cố định” hoặc “Linh hoạt” cho mỗi dòng.
TC-FLEX-12Export khi filter flexibleFilter “Linh hoạt” → ExportFile Excel chỉ chứa các đơn nạp linh hoạt. Cột “Tên thẻ” hiển thị đúng format “Nạp linh hoạt [số tiền]”.