Appearance
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 đổi | Section | Ả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ịch | FE, BE, QA |
Tooltip ROI KPI Card: 3 phân loại + edge case marketing_cost = 0 → "—" + tag "Chưa có budget" | §16.4 Tooltip Marketing | FE, 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ịch | FE, QA |
v3.15 — 14/05/2026
| Thay đổi | Section | Ả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 Table | BE, FE, QA |
V5 wallet balance source mở rộng: BE phải expose exact fields cho wallet_balance_diva và wallet_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 glossary | BE |
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/query | BE |
v3.14 — 12/05/2026
| Thay đổi | Section | Ả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ịch | BE, FE, QA |
| §16.6 Cột expanded row "Lịch sử sử dụng" — xoá section | §16.6 | BE, FE |
Component PrepaidAnalyticsTransactionRow.tsx: 3 sections → 2 sections, LOC 350 → 300 | Component table | FE |
⭐ 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 defs | All |
⭐ 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 function | BE, 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 codebase | Action |
|---|---|---|
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 enum | Derive từ paid_amount vs total: paid khi paid_amount = total, debt khi 0 < paid_amount < total, unpaid khi paid_amount = 0 | Tí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 aggregate | Dùng | Filter |
|---|---|---|
| Order-level (Tiền thu vào, total_collected) | Parent invoice | WHERE parent_id IS NULL AND canceled_at IS NULL |
| Payment method split (PTTT) | Parent invoice | WHERE parent_id IS NULL AND canceled_at IS NULL |
| DT ghi nhận (wallet_used — chỉ ví chính, ref DEC-B06) | Parent invoice | WHERE 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 invoice | WHERE 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— setprepaid_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ếpprepaid_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)
- 🛑 V1 BLOCKER — Customer filter rule — Đã đổi
FROM customer→FROM ecommerce_user euJOIN với prepaid orders. Codebase metadata KHÔNG cóaccount_typecột trênecommerce_user→ SQL hiện rely vào INNER JOIN vớiper_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 / viewreport_customer? - Cung cấp evidence: link Hasura metadata file hoặc query mẫu confirm rule đúng. KHÔNG được hardcode
account_typenếu chưa có bằng chứng.
- 🔍 Ví Diva / Ví KM split —
order_itemcó field riêngprepaid_value_into_wallet_promotionkhông, hay deriveVí KM = Tổng nạp ví − Tiền thu vào? - 🔍 NV thu ngân (cột 9 bảng Giao dịch dòng 2) — field source:
order.created_by?invoice.created_by?cashier_idriêng? Nếu chưa có → bỏ dòng 2 cột Nhân viên - 🔍
prepaid_card_viewcolumns — verify cóflexible,name,id(cho join). Nếu thiếu → extend view - 🔍 Wallet balance source — alert function dùng
wallet_balancetable — 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:37—prepaid_type_flexiblebị 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_cardJOIN 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 |
| Marketing | Hiệ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ủ spa | Tổ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 |
|---|---|
| ecommerce | order, order_item, invoice, order_commission, prepaid_card, customer, branch, ecommerce_user, campaign |
| wallet | transaction_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 (filterpayment_method_id='wallet', parent only) VÀ Ví KM ĐÃ DÙNG (filterpayment_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_amountvới filterpayment_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ênLưu ý router Phase 1: chỉ register 4 routes P1 trong
routes.ts. KHÔNG khai báo/marketingvà/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-grained —
hasActionPermission('report.prepaid_analytics', 'view')cho visibility,hasActionPermission(..., 'export')cho export buttons,hasActionPermission(..., 'view_full_phone')cho unmask SDT (xem Section 2.5). LegacyglobalStore.reportRoleschỉ 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
QSelectchứ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ởQDatecalendar. 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 actions —
view+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 (
hasActionPermissionvshasModulePermission) chưa được Security Lead confirm. Snippet dưới dùng tênhasActionPermission— 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ùngcustomer.view_full_phonechung) - Hasura permission rule trên
mv_prepaid_customer_stats: dùng computed columnphone_number_maskedcho role không có actionview_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
exportcover 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
| State | Xử lý |
|---|---|
| Loading | Skeleton loader cho KPI cards + charts. Table hiển thị shimmer rows. |
| Empty | Hiển thị message “Không có dữ liệu trong khoảng thời gian này” + gợi ý điều chỉnh filter |
| Error | Toast 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 / Stale | Hiể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ác | So sánh kỳ trước |
|---|---|---|
| Tiền thu vào | SUM(invoice.amount) WHERE invoice.canceled_at IS NULL AND order.order_kind=‘prepaid’ | ↑↓ % |
| DT ghi nhận | SUM(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 + km | Giá 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ác | So sánh kỳ trước |
|---|---|---|
| Thẻ đã bán | SUM(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ạp | COUNT(DISTINCT customer WHERE prepaid_order_count >= 2) / COUNT(DISTINCT customer WHERE prepaid_order_count >= 1) × 100 | ↑↓ % |
| KH mới | COUNT(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)
| Chart | Loại | Dữ liệu |
|---|---|---|
| Doanh thu theo thời gian | Line chart | Tiền thu vào theo ngày/tuần/tháng + đường nét đứt kỳ trước |
| So sánh khu vực | Grouped 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ẻ | Donut | Tỷ 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 trend | Area chart | Tỷ 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:
- Tính
trend_valuebằng cách so sánh count kỳ này vs kỳ trước (sliding window theolast_7dcho overdue/inactive/vip_inactive, FORMULA-019 cho revenue_drop) - Return
sla_labelhardcoded theo severity (mappingseverity → sla_label) - Return
suggested_action_keymapping theotype - Return
sort_overridemapping theotype(overdue →overdue DESC, inactive →last_wallet_usage_at ASC, vip_inactive →total_paid DESC, card_sales_drop → null (dùng defaultpaid_at DESCcủa Sub-tab Giao dịch)) - 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_flowkhô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.
| Filter | Giá trị | Implementation |
|---|---|---|
| Loại thẻ | Tất cả · Cố định · Linh hoạt | prepaid_card_view.flexible |
| Trạng thái thanh toán | Tất cả · Đã thanh toán đủ · Còn nợ · Chưa thanh toán | Derive paid_amount vs total |
| ❌ Removed (DEC-U12) | Dùng shared q ở usePrepaidAnalyticsFilter().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 / Formula | Notes |
|---|---|---|---|
| 1 | Tổng đơn | COUNT(DISTINCT order.id) WHERE filter | Số đơn prepaid trong kỳ + filter |
| 2 | Tổng thu | SUM(invoice.amount) WHERE canceled_at IS NULL | = Tiền thu vào |
| 3 | Tổng nạp ví | SUM(order_item.prepaid_value_into_wallet) ⚠️ KHÔNG × quantity (Section 0.2 Rule 1) | = Ví Diva + Ví KM |
| 4 | Ví Diva ⭐ | ⏳ PROVISIONAL (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). | |
| 5 | Ví KM ⭐ | ⏳ PROVISIONAL (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). | |
| 6 | Tổng nợ | SUM(order.total − order.paid_amount) WHERE paid_amount < total | Công nợ chưa thu |
| 7 | Tổng hoa hồng ⭐ DEC-T08 | SUM(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.getValueIntoWallet→payment_order.go→invoice_insert_update.go.- Sau khi V9-V10 confirmed → update MV
mv_prepaid_order_dailythêm 2 cộttotal_vi_diva_napped,total_vi_km_napped(JOIN sang invoice vớiparent_id IS NULL).- Trước khi V9-V10 confirmed: 2 cột này có thể stub
NULLhoặ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) | Format | Source |
|---|---|---|---|
| 1 | Mã đơn | Link TT-XXXXXXXX | order.code |
| 2 | Ngày TT | Multi-line: HH:mm / DD/MM/YYYY | order.paid_at |
| 3 | Khách hàng | Multi-line: tên (link CRM) / SDT | customer.display_name + customer.phone_number |
| 4 | Tên thẻ | Badge [Cố định]/[Linh hoạt] inline + tên thẻ | prepaid_card.flexible (badge) + prepaid_card.name (text). Khi flexible=true và name rỗng: "Nạp linh hoạt {prepaid_value_into_wallet}" |
| 5 | SL | Number, center | order_item.quantity |
| 6 | Tiền thu | VND right, sortable | SUM(invoice.amount) per order |
| 7 | Nạp ví | VND right, sortable | order_item.prepaid_value_into_wallet (per item, đã là line total — Section 0.2 Rule 1) |
| 8 | Trạng thái | QBadge pill (xanh/vàng/đỏ) | Derived từ paid_amount vs total |
| 9 | Nhân viên | Multi-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 |
| 10 | Chi nhánh | Text | order.branch_id → branch.name |
Schema TBD — NV thu ngân: verify field source. Có thể là
order.created_by,invoice.created_by, hoặc field riêngcashier_id. Hỏi backend team trước khi implement.
Expanded row (2 section, lazy load):
- Chi tiết thanh toán: Query
invoice WHERE order_id = ?→ Phương thức · Số tiền · Ngày (multi-lineHH:mm DD/MM/YYYY) · Trạng thái - Hoa hồng ⭐ DEC-T08: Query
invoice_commission WHERE order_id = ? AND invoice_status = 'invoice_completed'→ Nhân viên (user_id→ecommerce_user.display_name) · Số tiền (amount). Nếu đơn có refund: union vớitransaction_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_requestchỉ cóorder_id(= đơn dịch vụ đang tiêu ví), KHÔNG có fieldprepaid_card_id/source_prepaid_order_id/from_prepaid_order_id- Topup wallet khi mua thẻ: tạo
invoice, KHÔNG tạotransaction_request(fileecommerce-api/action/order_confirm.go)- Consume wallet: FIFO từ pool chung (
wallet.amount), không cówallet_topup_lotKế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.
4.4 Quick Links
| Click | Điều hướng |
|---|---|
| Mã đơn | /e/prepaid-order/:id |
| Tên Khách hàng | CRM profile |
| Tên Nhân viên | Sub-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 động | last_wallet_usage_at >= now() - interval '30 days' | Upsell thẻ lớn hơn |
| 🟡 Ngủ đông | last_wallet_usage_at BETWEEN now()-60d AND now()-30d AND wallet_balance > 0 | Gửi nhắc nhở, ưu đãi kích hoạt |
| 🔴 Rủi ro mất | last_wallet_usage_at < now() - 60d AND wallet_balance > 0 | Gọi điện, ưu đãi đặc biệt |
| 🔵 Mới | first_prepaid_order_at trong kỳ xem | Chà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:
- Danh sách thẻ/gói đã mua: Loại thẻ, tên thẻ, CN mua, ngày, giá trị, trạng thái
- 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)
- 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_idWHEREtype='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_phonepermission (BE enforce — không trust FE) - Audit log vào
export_jobtable (xem Section 9.x)
Out of scope (KHÔNG implement trong Analytics):
- ❌
notification-apiintegration cho bulk SMS/ZNS - ❌ Gán NV chăm sóc bulk
- ❌
selectionprop 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ạp | COUNT(DISTINCT customer WHERE prepaid_order_count >= 2) / COUNT(DISTINCT customer WHERE prepaid_order_count >= 1) × 100 | % |
| Chu kỳ trung bình | Customer-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 definition | ngà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ị) | Source | Công thức | V-ref |
|---|---|---|---|
| Tiền thu vào | invoice.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_amount | SUM WHERE paid_amount < total_amount | ✅ LOCKED |
| DT ghi nhận ⏳ | invoice.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ụng ⏳ | invoice.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 usage | SUM WHERE parent_id IS NULL AND payment_method_id='wallet_promotion' AND canceled_at IS NULL | ⏳ V12 PROVISIONAL |
| Hoa hồng ⭐ DEC-T08 | invoice_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ạp ⏳ | SUM(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ộp ⏳ | Derived sau khi V10 confirmed | Tiề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_dailyhiện đang wrap trong🔒 PROVISIONAL — DO NOT IMPLEMENTblock (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_commissionJOINorder(quaorder_id) WHEREinvoice_status = 'invoice_completed'ANDorder_kind = 'prepaid'. Refund track riêng quatransaction_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:
invoiceGROUP 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:
orderWHERE 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ình100% ≤ ROI ≤ 200%| KémROI < 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:
orderWHERE 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ó đơn | COUNT DISTINCT seller / Tổng NV × 100% |
| Số đơn TB / NV | Tổng số đơn / Số NV có đơn |
| Doanh thu TB / NV | Tổng Doanh thu / Số NV có đơn |
| Tổng Hoa hồng đã chi | SUM 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.
| MV | Phase | Mục đích | GROUP BY | Refresh | Est. size |
|---|---|---|---|---|---|
mv_prepaid_order_daily ⭐ | P1 | KPIs Tổng quan + Tài chính (order-level metrics) | date, branch_id, region_id | 15 min | ~150K rows |
mv_prepaid_card_daily ⭐ | P1 | Chart "Phân bố mệnh giá" + Top thẻ bán chạy (card-level metrics) | date, branch_id, product_id | 15 min | ~500K rows |
mv_prepaid_customer_stats | P1 | Sub-tab Khách hàng | customer_id | 30 min | ~100K rows |
mv_prepaid_finance_daily | P1 | Tab Tài chính (theo PTTT) | date, branch_id, payment_method_id | 15 min | ~500K rows |
mv_prepaid_staff_stats | P3+ ⏳ | Nhân viên | user_id, branch_id | 30 min | ~50K rows |
mv_prepaid_campaign_stats | P3+ ⏳ | Marketing | campaign_id, branch_id | 30 min | ~10K rows |
⚠️ Thay đổi v3.0 (review L4 fix double-count): MV cũ
mv_prepaid_summary_dailygroup 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ảototal_collectedkhông bị nhân với sốorder_itemmv_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.đã loại bỏ (DEC-U04).mv_prepaid_branch_flow
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 theoproduct_idđể tránh double-counttotal_collectedkhi 1 order có nhiềuorder_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:
parent_invoice.reference_amountcủa prepaid order = giá trị Ví Diva nạp (theousePrepaidOrderItem.getValueIntoWallet)?parent_invoice.wallet_promotion_amountcủa prepaid order = giá trị Ví KM nạp lúc bán?- Có cần lọc thêm
payment_method_idkhông (ví dụ chỉpayment_method_id IN ('cash','bank','wallet')cho phần nạp, excludewallet_promotion)?Nếu BE phát hiện schema khác (VD: có field
prepaid_value_into_wallet_main/prepaid_value_into_wallet_promotiontrênorder_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):
sqlSUM(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ỏ
× quantitytrênprepaid_value_into_wallet(Section 0.2 Rule 1)- Tách per-order CTE để tránh double-count
total_paidkhi 1 order có nhiềuorder_item- Đổi
FROM customer c→ 🔍 verify table (codebase dùngecommerce_usercho 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_daystrong 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ằnglast_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/$tocủ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_metricsnhậnfrom,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 WHEREcycle_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 BYpayment_method_id→ khi 1 order có nhiều invoice với phương thức khác nhau,wallet_topupbị 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_topuplấ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_invoicecủa orderorder_kind='prepaid'- Phần ĐÃ DÙNG (FORMULA-005 + 005b — V11/V12): từ
invoice.reference_amountfilter theopayment_method_idSau khi BE confirm V9-V13, MV này phải refactor: bỏ cột
promo_amounttừ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_commissionnằm trong ecommerce schema (cùng schema vớiorder,invoice) — KHÔNG cần cross-schema querytransaction_request(cho refund_commission) nằm trong wallet schema. Cùng PG instance nên truy xuất được bằngwallet_schema.transaction_request- Trước đây spec dùng
transaction_request_user(wallet) — đã chuyển sanginvoice_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 optimization9.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 connectionsHasura connect qua PgBouncer thay vì trực tiếp PostgreSQL.
9.7 Caching 3 tầng
| Tầng | Công nghệ | TTL | Áp dụng |
|---|---|---|---|
| Hasura response cache | Hasura annotation | 5-15 min | KPI cards, charts |
| Redis cache | Redis 7 (đã có) | 10 min | Aggregate nặng (tổng dư ví toàn hệ thống — query wallet DB) |
| Frontend cache | URQL cache-and-network | Realtime | Hiể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 gianNgưỡ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ạy1678865967129_region_branch/up.sql - Cột
branch.region_id— đã có FK đếnregion_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. Tra cứu nhanh — Global Search
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 DT10.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
| # | Migration | Phase | Database | Nội dung |
|---|---|---|---|---|
| 1 | create_branch_region | — | — | ❌ KHÔNG TẠO — region_branch + branch.region_id đã có sẵn (Section 9.10). Chỉ chạy data check |
| 2 | create_mv_prepaid_order_daily | P1 | ecommerce | MV + UNIQUE INDEX (xem SQL Section 9.2) |
| 3 | create_mv_prepaid_customer_stats | P1 | ecommerce | MV + segment logic (xem SQL Section 9.2) |
| 4 | create_mv_prepaid_finance_daily | P1 | ecommerce | MV + UNIQUE INDEX |
| 5 | create_indexes_prepaid_report | P1 | ecommerce + wallet | Composite + partial indexes (Section 9.3). Phase 1 KHÔNG cần GIN index pg_trgm — chỉ thêm khi build Global search Phase 2 |
| 6 | create_compute_prepaid_alerts | P1 | ecommerce | Function alert (Section 9.9) |
| 7 | create_export_job_table | P1 | ecommerce | Bảng theo dõi export async |
| 8 | create_search_prepaid_global | P2 ⏳ | ecommerce | Function tra cứu nhanh global (Section 10.2) — defer Phase 2 |
| 9 | create_mv_prepaid_staff_stats | P3+ ⏳ | ecommerce | MV (cross-schema ref wallet) — defer Phase 3+ |
| 10 | create_mv_prepaid_campaign_stats | P3+ ⏳ | ecommerce | MV + 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.viewmới được SELECT - branch_id filter theo
X-Hasura-Branch-Idcho non-admin - SDT column trong
mv_prepaid_customer_stats: dùng computed columnphone_number_maskedcho role không cóview_full_phone
- role có
- 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
| Query | Phase | Sub-tab | Pagination |
|---|---|---|---|
PrepaidAnalyticsOverview | P1 | Tổng quan | No (aggregate from MV) |
PrepaidAnalyticsAlerts | P1 | Tổng quan | No (function call) |
PrepaidAnalyticsTransactions | P1 | Giao dịch | Keyset cursor (flat list, ref: DEC-U04) |
PrepaidAnalyticsTransactionDetail | P1 | Giao dịch (expand) | No (lazy, single order) |
PrepaidAnalyticsCustomers | P1 | Khách hàng | Keyset cursor |
PrepaidAnalyticsCustomerDetail | P1 | Khách hàng (expand) | No (lazy, single customer) |
PrepaidAnalyticsFinanceSummary | P1 | Tà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) |
PrepaidAnalyticsDebtList | P1 | Tài chính (Công nợ) | Keyset cursor |
PrepaidAnalyticsCommissions | P1 | Tài chính (Hoa hồng) | Offset (small set) |
PrepaidAnalyticsPaymentMethods | P1 | Tài chính (PTTT) | No (small set) |
GetWalletBalanceAggregate | P1 | Tổng quan + Tài chính | No (Redis cached, from wallet DB) |
SearchPrepaidGlobal | P2 ⏳ | Tra cứu nhanh | No (limit 5/type) |
PrepaidAnalyticsCampaigns | P3+ ⏳ | Marketing | Offset (small set) |
PrepaidAnalyticsAffiliates | P3+ ⏳ | Affiliate | Offset (small set) |
PrepaidAnalyticsStaffRanking | P3+ ⏳ | Nhân viên | Offset (top N) |
PrepaidAnalyticsStaffDetail | P3+ ⏳ | NV chi tiết | No (single) |
PrepaidAnalyticsStaffBranch | P3+ ⏳ | NV so sánh CN | No (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
| Component | Phase | Type | Est. lines |
|---|---|---|---|
| PrepaidCardAnalytics.tsx | P1 | Parent page | 200 |
| PrepaidAnalyticsFilter.tsx | P1 | Shared filter + branch selector | 300 |
| PrepaidAnalyticsOverview.tsx | P1 | Sub-tab 1 page | 500 |
| PrepaidAnalyticsOverviewCards.tsx | P1 | KPI cards (8) | 300 |
| PrepaidAnalyticsOverviewCharts.tsx | P1 | 4 Charts + toggle | 400 |
| PrepaidAnalyticsOverviewAlerts.tsx | P1 | Alert box 3 levels | 200 |
| PrepaidAnalyticsOverviewRankings.tsx | P1 | 3 Mini tables | 300 |
| PrepaidAnalyticsTransactions.tsx | P1 | Sub-tab 2 page | 400 |
| PrepaidAnalyticsTransactionRow.tsx | P1 | Expandable row (2 sections — Chi tiết TT + Hoa hồng; DEC-T07) | 300 |
| PrepaidAnalyticsCustomers.tsx | P1 | Sub-tab 3 page | 400 |
| PrepaidAnalyticsCustomerRow.tsx | P1 | Expandable row (3 sections) | 400 |
| PrepaidAnalyticsCustomerSegments.tsx | P1 | 4 Segment cards | 200 |
| PrepaidAnalyticsFinance.tsx | P1 | Sub-tab 4 page + 4 tab con | 250 |
| PrepaidAnalyticsFinanceRevenue.tsx | P1 | Tab con Tổng hợp DT | 300 |
| PrepaidAnalyticsFinanceDebt.tsx | P1 | Tab con Công nợ | 300 |
| PrepaidAnalyticsFinanceCommission.tsx | P1 | Tab con Hoa hồng | 250 |
| PrepaidAnalyticsFinancePayment.tsx | P1 | Tab con Phương thức thanh toán | 250 |
| PrepaidAnalyticsGlobalSearch.tsx | P2 ⏳ | Tra cứu nhanh | 200 |
| PrepaidAnalyticsMarketing.tsx | P3+ ⏳ | Sub-tab 5 page + 4 tab con | 250 |
| PrepaidAnalyticsMktCampaigns.tsx | P3+ ⏳ | Tab con Chiến dịch | 400 |
| PrepaidAnalyticsMktAffiliate.tsx | P3+ ⏳ | Tab con Affiliate | 250 |
| PrepaidAnalyticsMktSources.tsx | P3+ ⏳ | Tab con Nguồn KH | 250 |
| PrepaidAnalyticsMktCompare.tsx | P3+ ⏳ | Tab con So sánh kênh | 250 |
| PrepaidAnalyticsStaff.tsx | P3+ ⏳ | Sub-tab 6 page + 3 tab con | 250 |
| PrepaidAnalyticsStaffRanking.tsx | P3+ ⏳ | Tab con Xếp hạng | 350 |
| PrepaidAnalyticsStaffDetail.tsx | P3+ ⏳ | Tab con Chi tiết NV | 400 |
| PrepaidAnalyticsStaffBranch.tsx | P3+ ⏳ | Tab con So sánh CN | 300 |
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 + PiniausePrepaidExport()— composable tạo export job + theo dõi progressprepaidAnalyticsRoutes— 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
| Metric | Target | Strategy |
|---|---|---|
| Tổng quan load | < 500ms | MV pre-aggregated + Hasura cache |
| Bảng giao dịch (20 rows) | < 300ms | Keyset pagination + composite index |
| Expand row | < 200ms | Lazy load, indexed queries |
| Tra cứu nhanh | < 300ms | pg_trgm GIN index + % operator, debounce 300ms |
| Export 50K rows | < 60s | Server-side chunked, async |
| MV refresh | < 30s each | CONCURRENT, staggered cron |
| Branch filter render | < 100ms | Grouped tree, search, max 70 items |
| Chart render | < 500ms | Pre-aggregated data, Chart.js |
| Alert computation | < 500ms | Function với indexed queries |
14. Risks & Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| MV refresh conflict với query | User 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ớn | Query chậm | Partial indexes + MV + keyset pagination |
| Export OOM | Server crash | Chunked streaming + file size limit |
| Branch không có region | Không nhóm được KV | Pre-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ậm | MV refresh chậm | Derive wallet usage từ invoice (ecommerce) thay vì query wallet DB |
| Campaign không có budget field | Không tính ROI chính xác | Tạm tính ROI từ HH affiliate, ghi chú trên UI |
| New tab có bug | User không dùng được | Feature flag FEATURE_PREPAID_ANALYTICS_V2 → rollback 1 dòng |
prepaid_card không có expiry | Khô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-time | MV không đủ | Query trực tiếp wallet DB + Redis cache (TTL 10min) |
15. Dependencies & Assumptions
Dependencies:
region_branchtable (đã có sẵn — không cần migration)pg_trgmextension (đã có)- export-api service (đã có, cần bổ sung endpoint)
- notification-api (đã có, dùng cho async export)
- Chart.js +
vue-chart-3(đã có trongpackage.json) - URQL + Hasura (đã có)
- Redis (đã có, dùng cho
wallet_balancecache) - 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) ordertable có các field:customer_id,branch_id,campaign_ids,order_kind,paid_at,paid_amount,totalinvoicetable có:reference_amount(⏳ V9/V11/V12 — Ví Diva NẠP khi parent của prepaid order, Ví Diva/KM ĐÃ DÙNG khi filterpayment_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_requestcó:order_id,branch_id,customer_id,behavior_idorder_commissionliê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_cardKHÔNG có expiry fieldROUTE_PREPAID_cARD_REPORT_GROUPgiữ 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ĩa | Nguồn dữ liệu | Ví dụ |
|---|---|---|---|
| Tiền thu vào ✅ V8 | Số tiền thực tế KH trả (tiền mặt + chuyển khoản + quẹt thẻ) khi mua thẻ trả trước | SUM(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í KH ✅ | Giá 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 ⏳ V9 | Phầ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 value | SUM(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 ⏳ V10 | Phầ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 thu | SUM(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 ⏳ V11 | Số 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 ⏳ V12 | Phầ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í ⏳ V5 | Tổ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 + km | 5000 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-T08 | Tiề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êng | Gross: 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 ⏳ V10 | Phầ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 thu | transaction_request WHERE behavior_id = 'refund_order' | Hủy đơn 500K → Hoàn ví KH 500K |
| Hoàn hoa hồng (Refund Commission) ⭐ DEC-T08 | Khi đơn bị hủy → tạo transaction_request mới với behavior_id='refund_commission' và amount âm (ghi sổ thu hồi). KHÔNG đổi status invoice_commission gốc | SUM(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 V10 | Tiề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 confirmed | Thu 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) | Format | Mô tả | Nguồn |
|---|---|---|---|---|
| 1 | Mã đơn | Link 1 dòng | Mã định danh đơn hàng (VD: TT-1232MNGA) | order.code → link /e/prepaid-order/:id |
| 2 | Ngày TT | Multi-line: HH:mm / DD/MM/YYYY | Ngà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 |
| 3 | Khách hàng | Multi-line: tên / SDT | Tên KH (link CRM) + Số điện thoại (text-secondary) | customer.display_name + customer.phone_number (masked theo role) |
| 4 | Tên thẻ | Badge inline + text | Badge [Cố định] (xanh) hoặc [Linh hoạt] (tím) + tên gói. Khi flexible=true và name 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) |
| 5 | SL | Number center | Số lượng thẻ trong đơn (thường = 1) | order_item.quantity |
| 6 | Tiền thu | VND right, sortable | Số tiền thực tế KH đã trả | SUM(invoice.amount) WHERE canceled_at IS NULL |
| 7 | Nạp ví | VND right, sortable | Giá 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) |
| 8 | Trạng thái | QBadge pill | Trạng thái thanh toán | Derived: 🟢 Đã thanh toán (paid_amount = total) / 🟡 Còn nợ (0 < paid_amount < total) / 🔴 Chưa thanh toán (paid_amount = 0) |
| 9 | Nhân viên | Multi-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_id → ecommerce_user.display_name. NV thu ngân: TBD — verify field source (order.created_by hoặc invoice.created_by hoặc cashier_id) |
| 10 | Chi nhánh | Text 1 dòng | CN bán thẻ (= CN mua) | order.branch_id → branch.name |
⚠️ Schema verification trước implement:
- 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.
- Ví Diva / Ví KM (sum cards 4-5): verify
order_itemcó field riêngprepaid_value_into_wallet_promotionkhông, hoặc derive từ ruleVí KM = Tổng nạp ví − Tiền thu.
Cột expanded row — Chi tiết thanh toán:
| Cột | Mô tả | Nguồn |
|---|---|---|
| Phương thức | Cách KH trả tiền (Tiền mặt / CK / Quẹt thẻ / Ví) | invoice.payment_method_id |
| Số tiền | Số tiền cho lần thanh toán này | invoice.amount |
| Ngày TT | Ngày thực hiện thanh toán | invoice.paid_at |
| Trạng thái | Đã thanh toán / Đã hủy | invoice.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ột | Mô tả | Nguồn |
|---|---|---|
| Nhân viên | NV nhận hoa hồng | invoice_commission.user_id → ecommerce_user.display_name |
| Số tiền HH | Hoa 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ại | Badge: Đã 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ồn | Ghi chú |
|---|---|---|---|
| Khách hàng | Tên KH, click để xem CRM | customer.display_name | Link CRM profile |
| SĐT | Số điện thoại | customer.phone_number | Masked: 0912***456 (trừ role có quyền) |
| Phân khúc | Nhóm KH tự động | Derived từ last_wallet_usage_at | Tag màu: 🟢 KH Hoạt động, 🟡 KH Ngủ đông, 🔴 KH Rủi ro mất, 🔵 KH Mới |
| Tổng tiền đã nạp | Tổ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 ⏳ V5a | Số dư ví chính hiện tại của KH | Exact 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 ⏳ V5b | Số dư ví khuyến mãi hiện tại của KH | Exact 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+V12 | Tổ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 usage | Lũy kế |
| % Đã dùng ví ⏳ V11+V12 | Tỷ 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 accounting | total_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 đã mua | COUNT(DISTINCT order.id) | Mua nhiều lần = KH trung thành |
| Lần dùng ví cuối | Ngày sử dụng ví gần nhất | MAX(transaction_request.created_at) WHERE type=‘T’ | Highlight đỏ nếu > 60 ngày |
| CN mua | CN mà KH mua thẻ nhiều nhất | MODE(order.branch_id) | Nếu mua nhiều CN → hiển thị CN nhiều nhất + "(+N CN)" |
| CN sử dụng | CN mà KH sử dụng dịch vụ nhiều nhất | MODE(transaction_request.branch_id) WHERE type=‘T’ | Quan trọng: có thể khác CN mua |
| Số CN đã dùng | Số CN khác nhau KH đã sử dụng | COUNT(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ột | Mô tả | Nguồn |
|---|---|---|
| Loại | Thẻ cố định hoặc Nạp linh hoạt | prepaid_card.flexible |
| Tên thẻ | Tên gói thẻ | prepaid_card.name |
| CN mua | Chi nhánh bán thẻ | order.branch_id → branch.name |
| Ngày mua | Ngày thanh toán | order.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ột | Mô tả | Nguồn |
|---|---|---|
| Tần suất | Số lần sử dụng ví / tháng | COUNT(transaction_request) WHERE type=‘T’ / số tháng |
| Dịch vụ hay dùng | Dịch vụ KH dùng ví thanh toán nhiều nhất | Từ order_item của đơn dịch vụ thanh toán bằng ví |
| Giờ hay đến | Khung giờ KH thường đặt lịch | Từ transaction_request.created_at → extract hour |
| CN hay đến | CN KH sử dụng dịch vụ nhiều nhất | MODE(transaction_request.branch_id) WHERE type=‘T’ |
Cột expanded row — Gợi ý hành động:
| Điều kiện | Gợ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ồn | Ghi chú |
|---|---|---|---|
| Thời gian | Ngày (DEC-U08 — KHÔNG có toggle Tuần/Tháng) | report_date từ mv_prepaid_order_daily | Sortable, format DD/MM/YYYY |
| Tiền thu vào | Tổ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 ⏳ V10 | Giá 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 | Chi phí spa bù thêm. Lấy thẳng từ field, KHÔNG derive |
| Hoa hồng ⭐ DEC-T08 | Tổ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 V10 | Tiền thu vào − Hoa hồng − KM đã nạp | Derived sau khi V10 confirmed (KM đã nạp lấy từ FORMULA-004 canonical) | KHÔNG phải lãi ròng |
| Số đơn | Số đơn hàng | SUM(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ồn | Ghi chú |
|---|---|---|---|
| Mã đơn | Mã đơn hàng | order.code | Link chi tiết đơn |
| Khách hàng | Tên KH | customer.display_name | Link CRM |
| Ngày mua | Ngày thanh toán đơn | order.paid_at | |
| Tổng đơn | Giá trị đơn hàng | order.total | |
| Đã trả | Số tiền KH đã thanh toán | order.paid_amount | |
| Còn nợ | Chênh lệch chưa trả | total - paid_amount | Highlight theo mức độ |
| Ngày quá hạn | Số 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ạn | Derived | Bình thường / Cảnh báo / Nghiêm trọng |
| Chi nhánh | Chi nhánh bán | order.branch_id → branch.name | |
| Nhân viên bán | Nhân viên bán | order_commission.user_id | |
| Hành động | Chỉ 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ồn | Ghi chú |
|---|---|---|---|
| Nhân viên | Tên NV bán | ecommerce_user.display_name | |
| Chi nhánh | CN của NV | branch.name | |
| Số đơn | Số đơn prepaid NV đã bán | COUNT(DISTINCT order.id) | |
| Doanh thu đơn bán | Tổng doanh thu từ đơn NV bán | SUM(invoice.amount) | |
| Hoa hồng ⭐ DEC-T08 | Tổ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 thu | Tỷ lệ hoa hồng trên doanh thu | Hoa hồng / Doanh thu × 100 | Dù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ồn | Ghi chú |
|---|---|---|---|
| Phương thức | Cách KH thanh toán khi mua thẻ | invoice.payment_method_id | Tiền mặt / Chuyển khoản / Quẹt thẻ / Ví / COD |
| Số đơn | Số đơn dùng phương thức này | COUNT(DISTINCT order.id) | |
| Số tiền | Tổng tiền thu qua phương thức | SUM(invoice.amount) | |
| % Tổng | Tỷ lệ trên tổng doanh thu | amount / SUM(amount) × 100 | Donut chart kèm |
| Trend | So sánh với kỳ trước | Derived | ↑↓ % 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ồn | Ghi chú |
|---|---|---|---|
| Tên chiến dịch | Tên chiến dịch | campaign.name | |
| Thời gian | Khoảng thời gian chạy | campaign.start_date → end_date | |
| Số đơn | Số đơ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 CD | COUNT WHERE campaign_ids @> id | Hiển thị cảnh báo nếu đơn thuộc > 1 CD |
| Doanh thu | Doanh thu từ chiến dịch | SUM(invoice.amount) | |
| KH mới | KH lần đầu mua thẻ qua chiến dịch | Derived | Chỉ tính KH chưa có đơn prepaid trước đó |
| Thẻ bán chạy | Loại thẻ bán nhiều nhất trong CD | MODE(prepaid_card.name) | |
| Khu vực hiệu quả | Khu vực có Doanh thu cao nhất từ CD | Derived 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ồn | Ghi chú |
|---|---|---|---|
| # | Thứ hạng | Derived (ORDER BY tiêu chí chọn) | |
| Nhân viên | Tên NV | ecommerce_user.display_name | |
| Chi nhánh | CN làm việc | branch.name | |
| Khu vực | Khu vực của CN | region_branch.name | |
| Số đơn | Số đơn đã bán | mv_prepaid_staff_stats.order_count | |
| Doanh thu | Tổng doanh thu | mv_prepaid_staff_stats.revenue | |
| Hoa hồng | Hoa hồng nhận | mv_prepaid_staff_stats.commission | |
| Doanh thu TB / đơn | Doanh thu trung bình mỗi đơn | revenue / order_count | Đánh giá chất lượng đơn (bán thẻ lớn vs nhỏ) |
| KH mới | Số KH mới NV mang về | mv_prepaid_staff_stats.new_customers | KH lần đầu mua thẻ qua NV này |
| Đánh giá | Hiệu suất so với TB | Derived | Xuấ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ồn | Ghi chú |
|---|---|---|---|
| Chi nhánh / Khu vực | Tên CN hoặc Khu vực (nhóm 2 cấp) | branch.name / region_branch.name | Collapsed = Khu vực, Expand = Chi nhánh |
| Tổng NV | Số NV thuộc CN/KV | COUNT(ecommerce_user) WHERE branch_id | |
| NV có đơn | Số NV đã bán ≥ 1 đơn prepaid | COUNT DISTINCT user_id từ MV | |
| % Tham gia | Tỷ lệ NV bán thẻ | NV có đơn / Tổng NV × 100 | Thấp = cơ hội đào tạo, thúc đẩy |
| Số đơn TB / NV | Đơn trung bình mỗi NV có bán | Tổng đơn / NV có đơn | |
| Doanh thu TB / NV | Doanh thu trung bình mỗi NV có bán | Tổng DT / NV có đơn | |
| Đánh giá | Nhận xét tự động | Derived | "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ồn | Ghi chú |
|---|---|---|---|
| Nguồn giới thiệu | Tên NV/KH/đối tác giới thiệu | order.ref_code → lookup | |
| Loại | NV nội bộ / KH cũ / Đối tác | Derived từ ref_code prefix hoặc user type | |
| Số đơn | Số đơn từ nguồn giới thiệu | COUNT(DISTINCT order.id) | |
| Doanh thu | Doanh thu từ nguồn | SUM(invoice.amount) | |
| KH mới | KH lần đầu mua qua nguồn | Derived | |
| Hoa hồng đã chi ⭐ DEC-T08 | Hoa 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ện | Tooltip |
|---|---|---|
| Chiến dịch | order.campaign_ids IS NOT NULL | “KH đến từ chiến dịch marketing (có gắn mã CD)” |
| Affiliate | order.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ạp | KH đã 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-in | Cò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ồn | Ghi chú |
|---|---|---|---|
| Kênh | Tê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ố đơn | Số đơn từ kênh | COUNT(DISTINCT order.id) | |
| Doanh thu | Doanh thu từ kênh | SUM(invoice.amount) | |
| Chi phí | Hoa hồng affiliate + budget chiến dịch (nếu có) | Derived | Ghi chú nếu thiếu budget |
| ROI | (Doanh thu − Chi phí) / Chi phí × 100% | Derived | Chỉ chính xác khi có đầy đủ chi phí |
| Đánh giá | Nhận xét tự động | Derived | "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ồn | Ghi chú |
|---|---|---|---|
| Tổng số đơn | Số đơn prepaid NV đã bán | mv_prepaid_staff_stats.order_count | |
| Tổng Doanh thu | Doanh thu từ đơn NV bán | mv_prepaid_staff_stats.revenue | |
| Tổng Hoa hồng | Hoa hồng đã nhận | mv_prepaid_staff_stats.commission | |
| KH mới | KH mới NV mang về | mv_prepaid_staff_stats.new_customers | |
| Trend Doanh thu 6 tháng | Line chart DT theo tháng vs TB hệ thống | Aggregated từ MV theo tháng | Đường xanh = NV, đường xám = TB |
| Loại thẻ hay bán | Thẻ NV bán nhiều nhất | MODE(prepaid_card.name) per staff | |
| Giờ bán tốt | Khung giờ NV bán được nhiều đơn nhất | extract(hour FROM order.paid_at) | |
| KH tái nạp qua NV | Số KH quay lại mua lần 2+ qua NV này | Derived | Đánh giá chất lượng quan hệ KH |
Bảng đơn đã bán (paginated):
| Cột (label header) | Mô tả | Nguồn |
|---|---|---|
| Mã đơn | Link chi tiết đơn | order.code |
| Ngày thanh toán | Ngày thanh toán | order.paid_at |
| Khách hàng | Tên khách hàng | customer.display_name |
| Tên thẻ | Tên thẻ/gói | prepaid_card.name |
| Doanh thu | Doanh thu đơn | invoice.amount |
| Hoa hồng | Hoa hồng đơn | transaction_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ện | Tooltip cho user | Hành động gợi ý (hiện trên UI) |
|---|---|---|---|
| 🟢 KH Hoạt động | Sử 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ủ đông | Khô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ất | Khô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ới | Lầ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 định | Ngưỡng backend | Tooltip | Icon |
|---|---|---|---|---|
| 🔴 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áo | KH > 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õi | KH 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.
- Header bảng / KPI label: luôn viết đầy đủ, không viết tắt. Khớp 100% với A9 Glossary.
- Cell body / wireframe ASCII: được phép rút gọn để tiết kiệm chỗ, KÈM tooltip giải thích.
- Tránh dùng "CN" cho cả "Chi nhánh" và "Cố định" —
Cố địnhluôn viết đầy đủ;CNchỉ dùng cho Chi nhánh. - 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").
- 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 ID | Mô tả | Bước thực hiện | Kết quả mong đợi |
|---|---|---|---|
| TC-FLEX-01 | Filter 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-02 | Filter 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-03 | Filter 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-04 | URL sync card_type | Truy cập URL ?card_type=flexible trực tiếp | Filter tự động chọn “Linh hoạt”, bảng chỉ hiện đơn flexible. |
17.2 Hiển thị UI
| TC ID | Mô tả | Bước thực hiện | Kết quả mong đợi |
|---|---|---|---|
| TC-FLEX-05 | Tag màu cột “Loại” | Xem bảng giao dịch có cả 2 loại | Cố định = QBadge xanh ($primary tint), Linh hoạt = QBadge tím ($purple-6). |
| TC-FLEX-06 | Tên thẻ flexible có số tiền | Tạo đơn nạp linh hoạt 7,500,000đ → xem bảng giao dịch | Cộ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-07 | Tên thẻ cố định bình thường | Xem đơn thẻ cố định VIP 20tr | Cột “Tên thẻ” hiển thị “VIP 20tr” (tên từ prepaid_card.name), không kèm suffix. |
| TC-FLEX-08 | KPI “Thẻ đã bán” có breakdown | Sub-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-09 | Donut chart phân bố mệnh giá | Sub-tab Tổng quan → xem Donut chart | Cá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-10 | Expanded row KH — Thẻ đã mua | Sub-tab Khách hàng → expand 1 KH có cả cố định + linh hoạt | Tab “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 ID | Mô tả | Bước thực hiện | Kết quả mong đợi |
|---|---|---|---|
| TC-FLEX-11 | Export Excel có cột Loại | Export danh sách giao dịch | File Excel có cột “Loại” với giá trị “Cố định” hoặc “Linh hoạt” cho mỗi dòng. |
| TC-FLEX-12 | Export khi filter flexible | Filter “Linh hoạt” → Export | File 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]”. |