Skip to content

PRD: Phân Tích Thẻ Trả Trước — Tab Báo Cáo Mới

Ngày: 2026-03-13 Phiên bản: 1.0 Product Owner: PO Team Design spec tham chiếu: dev-spec.mdUI spec tham chiếu: ui-spec.mdImplementation plan: plan.md

v3.16 — 16/05/2026

Thay đổiSectionẢnh hưởng
Thêm FORMULA-020: ROI Marketing & Trạng thái hiệu quả CD — định nghĩa canonical 3 ngưỡng: Hiệu quả > 200% / Trung bình 100%–200% / Kém < 100% + edge case marketing_cost = 0 → "—" + tag "Chưa có budget". Ngưỡng cũ chỉ định nghĩa > 200% (Hiệu quả), bỏ trống cutoff Trung bình/Kém → ambiguousFORMULA-020All (Phase 3+)
US-5.1 AC update: trạng thái CD ghi rõ 3 ngưỡng + ref FORMULA-020 + edge caseUS-5.1 AC lineFE, BE, QA
Tooltip ROI KPI Card + column "Trạng thái" bảng Chiến dịch + ui-spec ROI highlight rule: đồng bộ 3 ngưỡng theo FORMULA-020dev-spec §16.4, §16.12, ui-spec §7.1FE, UI/UX

v3.15 — 14/05/2026

Thay đổiSectionẢnh hưởng
Thêm DEC-T09: FORMULA-013 Chu kỳ TB = Customer-weighted mean (Option 2), KHÔNG phải Interval-weighted (Option 1). Scope cycle-closes-in-period + cap khoảng > 180 ngàyZ3. Technical DecisionsBE, FE, QA
FORMULA-013 rewritten: 2-step formula (per-customer AVG → AVG across customers), scope, outlier cap, edge cases, ví dụ so sánh Option 1 vs 2FORMULA-013BE, QA
A9 Glossary "Chu kỳ trung bình": thêm cụm "trung bình theo khách (mean of means)" + cap 180d. Tooltip ui-spec/dev-spec §16.24 cập nhật tương ứngA9 Glossary, ui-spec §5.5, dev-spec §16.24FE, UI/UX
dev-spec §9.2 mv_prepaid_customer_stats: thêm CTE per_customer_cycle + 3 field MV (avg_cycle_days, cycle_count, last_cycle_closing_at) + partial index. Aggregate query KPI 5.5 documented ngay sau MVdev-spec §9.2BE
QA TC-CT-CYCLE rewritten: 5 sub-case (power-user bias, outlier cap 180d, KH 1 đơn exclude, edge "—", scope cycle-closes-in-period)qa-test-plan §D4QA
Sub-tab Khách hàng: tách cột Dư ví thành Dư ví DIVA + Dư ví KM; bảng KH đổi 12 → 13 cột; risk highlight theo tổng DIVA + KM > 5trUS-3.2FE, BE, QA
FORMULA-006 + V5 blocker: Tổng dư ví = SUM(wallet_balance_diva + wallet_balance_km); BE phải confirm exact fields trong wallet_balance_result và reconcile với total-only wallet_balance.balance nếu cóFORMULA-006BE

v3.14 — 12/05/2026

Thay đổiSectionẢnh hưởng
Thêm DEC-T07: bỏ tab "Lịch sử sử dụng" khỏi expanded row đơn nạpZ3. Technical DecisionsAll
Thêm DEC-T08: commission source = invoice_commission (KHÔNG phải transaction_request). Tab Hoa hồng chỉ 1 status "Đã chi" — bỏ cột Trạng tháiZ3. Technical DecisionsAll
Thêm DEC-U13: Alert Box Rich Context — 4 lớp (SLA / metric+trend / suggested action / primary button). Banner top mandatory sau navigate. Sort override per alertZ2. UX DecisionsAll
US-1.2 Alert AC rewritten: 4 lớp rich context, hover tooltip preview, banner top, sort override, primary verb-button thay nút [Xem] genericUS-1.2FE, BE, QA, UI/UX
US-2.2: 3 tab → 2 tab + tab Hoa hồng bỏ trường "trạng thái"§4.2 Epic Giao dịchFE, QA
FORMULA-008 Hoa hồng: rewritten — đổi source transaction_requestinvoice_commission. Thêm gross/net split (net = gross − refund_commission)FORMULA-008BE, QA

Z) Decision Log

Các quyết định kiến trúc và nghiệp vụ đã được thống nhất. Mọi section downstream tham chiếu DEC-ID tương ứng.

Z1. Business Decisions

DEC-IDQuyết địnhLý doNgày
DEC-B01Dùng hành vi KH (ngày cuối dùng ví) thay vì "thẻ hết hạn" để phân khúcprepaid_card không có field expiry_date. KH lâu không dùng mới là vấn đề thực tế2026-03-13
DEC-B02Định nghĩa KH VIP: tổng nạp ≥ 20tr HOẶC lần nạp ≥ 5 HOẶC (Hoạt động + dư ≥ 5tr)Cần threshold rõ ràng cho alert + phân khúc, phản ánh giá trị KH thực tế2026-03-13
DEC-B03"Doanh thu ghi nhận" = tiền KH đã dùng từ ví, KHÔNG phải tiền thu vàoTránh nhầm lẫn kế toán — 2 khái niệm khác nhau hoàn toàn2026-03-13
DEC-B04ROI chiến dịch chỉ dựa trên HH affiliate, chưa bao gồm chi phí CDCampaign chưa có budget tracking — ghi rõ trên UI2026-03-13
DEC-B05Phase 1 = 4 sub-tab (Tổng quan · Giao dịch · Khách hàng · Tài chính). Marketing + Nhân viên defer Phase 3+ (TBD). Phase 2 = Global SearchPhase 1 ưu tiên giá trị cấp thiết cho Kế toán + Quản lý + Marketing tự phục vụ KH. 2 sub-tab Marketing/NV sẽ ưu tiên lại sau khi MVP ổn định và có nhu cầu thực tế2026-05-04
DEC-B06 ⏳ PROVISIONAL — REVIEW L8 BLOCKER(Đã đề xuất: DT chỉ ví chính, KHÔNG tính ví KM) — TẠM HOÃN khóa quyết định sau review L8: codebase report cũ (search_report_service, dashboard) vẫn split wallet vs wallet_promotion thành 2 bucket revenue riêng, conflict với option chỉ ví chính. PO + Kế toán đại diện phải reconcile với báo cáo cũ + xác nhận semantics đúng trước khi khóaCần evidence: (a) Báo cáo cũ tính DT thế nào? (b) Kế toán muốn DT bao gồm KM hay không? (c) Reconciliation chênh lệch chấp nhận được?⏳ Open — V11 BLOCKER

Z2. UX Decisions

DEC-IDQuyết địnhLý doNgày
DEC-U01Desktop-first, responsive hỗ trợ (không mobile-first)User chính là kế toán + quản lý dùng desktop tại văn phòng2026-03-13
DEC-U02Tab cũ ẩn qua feature flag, không xóaCho phép rollback nhanh nếu tab mới có bug2026-03-13
DEC-U03SĐT KH masked mặc định (0912***456)Bảo mật PII, chỉ role có quyền mới xem đầy đủ2026-03-13
DEC-U04Bỏ chế độ "So sánh KV / So sánh CN" — filter bar chỉ còn 3 element (Chi nhánh, Khoảng thời gian, Tìm kiếm)Đơn giản hóa UX, giảm phức tạp state. Chart "So sánh khu vực" và bảng có cột KV vẫn giữ — không phụ thuộc toggle2026-05-04
DEC-U05Date range presets (Hôm nay/7 ngày/Tháng/Quý) gộp vào dropdown "Khoảng thời gian", không tách button rờiFilter bar gọn hơn, click 1 lần thay vì 2 element rời nhau2026-05-04
DEC-U06Rename: "Doanh thu & Công nợ" → "Tài chính", "Marketing & Chiến dịch" → "Marketing"Tên gọn hơn cho tab bar, dễ quét mắt khi có 6 tabs ngang2026-05-04
DEC-U07Sub-tab order chuẩn: Tổng quan · Giao dịch · Khách hàng · Tài chính · Marketing · Nhân viên (Khách hàng đứng trước Tài chính)Phù hợp luồng đọc của Quản lý: nhìn tổng → tra đơn → xem khách → mới đến số tiền2026-05-04
DEC-U08Charts time-series chỉ theo ngày, KHÔNG có toggle Ngày/Tuần/Tháng. Range follow shared filter "Khoảng thời gian". Trend kỳ trước tính theo A10 FORMULA-019 (Period Comparison) — sliding N ngày cho rolling preset, cùng độ dài calendar cho this_month/this_quarter, full calendar cho last_month/last_quarterPhase 1 đơn giản UX (1 source of truth time scale). Range mặc định "Tháng này" = 30 ngày = 30 điểm chart, vừa đủ readable. Phase 3+ có thể bổ sung toggle nếu user request2026-05-04
DEC-U09Sub-tab Khách hàng = pure read-only: bỏ checkbox multi-select + bỏ HẲN bulk SMS/ZNS/Gán NV (kể cả Phase 1.x). Nút Excel ở header bảng (góc trên phải), không cần select rowsBulk notification thuộc nghiệp vụ module Marketing/CRM riêng — không nên duplicate. Tab Analytics = read-only an toàn, không có write surface2026-05-04
DEC-U10Hợp nhất 1 permission report.prepaid_analytics.export cho TOÀN BỘ tính năng xuất báo cáo trong tab. Label nút export đồng bộ tiếng Việt: [📥 Xuất Excel] (generic) hoặc [📥 Xuất {Tên báo cáo}] (specific 5 buttons Tài chính)Đơn giản UX cho admin cấp quyền (1 toggle thay vì 5+ permissions). Audit log chi tiết per-export-type vào export_job table — permission gate ở entry, audit chi tiết ở data2026-05-04
DEC-U11Sub-tab Tổng quan = pure dashboard read-only: (a) Header có nút [🔄 Tải lại] manual refresh; (b) KHÔNG có nút "Tải xuống" tổng ở header — Export ở Sub-tab Tài chính (5 nút) + Sub-tab Khách hàng (1 nút) là đủ; (c) KHÔNG có banner auto-insight ("DT giảm X% chủ yếu từ vùng Y") — defer Phase 3+ vì cần AI/heuristic; (d) Tổng dư ví giữ 1 metric tổng ở Tổng quan; split Ví DIVA/KM chỉ hiện ở Sub-tab Giao dịch và bảng Khách hàng; (e) Trend label dynamic theo filter Khoảng thời gian (đồng bộ DEC-U08); (f) Format VND nhất quán: rút gọn tr/tỷ cho card display, full format trong tooltip hoverTổng quan = high-level view, không lặp chi tiết các sub-tab. Insight tự động cần data + heuristic chưa có Phase 1. Ví DIVA/KM split phù hợp màn chi tiết giao dịch/khách hàng; Tổng quan giữ đơn giản2026-05-04
DEC-U12Single search source — Search keyword là single source ở shared filter top (3-element bar: Chi nhánh / Khoảng TG / Tìm kiếm). Bỏ HẲN local search input ở mọi sub-tab có table (Giao dịch, Khách hàng, Tài chính). Local filter chỉ giữ structured filter (dropdown / chip / toggle). Placeholder dynamic theo sub-tab active: "Tìm KH/SĐT/mã đơn..." (Tổng quan/Giao dịch) → "Tìm khách hàng (tên/SĐT)..." (Khách hàng) → "Tìm mã đơn/KH..." (Tài chính). URL state: ?q= ở root, persist khi đổi tab. Empty state v2 (3 case detect priority): Loading skeleton → Error [Thử lại] → No-data thực sự (📭 + "CN này chưa có giao dịch...") → Filter quá hẹp (🔍 + counter Tổng X giao dịch trong khoảng — thử bỏ filter + active chips clickable × + PRIMARY [Đặt lại tất cả] + SECONDARY link Mở rộng khoảng TG)Đơn giản hóa UX (1 ô search thay vì N), thống nhất state (1 store thay vì N store rời), persist keyword khi đổi tab giúp user diagnose cross-tab. Empty state v2 cung cấp diagnostic clarity (counter + chips) thay vì chỉ message generic2026-05-04
DEC-U13Alert Box Rich Context — Mỗi alert card có 4 lớp: (1) SLA label ⚠️/📅/👀 + count, (2) metric + trend chip ↑↓ (vs prior period), (3) Suggested action 💡 (i18n key từ BE), (4) Primary action button verb + số liệu ([📋 Xem 120 đơn] thay vì [Xem]). Hover preview tooltip mandatory ("Sẽ navigate sang... Filter: ..."). Banner top mandatory trên landing-from-alert page với [Bỏ alert filter ×] + [Đặt lại tất cả ↻]. Sort override từ alert payload (VD: Công nợ landing-from-alert → sort overdue DESC). Summary highlight: nhóm tương ứng bold + tinted, nhóm khác grey out 40%. Phase 1 chỉ 1 primary action per alert — secondary actions (bulk SMS/call) defer Phase 1.xUI cũ chỉ có nút [Xem] generic + 1 dòng metric → user không biết click sẽ navigate đâu, không có hint hành động, không có urgency framing. Rich context giúp manager scan → biết phải làm gì → click có confidence. Banner top + filter chips ngăn user lạc lối khi landing trên target page2026-05-12

Z3. Technical Decisions

DEC-IDQuyết địnhLý doNgày
DEC-T01Dùng 6 Materialized Views thay vì query real-time70 CN × 365 ngày × 1M+ giao dịch — query real-time sẽ > 10s2026-03-13
DEC-T02Feature flag FEATURE_PREPAID_ANALYTICS_V2Toggle tab cũ/mới, rollback 1 dòng code2026-03-13
DEC-T03Export server-side (chunked, async)Export client-side crash browser với 50K+ dòng2026-03-13
DEC-T04Keyset pagination thay vì offsetPerformance ổn định ở page sâu, tránh slow scan2026-03-13
DEC-T05Wallet balance qua Redis cache, không tạo MVWallet DB thuộc domain khác, cần data freshness 10 phút2026-03-13
DEC-T06QTabPanels + direct component (không dùng child routes)Khớp pattern ServiceReport.tsx hiện có trong codebase2026-03-13
DEC-T07Bỏ tab "Lịch sử sử dụng" khỏi expanded row đơn nạp. Wallet là pool chung per-customer, không track per-card. transaction_request.order_id = đơn dịch vụ tiêu ví (KHÔNG phải đơn thẻ nạp source). Khi KH có nhiều thẻ → không thể attribute usage về thẻ cụ thể. Lịch sử biến động ví hiển thị ở Sub-tab Khách hàng (per customer). FIFO lot accounting (table wallet_topup_lot + multi-lot consume) defer Phase 3+ nếu Finance yêu cầu audit nghiêm ngặtCodebase finding: transaction_request schema không có prepaid_card_id/source_prepaid_order_id. Topup không tạo transaction_request. Consume FIFO từ pool chung. Query gốc (line 499 dev-spec cũ) trả 0 rows2026-05-12
DEC-T08Commission source = invoice_commission (KHÔNG phải transaction_request). Tab Hoa hồng chỉ có 1 trạng thái thực tế = "Đã chi" (invoice_status = 'invoice_completed'). KHÔNG có pending (P) và KHÔNG có cancel (C) trên commission gốc. Refund commission = bản ghi transaction_request MỚI với behavior_id = 'refund_commission'amount âm (ghi sổ thu hồi) — KHÔNG đổi status của commission gốc. Action: (a) Bỏ cột "Trạng thái" khỏi tab Hoa hồng expanded row. (b) Đổi tất cả KPI/MV query từ transaction_request_user.amount WHERE behavior_id='transaction_commission'invoice_commission.amount WHERE invoice_status='invoice_completed'. (c) Refund track riêng nếu cần (sub-row hoặc visual indicator amount âm). (d) Net commission = Sum invoice_commission − Sum refund_commission (nếu cần net view)Codebase finding 2026-05-12: schema invoice_commission(id, user_id, order_id, invoice_id, order_item_id, amount, invoice_status, unit, created_at, ...) với invoice_status chỉ có 2 giá trị draft/invoice_completed. Code path order_commission_user_insert.go:16-58 đã comment-out behavior_id='transaction_commission' → KHÔNG được tạo trong wallet. Refund flow: invoice_cms_version_update.go tạo transaction_request với behavior_id='refund_commission' amount âm2026-05-12
DEC-T09FORMULA-013 Chu kỳ trung bình = Customer-weighted mean (Option 2), KHÔNG phải Interval-weighted (Option 1). Tính 2 bước: (1) per-customer AVG(paid_at[n+1] − paid_at[n]), (2) AVG kết quả across customers. Scope: cycle-closes-in-period (paid_at[n+1] ∈ filter) + cap khoảng > 180 ngày. Lý do: Use case PRD Section 5.5 = "timing gửi nhắc nhở cho 1 khách điển hình" → cần "hành vi điển hình per-customer", KHÔNG phải "nhịp nạp toàn hệ thống". Option 1 (interval-weighted) bị bias nặng bởi power user: 1 KH VIP nạp 20 lần/năm đóng góp 19 khoảng vs 100 KH thường mỗi người 1 khoảng → metric phản ánh nhịp VIP, làm Marketing gửi reminder quá sớm cho 99% khách còn lại. Option 2 cùng "đơn vị nguyên tử" (1 KH = 1 vote) với Segment Cards (5.1) + Customer Table (5.2) → consistent cross-section. Trade-off: thêm 1 GROUP BY layer trong SQL (chấp nhận được — MV pre-compute, < 200ms cho 100K KH). Action: (a) PRD A10 FORMULA-013 + A9 Glossary update với 2-step formula; (b) dev-spec §9.2 mv_prepaid_customer_stats thêm 2 field avg_cycle_days + cycle_count (CTE per_customer_cycle, cap 180d); (c) Aggregate query Section 5.5 dev-spec: AVG(avg_cycle_days) WHERE cycle_count >= 1; (d) QA TC-CT-CYCLE 3 sub-case (VIP bias, outlier cap, KH 1 đơn)PO requirement 2026-05-14: phân biệt rõ 2 option (interval-weighted vs customer-weighted) trước khi BE implement. Spec gốc để công thức AVG(paid_at[n+1] − paid_at[n]) trần trụi → ambiguous, BE có thể implement sai. Use case Marketing trong PRD + ui-spec tooltip đã ngầm chỉ định "khách điển hình" → bắt buộc Option 22026-05-14

Z4. QA Decisions

DEC-IDQuyết địnhLý doNgày
DEC-Q01MV refresh fail → hiện badge "Dữ liệu có thể chưa cập nhật", không block userGraceful degradation — user vẫn thấy data cũ hơn là blank screen2026-03-13
DEC-Q02Export fail → retry tự động 1 lần, sau đó thông báo userTransient error phổ biến với large dataset2026-03-13

1. Bối cảnh & Vấn đề

1.1 Bối cảnh nghiệp vụ

DIVA Group vận hành 70 chi nhánh spa trên toàn quốc. Thẻ trả trước (prepaid card) là sản phẩm chiến lược:

  • Khách hàng nạp tiền trước → nhận ưu đãi → sử dụng dịch vụ dần
  • Spa thu tiền trước → tạo dòng tiền ổn định → giữ chân khách
  • Có 2 loại: Thẻ cố định (gói cố định VD: VIP 20tr) và Nạp linh hoạt (tự chọn số tiền)

Hiện tại quy mô: 100K+ khách hàng có ví, 1M+ giao dịch tích lũy, tổng dư ví toàn hệ thống hàng tỷ đồng.

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

Báo cáo thẻ trả trước hiện tại (/r/reports/prepaid-card-report) không đáp ứng được nhu cầu vận hành:

Vấn đềẢnh hưởng thực tế
Dữ liệu sai/không khớpKế toán không tin tưởng báo cáo, phải đối soát thủ công trên Excel
Không phân biệt thẻ cố định vs linh hoạtKhông đánh giá được hiệu quả từng loại sản phẩm
Không có hoa hồng NVKế toán phải tra từng đơn để tính lương
Không có phân tích khách hàngMarketing chạy chiến dịch mà không biết KH nào cần chăm sóc
Không so sánh được chi nhánhQuản lý 70 CN mà không biết CN nào tốt, CN nào cần hỗ trợ
Export crash trên dữ liệu lớnKế toán cuối tháng không xuất được báo cáo
Không có cảnh báo vận hànhNợ quá hạn, KH “ngủ đông” không ai biết cho đến khi quá muộn

1.3 Tác động kinh doanh nếu không làm

  • Mất KH thầm lặng: KH mua thẻ xong không dùng → không ai phát hiện → KH quên luôn → mất KH
  • Nợ xấu tích tụ: Không theo dõi được công nợ → đơn quá hạn ngày càng nhiều
  • Dư ví = nợ phải trả: Tổng dư ví hàng tỷ nhưng không ai monitor → rủi ro tài chính
  • NV không bán thẻ: 70-80% NV chưa bán được thẻ nào → bỏ lỡ doanh thu
  • Marketing mù: Chạy chiến dịch mà không biết hiệu quả → lãng phí ngân sách

2. Mục tiêu sản phẩm

2.1 Mục tiêu chính

Xây dựng tab báo cáo mới hoàn toàn trong module Báo cáo thẻ trả trước, giúp 3 nhóm người dùng (Kế toán, Marketing, Quản lý) tự phục vụ được mà không cần nhờ IT hoặc làm thủ công.

2.2 Đo lường thành công

MetricHiện tạiMục tiêu sau 3 tháng
Thời gian đối soát doanh thu cuối tháng2-3 ngày (thủ công Excel)< 30 phút (export trực tiếp)
KH “rủi ro mất” được phát hiệnKhông phát hiện được100% KH > 60 ngày không dùng có alert
Tỷ lệ NV bán thẻKhông đo đượcĐo được + có ranking → tăng 10% NV tham gia bán
Thời gian load báo cáo> 10s, thường crash< 500ms (trang tổng quan)
Số lần kế toán nhờ IT tra dữ liệu5-10 lần/tháng0 (tự tra được)

2.3 Không nằm trong phạm vi (Out of Scope)

  • Sửa module bán thẻ trả trước (/e/prepaid-order/create) — module riêng
  • Thay đổi logic ví điện tử, hoa hồng — chỉ đọc dữ liệu, không sửa
  • Dashboard riêng cho từng CN (phase 2)
  • Push notification tự động khi có alert (phase 2)
  • Mobile-first design — desktop-first, responsive hỗ trợ

3. Người dùng & Vai trò

3.1 Persona chính

Chị Hà — Kế toán trưởng

  • Công việc hàng ngày: Đối soát doanh thu, theo dõi công nợ, tính hoa hồng NV
  • Pain point: “Cuối tháng tôi mất 2-3 ngày trích từng đơn ra Excel để đối soát. Nếu sai phải làm lại.”
  • Cần: Xuất Excel nhanh, số liệu chính xác, phân biệt được tiền thu / nạp ví / dư ví / hoa hồng

Anh Minh — Marketing Manager

  • Công việc hàng ngày: Chạy chiến dịch bán thẻ, chăm sóc KH cũ, phân tích nguồn khách
  • Pain point: “Chạy xong chiến dịch không biết hiệu quả bao nhiêu. KH mua thẻ xong bỏ đi không ai gọi lại.”
  • Cần: Xem được hiệu quả chiến dịch, biết KH nào sắp “chạy”, gửi SMS/ZNS hàng loạt

Anh Tuấn — Chủ spa / Giám đốc vùng

  • Công việc hàng ngày: Xem tổng quan kinh doanh, so sánh chi nhánh, ra quyết định
  • Pain point: “70 chi nhánh nhưng không biết CN nào bán thẻ tốt, CN nào NV không bán. Phải hỏi từng QĐ CN.”
  • Cần: Dashboard tổng quan, so sánh khu vực, cảnh báo khi có vấn đề

3.2 Ma trận vai trò — Sub-tab

#Sub-tabKế toánMarketingQuản lýPhase
1Tổng quanXemXemChínhP1
2Giao dịchChínhXemXemP1
3Khách hàngChínhXemP1
4Tài chínhChínhXemP1
5MarketingChínhXemP3+
6Nhân viênXem (Hoa hồng)ChínhP3+

Sub-tab order trên UI: Tổng quan → Giao dịch → Khách hàng → Tài chính → Marketing* → Nhân viên* (* defer Phase 3+).

Phân quyền kỹ thuật: Sub-tab ẩn/hiện theo globalStore.reportRoles. Xem design spec Section 2.5. Phase 1 router KHÔNG expose route /marketing/staff.


4. Yêu cầu chức năng — User Stories

4.1 Epic: Tổng quan Dashboard

Người dùng chính: Quản lý / Chủ spa


US-1.1: Xem KPI tổng quan thẻ trả trước

quản lý, tôi muốn mở tab báo cáo và thấy ngay 8 con số quan trọng nhất để nắm được tình hình kinh doanh thẻ trả trước trong 10 giây đầu tiên.

Acceptance Criteria:

  • [ ] Hiển thị 8 KPI cards chia 2 hàng (4 tài chính + 4 vận hành)
  • [ ] Hàng 1: Tiền thu vào · DT ghi nhận · Tổng dư ví · Công nợ (4 cards tài chính, đồng bộ A9 Glossary canonical names)
  • [ ] Hàng 2: Thẻ đã bán (kèm dòng phụ "X Cố định + Y Linh hoạt" bên dưới giá trị chính), Tỷ lệ KH đã dùng ví, Tỷ lệ KH tái nạp, KH mới
  • [ ] Mỗi card hiện: giá trị chính + so sánh kỳ trước (↑↓ %)
  • [ ] “Tổng dư ví” hiển thị kèm ghi chú “(Nợ phải trả)”
  • [ ] “Công nợ” hiện cảnh báo đỏ khi vượt ngưỡng
  • [ ] Click vào card → nhảy sang sub-tab chi tiết tương ứng
  • [ ] Hover icon ℹ️ → hiện tooltip giải thích bằng tiếng Việt (nội dung từ design spec Section 16.2)
  • [ ] Dữ liệu thay đổi theo filter chi nhánh + thời gian
  • [ ] Trang load < 500ms (dữ liệu từ materialized view)

Công thức KPI: Xem design spec Section 3.1 (chính xác từng field).

Lưu ý nghiệp vụ quan trọng — Phân biệt tiền:

  • “Tiền thu vào” = tiền KH thực trả (mặt + CK)
  • “Nạp ví” = giá trị vào ví KH (có thể > tiền thu do khuyến mãi)
  • “Doanh thu ghi nhận” = tiền KH đã dùng từ ví mua dịch vụ (DT thực tế theo kế toán)
  • “Tổng dư ví” = tiền KH chưa dùng = NỢ PHẢI TRẢ của spa

US-1.2: Xem cảnh báo vận hành — Rich Context (DEC-U13 — v3.14)

quản lý, tôi muốn thấy ngay những vấn đề cần xử lý + biết phải làm gì tiếp theo + biết click sẽ đi đâu để không bỏ sót nợ xấu, KH sắp mất, doanh thu giảmkhông phải đoán hành động.

Acceptance Criteria:

(a) Alert hierarchy & SLA:

  • [ ] Alert box hiển thị 3 mức: 🔴 Khẩn cấp, 🟡 Cảnh báo, 🔵 Theo dõi
  • [ ] 🔴 Khẩn cấp: đơn quá hạn > 30 ngày HOẶC doanh thu giảm > 30%
  • [ ] 🟡 Cảnh báo: KH > 60 ngày không dùng ví (còn dư > 1tr) HOẶC DT giảm 15-30%
  • [ ] 🔵 Theo dõi: KH VIP > 90 ngày không hoạt động, thẻ giảm bán > 30%
  • [ ] SLA label hiển thị bên cạnh count badge: 🔴 ⚠️ Cần xử lý trong 24h | 🟡 📅 Cần xử lý trong 7 ngày | 🔵 👀 Chỉ thông tin — không cần hành động ngay

(b) Mỗi alert card hiển thị 4 lớp thông tin (DEC-U13):

  • [ ] Lớp 1 — Metric: dòng tiêu đề mô tả vấn đề + số liệu cụ thể (VD: "120 đơn quá hạn > 30 ngày — Công nợ 45 tr")
  • [ ] Lớp 2 — Trend chip ↑↓: số thay đổi vs kỳ trước (VD: ↑15 (vs 7d qua)) — màu đỏ nếu trend negative, xanh nếu positive. Nếu BE không return → ẩn chip (không render placeholder)
  • [ ] Lớp 3 — Suggested action 💡: 1 dòng gợi ý hành động tiếp theo (VD: "Gợi ý: Gọi KH hoặc gửi SMS nhắc thanh toán"). Source: i18n key từ payload.suggested_action_key
  • [ ] Lớp 4 — Primary action button: verb + số liệu (VD: [📋 Xem 120 đơn], [👥 Xem 23 KH], [📊 Xem chart doanh thu]). KHÔNG dùng nút [Xem] generic

(c) Click behavior:

  • [ ] Hover preview tooltip (mandatory): "Sẽ navigate sang {target_tab} → {target_inner_tab}. Filter: {chips}" — Quasar QTooltip max-width 380px, position top, delay 300ms
  • [ ] Click primary action → navigate sang target page với filter từ payload.navigate
  • [ ] Banner top mandatory trên target page: ⚠️ Đang xem từ alert: "{banner_text}" + filter chips + [Bỏ alert filter ×] + [Đặt lại tất cả ↻]
  • [ ] Sort override: target page tự động sort theo payload.navigate.sort_override (VD: Công nợ → overdue DESC)
  • [ ] Summary highlight: Tại landing-from-alert, nhóm tương ứng được bold + background tinted, các nhóm khác grey out 40% opacity (focus user)

(d) Layout & misc:

  • [ ] Auto expand nếu có alert 🔴, collapse nếu chỉ có 🔵
  • [ ] Empty state: "Không có cảnh báo — mọi thứ đang tốt! 👍" (ẩn cả box)
  • [ ] Phase 1 chỉ 1 primary action per alert. Secondary actions kiểu bulk SMS/call — defer Phase 1.x (cần API + RBAC + audit)
  • [ ] Aria: primary button role="button" + aria-label="Xem chi tiết alert {title}". Banner top role="status" + aria-live="polite"

Định nghĩa "KH VIP": Tổng nạp ≥ 20tr HOẶC số lần nạp ≥ 5 HOẶC (Hoạt động + dư ≥ 5tr). Xem design spec Section 16.19.

Definition of Done (DEC-U13):

  • Backend compute_prepaid_alerts return đủ 5 field mới: sla_label, trend_value, prior_count, suggested_action_key, navigate.sort_override (xem dev-spec §3.3)
  • FE render đủ 4 lớp + hover tooltip + banner top sau navigate
  • QA test mỗi 5 alert type: hover preview, click → đúng filter + sort + banner, [Bỏ alert filter ×] clear đúng chips

US-1.3: Xem biểu đồ xu hướng

quản lý, tôi muốn xem biểu đồ trend doanh thu, so sánh khu vực, phân bố thẻ để phát hiện xu hướng nhanh mà không cần đọc bảng số.

Acceptance Criteria:

  • [ ] 4 charts lưới 2×2: Line (DT theo thời gian), Grouped Bar (so sánh KV), Donut (mệnh giá — gồm các mệnh giá cố định + 1 segment riêng “Nạp linh hoạt” gộp tất cả flexible cards), Area (tỷ lệ sử dụng)
  • [ ] Line chart: đường kỳ này (solid) + đường kỳ trước (nét đứt)
  • [ ] Charts time-series luôn theo ngày — không có toggle Ngày/Tuần/Tháng. Range follow shared filter "Khoảng thời gian"
  • [ ] Hover data point → tooltip giá trị chính xác (format VND)
  • [ ] Responsive: 2×2 desktop → stack dọc mobile

US-1.4: Xem top thẻ / NV / KH

quản lý, tôi muốn thấy nhanh top 5 thẻ bán chạy, NV giỏi nhất, KH VIP nhất để khen thưởng đúng người và biết sản phẩm nào hot.

Acceptance Criteria:

  • [ ] 3 mini-tables ngang: Top thẻ, Top NV, Top KH VIP
  • [ ] Mỗi bảng 5 dòng, compact
  • [ ] Click tên → navigate sang sub-tab chi tiết
  • [ ] [Xem thêm →] → navigate sang sub-tab tương ứng

4.2 Epic: Giao dịch chi tiết

Người dùng chính: Kế toán


US-2.1: Tra cứu giao dịch bán thẻ

kế toán, tôi muốn tìm nhanh bất kỳ đơn bán thẻ nào để đối soát hoặc xử lý khiếu nại KH.

Acceptance Criteria:

  • [ ] Bảng danh sách đơn hàng prepaid, mỗi đơn 1 dòng
  • [ ] 10 cột (gộp cũ 11 cột bằng cách merge Loại thẻ + Tên thẻ thành 1 cột với badge inline): Mã đơn · Ngày TT · Khách hàng · Tên thẻ (badge [Cố định]/[Linh hoạt] inline) · SL · Tiền thu · Nạp ví · Trạng thái · Nhân viên · Chi nhánh
  • [ ] 3 cột format multi-line: Ngày TT (giờ + ngày), Khách hàng (tên + SDT), Nhân viên (NV bán + NV thu ngân)
  • [ ] Cột "Tên thẻ" khi flexible = true: hiển thị "Nạp linh hoạt [số tiền]" với số tiền = order_item.prepaid_value_into_wallet (đã là line total — KHÔNG × quantity). Nếu prepaid_card.name có giá trị → hiển thị "{name} (linh hoạt {số tiền})"
  • [ ] Local filter 2 element (DEC-U12): Loại thẻ, Trạng thái thanh toán. BỎ HẲN local search — search dùng từ shared filter top (single source). Đã bỏ filter Nhân viên/Mệnh giá. Phase 2 Global Search dropdown (suggestion list) là enhancement của shared search hiện tại, KHÔNG tạo input mới
  • [ ] 7 Sum cards (grid layout, mỗi card có ↑↓% so kỳ trước): Tổng đơn · Tổng thu · Tổng nạp ví · Ví Diva · Ví KM · Tổng nợ · Tổng hoa hồng
  • [ ] Sum cards phản ánh đúng filter đang chọn
  • [ ] Sort theo Ngày TT (mặc định desc)
  • [ ] Pagination 20 dòng/trang, nút Trước/Sau

Phân biệt “Tiền thu” vs “Nạp ví” vs “Ví Diva” vs “Ví KM” (xem A10 Canonical Wallet Table + Section 16.1 + 4.2):

  • Tiền thu = tiền KH thực trả → invoice.amount (FORMULA-001, đã khóa)
  • Nạp ví = tổng giá trị thực nạp vào ví KH = Ví Diva + Ví KMorder_item.prepaid_value_into_wallet (FORMULA-002, đã khóa)
  • Ví Diva = phần nạp ví chính → ⏳ PROVISIONAL V9 (FORMULA-003) — KHÔNG bằng "Tiền thu" khi có discount/phụ thu hoặc giá bán ≠ base value
  • Ví KM = phần KM spa bù vào ví khuyến mãi → ⏳ PROVISIONAL V10 (FORMULA-004) — KHÔNG derive bằng Nạp ví − Tiền thu (sai khi giá bán ≠ base)

⚠️ Trước khi BE xác nhận V9-V10 với evidence codebase, KHÔNG được giả định Tiền thu = Ví Diva hoặc Ví KM = Nạp ví − Tiền thu. Hai số này phải bám công thức tại A10 Canonical Wallet Table (provisional rows).


US-2.2: Xem chi tiết từng đơn (expand)

kế toán, tôi muốn click vào 1 đơn để xem chi tiết thanh toán và hoa hồngkhông cần mở trang khác.

Acceptance Criteria:

  • [ ] Click row → expand hiển thị 2 tab bên trong:
    • Tab “Chi tiết TT”: danh sách invoice (phương thức, số tiền, ngày, trạng thái)
    • Tab “Hoa hồng”: NV nào nhận, bao nhiêu (commission lấy từ invoice_commission — chỉ trạng thái "Đã chi", ref DEC-T08). Nếu đơn có refund → hiển thị row riêng số âm với badge ↩️ Hoàn HH
  • [ ] Data lazy load — chỉ query khi user click expand
  • [ ] Expand load < 200ms

⚠️ Loại bỏ "Lịch sử sử dụng" khỏi level đơn nạp (DEC-T07): Wallet là pool chung per-customer, không track per-card. Khi KH có nhiều thẻ, không thể attribute usage về thẻ cụ thể. "Lịch sử biến động ví" được hiển thị ở Sub-tab Khách hàng → expanded row KH (đúng level — per customer). FIFO lot accounting defer Phase 3+.


US-2.3: Nhanh chóng navigate từ đơn hàng

kế toán, tôi muốn click tên KH để xem hồ sơ, click NV để xem hiệu suất, click “Nợ” để xác nhận TTkhông cần copy-paste tìm ở nơi khác.

Acceptance Criteria:

  • [ ] Mã đơn → link mở /e/prepaid-order/:id
  • [ ] Tên KH → link CRM profile
  • [ ] Tên NV → navigate sub-tab Nhân viên, auto filter NV đó
  • [ ] Badge “Nợ” → mở dialog xác nhận thanh toán

4.3 Epic: Phân tích Khách hàng

Người dùng chính: Marketing


US-3.1: Xem phân khúc KH tự động

marketing, tôi muốn biết bao nhiêu KH đang hoạt động, bao nhiêu đang “ngủ”, bao nhiêu sắp mất để ưu tiên chăm sóc đúng nhóm.

Acceptance Criteria:

  • [ ] 4 segment cards: 🟢 KH Hoạt động, 🟡 KH Ngủ đông, 🔴 KH Rủi ro mất, 🔵 KH Mới
  • [ ] Mỗi card hiện: số KH, % tổng, Dư ví (chỉ hiện cho Ngủ đông + Rủi ro mất)
  • [ ] Click card → filter bảng KH bên dưới theo segment
  • [ ] Điều kiện phân khúc:
    • Hoạt động: dùng ví trong 30 ngày gần nhất
    • Ngủ đông: 30-60 ngày không dùng, còn dư ví > 0
    • Rủi ro: > 60 ngày không dùng, còn dư ví > 0
    • Mới: lần đầu mua thẻ trong kỳ xem

Tại sao không có “thẻ hết hạn”: Hệ thống prepaid_card không có field expiry_date. Thay vào đó dùng hành vi sử dụng (KH lâu không dùng) — thực tế chính xác hơn: KH không quay lại mới là vấn đề, không phải thẻ hết hạn.


US-3.2: Tra cứu thông tin KH thẻ trả trước

marketing/CSKH, tôi muốn xem chi tiết từng KH: đã mua gì, còn bao nhiêu tiền, dùng ở đâu, bao lâu chưa quay lại để tư vấn đúng khi KH đến hoặc gọi điện.

Acceptance Criteria:

  • [ ] Bảng KH: 13 cột (Khách hàng, SĐT, Phân khúc, Tổng tiền đã nạp, Dư ví DIVA, Dư ví KM, Đã dùng ví, % Đã dùng ví, Số lần mua thẻ, Lần dùng ví cuối, CN mua, CN sử dụng, Số CN đã dùng)
  • [ ] % Đã dùng ví hiện dạng progress bar (Xanh > 70%, Vàng 30-70%, Đỏ < 30%)
  • [ ] "Lần dùng ví cuối" hiện relative time, highlight đỏ nếu > 60 ngày
  • [ ] Highlight đỏ nếu Tổng dư ví = Dư ví DIVA + Dư ví KM > 5tr (dư cao = rủi ro nếu KH không dùng)
  • [ ] SĐT masked (0912***456), trừ role có quyền xem đầy đủ
  • [ ] Expand row → 3 tab: Thẻ đã mua, Hành vi sử dụng, Gợi ý hành động

Lưu ý “CN mua” vs “CN dùng”: Đặc thù chuỗi 70 CN — KH mua thẻ ở CN A nhưng có thể dùng dịch vụ ở CN B, C. Cả 2 cột đều quan trọng. “Số CN đã dùng” > 1 = KH cross-branch.


US-3.3: Tải danh sách KH dạng Excel để dùng ở module khác

Update 2026-05-04 (review L5): Sub-tab Khách hàng = pure read-only analytics. Bulk write actions (SMS/ZNS/Gán NV) BỎ HẲN khỏi tab này — thuộc nghiệp vụ module Marketing/CRM riêng. User lọc KH ở tab này → tải Excel → import vào Marketing để gửi notification.

marketing, tôi muốn lọc KH theo segment (ví dụ “Ngủ đông”) và tải toàn bộ danh sách Excel để import vào module Marketing/CRM gửi campaign chăm sóc.

Acceptance Criteria — Phase 1:

  • [ ] KHÔNG có checkbox multi-select trên bảng KH (read-only design)
  • [ ] KHÔNG có sticky toolbar bulk actions
  • [ ] Bảng KH có header: title trái (Dữ liệu khách hàng — Tổng cộng {N} KH) + button phải [📥 Tải Excel]
  • [ ] [📥 Tải Excel]: trigger server-side export toàn bộ KH đang filter (theo segment + branch + date range hiện tại) — không cần select rows
  • [ ] Permission report.prepaid_analytics.export enforce ở BE (FE hide button + BE guard endpoint)
  • [ ] SDT trong file Excel mask theo view_full_phone permission (BE enforce)
  • [ ] Click row vẫn expand chi tiết KH (Section 5.3) — đây là read interaction, OK giữ

Out of scope (KHÔNG build trong tab Analytics — kể cả Phase 1.x):

  • ❌ Bulk SMS / ZNS — dùng module Marketing/CRM riêng
  • ❌ Gán NV chăm sóc bulk — thuộc module CRM
  • ❌ Multi-select / bulk select pattern — không cần

US-3.4: Xem chỉ số hành vi mua thẻ của khách hàng

quản lý marketing, tôi muốn biết KH chi mạnh không (AOV), có quay lại không (tỷ lệ tái nạp), bao lâu quay lại (chu kỳ TB) để đánh giá hiệu quả bán thẻ và chọn timing campaign giữ chân.

Acceptance Criteria:

  • [ ] Section "Chỉ số hành vi khách hàng" hiển thị 3 chỉ số: Giá trị đơn hàng trung bình (đ/đơn), Tỷ lệ tái nạp (%), Chu kỳ trung bình (ngày)
  • [ ] Mỗi chỉ số có icon ℹ️ tooltip với định nghĩa + công thức + ví dụ số
  • [ ] Mỗi chỉ số có trend so kỳ trước với label dynamic theo filter (VD filter "Tháng này" → "so với tháng trước"; filter "7 ngày" → "so với 7 ngày trước")
  • [ ] Cố định phía trên bảng KH (dưới segment cards)
  • [ ] Không hiển thị "CLV thuần" và "Tỷ lệ rời bỏ" trong Phase 1 (lý do: data tích lũy chưa đủ + duplicate với segment "Rủi ro mất"). Giữ formula trong A9 Glossary làm reference cho Phase 3+

4.4 Epic: Tài chính

Người dùng chính: Kế toán


US-4.1: Xem dashboard tài chính chuyên sâu

kế toán, tôi muốn xem 8 con số tài chính chi tiết để hiểu rõ dòng tiền thẻ trả trước mà không cần dùng Excel.

Acceptance Criteria:

  • [ ] 8 KPI cards: Tiền thu vào, Nạp ví, Tổng dư ví, Công nợ, DT ghi nhận, Hoa hồng, KM đã nạp, Lợi nhuận gộp

  • [ ] Công thức dòng tiền hiển thị rõ ràng:

    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ả
  • [ ] Mỗi KPI có tooltip giải thích tiếng Việt (design spec Section 16.3)

  • [ ] “Lợi nhuận gộp” ghi rõ: KHÔNG phải lãi ròng

Công thức chính xác: Xem design spec Section 6.1.


US-4.2: Theo dõi doanh thu theo thời gian

kế toán, tôi muốn xem doanh thu theo ngày/tuần/tháng để đối soát cuối kỳ và phát hiện bất thường.

Acceptance Criteria:

  • [ ] Tab con "Tổng hợp doanh thu"
  • [ ] Bảng + chart luôn theo ngày — không có toggle group-by. Mỗi row 1 ngày, 30 ngày = 30 rows (mặc định "Tháng này")
  • [ ] Bảng 7 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
  • [ ] Dòng tổng cộng ở cuối
  • [ ] Line chart doanh thu kèm đường kỳ trước (so sánh tự động)

US-4.3: Quản lý công nợ

kế toán, tôi muốn xem danh sách đơn còn nợ, phân loại theo mức nghiêm trọng để nhắc KH thanh toán trước khi thành nợ xấu.

Acceptance Criteria:

  • [ ] Tab con “Công nợ”
  • [ ] Summary: Tổng nợ + phân theo mức (< 15d, 15-30d, > 30d)
  • [ ] Bảng: Mã đơn, KH, Ngày mua, Tổng đơn, Đã trả, Còn nợ, Ngày quá hạn, Mức độ, CN, NV
  • [ ] Màu theo mức: Xanh (< 15d), Vàng (15-30d), Đỏ (> 30d)
  • [ ] “Ngày quá hạn” tính từ ngày tạo đơn (created_at), không phải ngày TT
  • [ ] Hành động mỗi dòng: chỉ 1 nút [📞 Gọi] (icon-only) — tel: link hoặc copy SDT. Click Mã đơn = navigate detail (đã có ở Quick Links). [Xác nhận TT] và [Ghi chú] = ❌ OUT OF SCOPE Phase 1 (write actions, defer)

US-4.4: Đối soát hoa hồng NV

kế toán, tôi muốn xem tổng hoa hồng từng NV từ bán thẻ để tính lương cuối tháng mà không cần tra từng đơn.

Acceptance Criteria:

  • [ ] Tab con “Hoa hồng”
  • [ ] Bảng: Nhân viên, Chi nhánh, Số đơn, Doanh thu đơn bán, Hoa hồng, % Hoa hồng / Doanh thu
  • [ ] Expand → chi tiết từng đơn
  • [ ] Chỉ tính HH thành công (status=‘S’, bên nhận)

US-4.5: Xem doanh thu theo phương thức thanh toán

kế toán, tôi muốn biết KH trả tiền mua thẻ bằng cách nào (mặt/CK/quẹt thẻ/ví) để đối soát với ngân hàng và quỹ tiền mặt.

Acceptance Criteria:

  • [ ] Tab con "Phương thức thanh toán"
  • [ ] Donut chart + bảng: Phương thức thanh toán, Số đơn, Số tiền, % Tổng, Trend

US-4.6: Xuất Excel cho kế toán

kế toán, tôi muốn xuất 5 loại báo cáo Excel để gửi ban giám đốc, đối soát ngân hàng, và tính lương.

Acceptance Criteria:

  • [ ] 5 nút export: Sổ doanh thu, Công nợ, Hoa hồng, Đối soát chi nhánh (mỗi CN 1 sheet), Dư ví
  • [ ] Export chạy server-side (không crash browser)
  • [ ] Progress bar: “65% (32K/50K dòng)”
  • [ ] Download link khi hoàn thành (qua notification)
  • [ ] Export 50K dòng < 60 giây

4.5 Epic: Marketing

🚫 Phase 3+ — Defer indefinitely (TBD). Section này KHÔNG triển khai trong Phase 1 (ref: DEC-B05). Giữ trong spec để team có context tổng thể về roadmap. Sẽ ưu tiên lại sau khi MVP ổn định và team Marketing có nhu cầu thực tế.

Người dùng chính: Marketing


US-5.1: Đánh giá hiệu quả chiến dịch

marketing, tôi muốn biết mỗi chiến dịch bán thẻ mang về bao nhiêu đơn, doanh thu, KH mới để nhân bản chiến dịch tốt, dừng chiến dịch kém.

Acceptance Criteria:

  • [ ] 8 KPI cards marketing (design spec Section 7.1)
  • [ ] Tab con “Chiến dịch”: bảng hiệu quả từng CD
  • [ ] Cột: Tên chiến dịch, Thời gian, Số đơn, Doanh thu, KH mới, Thẻ bán chạy, Khu vực hiệu quả, Trạng thái
  • [ ] Trạng thái CD (canonical FORMULA-020): Hiệu quả (ROI > 200%) / Trung bình (100% ≤ ROI ≤ 200%) / Kém (ROI < 100%). Edge case: Chi phí = 0 → hiển thị "—" + tag "Chưa có budget"
  • [ ] Expand: chi tiết theo khu vực, theo ngày, loại thẻ, danh sách KH
  • [ ] Lưu ý: 1 đơn có thể thuộc nhiều CD (campaign_ids array) → DT có thể tính trùng → hiện cảnh báo

US-5.2: Phân tích nguồn khách hàng

marketing, tôi muốn biết KH đến từ đâu: tự đến, chiến dịch, giới thiệu, hay KH cũ tái nạp để phân bổ ngân sách MKT đúng kênh.

Acceptance Criteria:

  • [ ] Tab con “Nguồn KH”: Donut chart + Stacked area chart trend
  • [ ] 4 nguồn: Walk-in, Chiến dịch, Affiliate, Tái nạp
  • [ ] Ưu tiên phân loại: CD > Affiliate > Tái nạp > Walk-in
  • [ ] Tab con “So sánh kênh”: bảng ROI theo kênh + insight tự động

US-5.3: Hành động marketing nhanh

marketing, tôi muốn từ màn hình báo cáo có thể tạo CD mới, xem KH từ CD, xuất báo cáokhông cần navigate qua lại nhiều module.

Acceptance Criteria:

  • [ ] Nút [+ Tạo CD mới] → link module settings
  • [ ] Nút [Xem KH từ CD] → sub-tab Khách hàng với filter
  • [ ] Nút [Xuất báo cáo CD] → Excel
  • [ ] Nút [Nhân bản CD thành công] → tạo bản sao CD có ROI cao

4.6 Epic: Nhân viên

🚫 Phase 3+ — Defer indefinitely (TBD). Section này KHÔNG triển khai trong Phase 1 (ref: 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) sẽ ưu tiên lại khi có nhu cầu coaching/đào tạo từ team Quản lý vùng.

Người dùng chính: Quản lý


US-6.1: Xếp hạng NV bán thẻ toàn hệ thống

giám đốc vùng, tôi muốn xem bảng xếp hạng NV bán thẻ trên toàn bộ 70 CN để khen thưởng top performers và đào tạo nhóm yếu.

Acceptance Criteria:

  • [ ] Tab con “Xếp hạng”
  • [ ] 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 / đơn, KH mới
  • [ ] Highlight: 🟢 Xuất sắc (> 150% TB), 🔴 Cần cải thiện (< 50% TB)
  • [ ] Insight: "77% NV chưa bán thẻ → cơ hội đào tạo"

US-6.2: Xem chi tiết hiệu suất 1 NV

quản lý CN, tôi muốn xem chi tiết NV: trend DT, đơn đã bán, phân tích hành vi bán để coaching 1-1 hiệu quả.

Acceptance Criteria:

  • [ ] Tab con “Chi tiết NV”
  • [ ] KPI cá nhân: Số đơn, Doanh thu, Hoa hồng, KH mới
  • [ ] Line chart trend Doanh thu 6 tháng (vs TB hệ thống)
  • [ ] Phân tích: thẻ hay bán, giờ bán tốt, KH tái nạp qua NV
  • [ ] Bảng đơn đã bán (paginated)

US-6.3: So sánh hiệu suất NV giữa các CN

giám đốc vùng, tôi muốn so sánh % NV bán thẻ giữa các CN để biết CN nào cần đẩy mạnh đào tạo bán thẻ.

Acceptance Criteria:

  • [ ] Tab con “So sánh CN”
  • [ ] Bảng nhóm 2 cấp: Khu vực (collapsed) → CN (expand)
  • [ ] Cột: Chi nhánh / Khu vực, Tổng NV, NV có đơn, % Tham gia, Số đơn TB / NV, Doanh thu TB / NV
  • [ ] Insight tự động: "CN mẫu" / "Cần đào tạo"

4.7 Epic: Shared — Filter, Search, Export


US-7.1: Lọc theo chi nhánh (70 CN)

bất kỳ vai trò nào, tôi muốn chọn chi nhánh nhanh chóng từ 70 CNkhông phải scroll danh sách dài.

Acceptance Criteria:

  • [ ] Branch selector với search + nhóm theo khu vực (HCM ▼ / HN ▼ / ...)
  • [ ] Click tên khu vực → chọn/bỏ tất cả CN trong khu vực đó
  • [ ] Badge hiện số CN đã chọn (VD: "3 CN", "HCM (25)", "Tất cả")
  • [ ] Mặc định chọn các CN user có quyền (theo role)
  • [ ] Persist qua URL: ?branch=uuid1,uuid2

US-7.2: Chọn khoảng thời gian báo cáo

bất kỳ vai trò nào, tôi muốn chọn nhanh khoảng thời gian báo cáo (hôm nay, 7 ngày, tháng này, quý này, hoặc khoảng tùy chọn) để xem KPI đúng kỳ cần phân tích.

Acceptance Criteria:

  • [ ] 1 dropdown "Khoảng thời gian" gộp cả presets + custom range
  • [ ] Presets: Hôm nay · Hôm qua · 7 ngày qua · 30 ngày qua · Tháng này · Tháng trước · Quý này · Tùy chọn...
  • [ ] "Tùy chọn..." mở date range picker (calendar)
  • [ ] Mặc định: Tháng này
  • [ ] Max range: 1 năm (365 ngày) — vượt sẽ disable button áp dụng
  • [ ] Persist qua URL: ?from=2026-03-01&to=2026-03-13
  • [ ] 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ề)

US-7.3: Tra cứu nhanh xuyên suốt

bất kỳ vai trò nào, tôi muốn gõ tên KH, SĐT, hoặc mã đơn vào 1 ô tìm kiếm duy nhất để tìm ngay thông tin cần mà không biết nó nằm ở sub-tab nào.

Acceptance Criteria:

  • [ ] 1 ô search trên shared filter bar
  • [ ] Gõ → debounce 300ms → hiện dropdown: top 5 KH, top 5 Đơn, top 5 NV
  • [ ] Click kết quả → navigate đúng nơi
  • [ ] Minimum 2 ký tự, search bằng pg_trgm (tìm gần đúng)

5. Yêu cầu phi chức năng

5.1 Hiệu năng

MetricTargetGhi chú
Trang Tổng quan load< 500msDữ liệu từ materialized view, cache Hasura
Bảng giao dịch (20 dòng)< 300msKeyset pagination
Expand row< 200msLazy load
Tra cứu nhanh< 300mspg_trgm GIN index
Export 50K dòng< 60sServer-side chunked
MV refresh< 30s/MVCONCURRENT, staggered cron

5.2 Tính ổn định

  • Feature flag FEATURE_PREPAID_ANALYTICS_V2: toggle tab cũ/mới, rollback 1 dòng code
  • Tab cũ không xóa, chỉ ẩn → user có thể quay lại nếu cần
  • MV refresh fail → hiện badge “Dữ liệu có thể chưa cập nhật” (không block user)
  • Export fail → retry tự động 1 lần, nếu vẫn fail → thông báo user

5.3 Bảo mật & Phân quyền

Cập nhật v3.0 — Dynamic Permission v2 fine-grained (review L4 fix): Sau review Security, Phase 1 cần 3 actions thay vì 1 module-level toggle vì module có PII (SDT) + financial data + export. Module + 3 actions:

  • view — bật/tắt xem báo cáo
  • export — bật/tắt export Excel (compliance: kế toán có quyền, NV thường không)
  • view_full_phone — bật/tắt xem SDT đầy đủ (compliance: chỉ role được duyệt)

3 actions được seed migration cùng với module registry. BE enforce ở Hasura permission rule + export endpoint guard, KHÔNG chỉ trust FE hide button.

Permission model — Phase 1:

module_id = "report.prepaid_analytics"
portal    = "admin"
actions   = [
  "view",              # Bật/tắt xem báo cáo (mặc định cho admin/area_manager/branch_manager)
  "export",            # Bật/tắt export Excel (mặc định ON cho admin, OFF cho NV/branch_manager)
  "view_full_phone"    # Bật/tắt xem SDT đầy đủ (mặc định OFF; chỉ enable khi org cần compliance)
]

Tại sao có exportview_full_phone action riêng (review L4 fix): SDT là PII + dữ liệu tài chính có sensitivity. Module-level toggle (chỉ view) KHÔNG đủ — cần fine-grained để Kế toán xem KPI nhưng KHÔNG export bulk; Marketing xem KH nhưng KHÔNG nhìn SDT đầy đủ.

Default permission matrix (3 actions × role):

User roleviewexportview_full_phoneBranch scopeHành vi
AdminTất cả CNToàn quyền
Area Manager (QL vùng)Khu vực mình quảnXem + export, SDT mask
Branch Manager (QL CN)CN của mìnhXem + export, SDT mask, scope CN
Accountant (Kế toán)Tất cả CN (theo cấp)Đặc biệt: cần xem SDT đầy đủ để gọi KH thu nợ
MarketingTất cả CNXem KH segment, KHÔNG export bulk, SDT mask
Staff (NV thường)Tab ẩn
Custom roleCấu hình qua Settings → Phân quyềnTheo branch_modeLinh hoạt

Default này được cấp qua migration seed Dynamic Permission v2. Admin có thể override per-user qua UI Settings sau deploy.

Branch scoping:

  • Hasura permission rule: branch_id = X-Hasura-Branch-Id cho non-admin
  • Admin/Area Manager bypass branch filter
  • branch_mode từ role: "all" / "scoped" / "self" — pick từ codebase Dynamic Permission v2 spec

PII (SĐT KH) — Phase 1 enforce ở backend:

  • Mặc định masked (0912***456) cho mọi user
  • Unmask CHỈ khi user có report.prepaid_analytics.view_full_phone action permission
  • Hasura permission rule: query trả phone_number_masked (computed column) thay vì phone_number raw cho user không có permission
  • Export Excel cũng phải mask theo cùng rule (BE enforce, không trust FE)

Export audit + permission:

  • Action permission report.prepaid_analytics.export → kiểm tra trước khi cho phép trigger export job
  • Mỗi export action ghi log vào export_job table: user_id, module_id, export_type (sổ DT / công nợ / HH / dư ví / KH list), filter_params (jsonb), phone_unmask (bool — log có unmask SDT trong file không), row_count, created_at, completed_at, file_url, downloaded_at
  • Log persist 90 ngày để audit
  • BE export endpoint check permission trước khi đọc data — KHÔNG chỉ trust FE hide button

Implementation note cho FE — chi tiết tại dev-spec.md Section 2.5:

typescript
// 🛑 V6 BLOCKER — Helper API name chưa confirmed (hasActionPermission vs hasModulePermission)
import { hasActionPermission } from '@/composables/useDynamicPermission'

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) redirect('/r/reports')

Migration path: Hệ thống cũ dùng globalStore.reportRoles (legacy). Phase 1 launch tab mới với v2 fine-grained 3 actions. Pre-deploy migration tasks:

  1. Seed module report.prepaid_analytics vào Dynamic Permission v2 registry
  2. Seed 3 actions: view, export, view_full_phone
  3. Default cấp permission cho admin/area_manager/branch_manager (xem matrix dưới)

Nếu Dynamic Permission v2 chưa ready (V6 trong SOURCE_OF_TRUTH) → spec block start implementation cho đến khi Security team confirm.

5.4 Data Freshness

Loại dữ liệuĐộ trễ chấp nhậnCơ chế
KPI cards, charts, bảng15-30 phútMaterialized view refresh
Tổng dư ví10 phútRedis cache từ wallet DB
Chi tiết đơn (expand)Real-timeQuery trực tiếp
Alert15 phútFunction query trên MV

Hiển thị “Dữ liệu cập nhật lúc: HH:mm” trên mỗi sub-tab.


6. Phân chia giai đoạn triển khai

TL;DR — Phase 1 MVP gồm 4 sub-tab cốt lõi. 2 sub-tab Marketing + Nhân viên defer Phase 3+ (TBD). Ref: DEC-B05.

Phase 1 — MVP (Sprint 1–4, ~8 tuần)

Mục tiêu: 4 sub-tab cốt lõi hoạt động, đủ thay thế hoàn toàn tab báo cáo cũ cho Kế toán + Quản lý + Marketing tự phục vụ KH cơ bản.

CÓ trong Phase 1:

Ưu tiênHạng mụcLý do
P0Backend: 4 MVs (order_daily, card_daily, customer_stats, finance_daily) + indexes (KHÔNG cần branch_region — đã có region_branch sẵn)Nền tảng cho 4 sub-tab P1
P0Shared filter (chi nhánh, khoảng thời gian, tìm kiếm trong sub-tab)Dùng chung cho tất cả sub-tab
P0Sub-tab 1 — Tổng quan (KPI + charts + alerts + 3 mini-rankings)Landing page, giá trị ngay cho quản lý
P0Sub-tab 2 — Giao dịch (bảng + expand + filter + export)Tra cứu hàng ngày của kế toán
P0Sub-tab 3 — Khách hàng (4 segment cards + behavior bar + bảng KH read-only + nút Tải Excel ở header bảng)Marketing tự phục vụ. KHÔNG có bulk SMS/ZNS/Gán NV trong tab này — dùng module Marketing/CRM riêng
P0Sub-tab 4 — Tài chính (4 tab con: Tổng hợp DT · Công nợ · Hoa hồng · PTTT)Đối soát cuối tháng = urgent nhất
P0Export Excel (server-side, 5 loại)Kế toán cần ngay
P0Feature flag + rollback mechanismAn toàn khi deploy

KHÔNG CÓ trong Phase 1 (defer các phase sau):

Hạng mụcDefer PhaseLý do
Bulk SMS / ZNS / Gán NV / Multi-select (Sub-tab Khách hàng)OUT OF SCOPEBỏ HẲN khỏi tab Analytics. Bulk notification dùng module Marketing/CRM riêng — KHÔNG duplicate. User flow: lọc KH ở tab này → Tải Excel → import sang Marketing
Xác nhận thanh toán (badge "Còn nợ" → dialog)P1.xWrite action — cần API endpoint + audit. P1 chỉ navigate đến trang chi tiết đơn (read-only)
Tra cứu nhanh (Global search dropdown — suggestion list xuyên entity)P2Phase 1 đã có shared search top (DEC-U12) apply scope auto theo sub-tab. P2 thêm dropdown suggestion + xuyên entity (KH/đơn/thẻ/CN)
Sub-tab 5 — Marketing (chiến dịch, affiliate, nguồn KH, so sánh kênh)P3+Khách hàng đã đủ Phase 1 cho team Marketing tự phục vụ
Sub-tab 6 — Nhân viên (ranking, chi tiết NV, so sánh CN)P3+Hoa hồng NV đã có ở Tài chính tab con 3 cho Kế toán
MV mv_prepaid_campaign_stats, mv_prepaid_staff_statsP3+Theo sub-tab tương ứng

Deliverable Phase 1: Tab mới đủ dùng cho 3 vai trò chính (Kế toán + Quản lý + Marketing). Tab cũ ẩn mặc định cho 3 vai trò này.


Phase 2 — Tiện ích (1–2 sprint, sau MVP ổn định)

Hạng mụcLý do
Tra cứu nhanh (Global search) — pg_trgm + function search_prepaid_globalTiện ích quan trọng nhưng KHÔNG block MVP
Push notification khi có alert criticalNâng cao UX cho Quản lý
Ngưỡng VIP, alert configurable từ SettingsCho phép tùy biến không cần dev

Phase 3+ — Mở rộng (TBD — chưa lên lịch)

Các hạng mục dưới đây sẽ được ưu tiên lại dựa trên phản hồi thực tế từ Phase 1/2 và nhu cầu kinh doanh tại thời điểm đó. KHÔNG cam kết timeline.

Hạng mụcTrigger ưu tiên lại
Sub-tab Marketing (chiến dịch, affiliate, nguồn KH, ROI)Khi team Marketing yêu cầu báo cáo CD chuyên sâu thay vì xem trên CRM
Sub-tab Nhân viên (ranking, chi tiết, so sánh CN)Khi Quản lý vùng cần coaching/đào tạo dữ-liệu-driven
Dashboard riêng cho từng CN (mobile-friendly)Khi CN trưởng cần view riêng trên điện thoại
Campaign budget tracking (hiện chỉ có HH affiliate)Cùng với Sub-tab Marketing
Xóa tab cũ hoàn toànSau khi Phase 1 stable ≥ 3 tháng và toàn bộ vai trò chuyển sang tab mới

7. Rủi ro & Giải pháp

Rủi roXác suấtẢnh hưởngGiải pháp
MV refresh chậm ảnh hưởng userTrung bìnhUser thấy data cũCONCURRENT refresh + badge thời gian cập nhật
70 CN × 365 ngày = data lớnCaoQuery chậmMV pre-aggregate + keyset pagination + partial indexes
Export crash với dữ liệu lớnCaoKế toán không làm được việcServer-side chunked + async + progress bar
Branch chưa có regionTrung bìnhKhông nhóm được KVPre-deploy data check: region_branch đã có sẵn, verify 70 CN có region_id (KHÔNG cần migration mới)
KPI “Doanh thu ghi nhận” gây hiểu lầmCaoKế toán hiểu saiTooltip chi tiết + link “Giải thích thuật ngữ” trên mỗi trang
Tab mới có bugTrung bìnhUser mất niềm tinFeature flag rollback 1 dòng code
Campaign chưa có budgetChắc chắnROI không chính xác (P3+ vấn đề)Ghi chú trên UI khi Phase 3+ ramp up
Reuse logic export buggy từ tab cũ (PrepaidCardReportFilter.tsx:180-185 tăng offset trước first fetch → bỏ 1000 rows đầu)CaoSố liệu export sai, kế toán đối soát thấy thiếu dữ liệuBuild mới hoàn toàn export pipeline. KHÔNG copy logic từ tab cũ. Code review check kỹ
Reuse logic summary buggy (PrepaidCardReportCard.tsx:37 exclude prepaid_type_flexible ở một số where)Trung bìnhSummary thiếu thẻ linh hoạt → số liệu saiBuild mới đảm bảo include cả Cố định + Linh hoạt trong tất cả KPI/MV
Schema mismatch (field name spec vs codebase)Đã fixSQL fail / số liệu saiĐã apply Schema Mapping (dev-spec Section 0). BE verify thêm các điểm 🔍 trước implement
Phase 1 thiếu global search → kế toán khó tra đơnĐã giảmUX kémLocal search (3-element filter trong Transactions): mã đơn / KH / SDT
Navigation đến Marketing/Staff sub-tab (P3+)Trung bìnhDead link, UX confuseDisable click + tooltip “Sắp ra mắt Phase 3+” trên tất cả nơi link đến deferred sub-tab
Dynamic Permission v2 chưa applyTrung bìnhRBAC không nhất quán với module mới của orgSection 5.3 đã update theo v2 fine-grained 3 actions (view, export, view_full_phone). Pre-deploy migration seed module + 3 actions + default grants. Block start implementation nếu V6 (Security Lead) chưa confirm v2 ready

A10) Formula Single-Source

⚠️ ĐÂY LÀ NGUỒN CHÍNH THỨC cho mọi công thức trong Phase 1. Dev-spec C3 (SQL implementation) chỉ ref các FORMULA-ID dưới đây — KHÔNG được tự định nghĩa lại. Nếu công thức cần đổi → đổi ở A10 trước, sau đó update SQL implementation.

Chuẩn theo CLAUDE.md project rule: "PRD A10 = business definition (canonical). Dev-spec C3 = SQL implementation delta only, ref A10".

A10.0) Canonical Wallet Table (5 numbers — single source for downstream)

⚠️ MỤC ĐÍCH: Sau Review L8, mọi tài liệu downstream (dev-spec SQL, plan GraphQL, ui-spec sum cards, qa seed) PHẢI bám bảng này. KHÔNG document nào được derive lại 5 số dưới đây bằng công thức khác. Nếu BE confirm/đổi công thức trong V9-V13 → update bảng này TRƯỚC, sau đó cascade.

#Số (Canonical name)Trạng tháiFORMULA-IDV-refCông thức ĐÃ KHÓA / ĐỀ XUẤTSource field (codebase evidence — BE confirm)
1Tiền thu (Total Collected)✅ ĐÃ KHÓAFORMULA-001V8 (parent filter)SUM(invoice.amount) WHERE parent_id IS NULL AND canceled_at IS NULL AND order_kind='prepaid' AND paid_at IS NOT NULLinvoice.amount (parent only — Rule 0 dev-spec §0.2)
2Ví Diva nạp (Main Wallet Top-up)⏳ PROVISIONALFORMULA-003V9 BLOCKERSUM(parent_invoice.reference_amount) cho prepaid orderparent_invoice.reference_amount — codebase: usePrepaidOrderItem.getValueIntoWallet = prepaid_card_base_value × customer_paid_amount / prepaid_card_valuepayment_order.go ghi vào parent_invoice.reference_amount. BE verify đường đi này còn đúng không.
3Ví KM nạp (Promotion Wallet Top-up)⏳ PROVISIONALFORMULA-004V10 BLOCKERSUM(parent_invoice.wallet_promotion_amount) cho prepaid orderparent_invoice.wallet_promotion_amount — codebase: KM nạp khi bán thẻ ghi vào field này trên parent invoice. BE verify có cần payment_method_id filter không.
4Ví Diva đã dùng (Main Wallet Used)⏳ PROVISIONALFORMULA-005V11 BLOCKER (semantics)SUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id='wallet' AND canceled_at IS NULLinvoice.reference_amount filtered theo payment_method_id='wallet'. PO + Kế toán reconcile với báo cáo cũ (search_report_service vẫn split wallet vs wallet_promotion thành 2 bucket).
5Ví KM đã dùng (Promotion Wallet Used)⏳ PROVISIONALFORMULA-005bV12 BLOCKERSUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id='wallet_promotion' AND canceled_at IS NULLinvoice.reference_amount filtered theo payment_method_id='wallet_promotion'. KHÔNG dùng invoice.wallet_promotion_amount cho phần ĐÃ DÙNG (field này dùng cho phần KM NẠP lúc bán thẻ, không phải usage). BE verify với codebase report hiện hữu.

Quy tắc bắt buộc:

  1. 5 hàng trên là canonical. Mọi MV SQL, GraphQL alias, sum card, export column, QA seed bám đúng FORMULA-ID + công thức bảng này.
  2. PROVISIONAL rows (#2-5) chưa được dev triển khai. Trong dev-spec/plan, các đoạn SQL/code derive 5 số này phải wrap trong block 🔒 PROVISIONAL — DO NOT IMPLEMENT until V9-V13 confirmed.
  3. Các cách derive bị cấm (đã chứng minh SAI sau Review L8 — KHÔNG được dùng ở bất kỳ document nào):
    • vi_diva = total_collected (sai khi discount hoặc giá bán ≠ base)
    • vi_km = total_wallet_topup − total_collected (sai cùng lý do)
    • vi_km_used = SUM(invoice.wallet_promotion_amount) cho phần ĐÃ DÙNG (field này là phần NẠP, không phải usage)
    • ❌ Equate Tiền thu với Ví Diva nạp trong narrative/UI/SQL
  4. Reconciliation requirement (V11): Trước khi unlock #4-5, PO + Kế toán phải reconcile với baseline report cũ — chấp nhận chênh lệch hay điều chỉnh semantics. DEC-B06 hiện đang PROVISIONAL.
  5. Update protocol: BE confirm 1 hàng → update bảng (đổi PROVISIONAL → LOCKED, fill Evidence column với file:line) → cascade dev-spec/plan/QA/handoff bằng grep theo FORMULA-ID.

Cross-ref: SOURCE_OF_TRUTH.md V9-V13 BLOCKER · EVIDENCE_PACK.md V9-V13 sections · dev-spec.md §0.2 Schema Mapping + §16.x


FORMULA-001: Tiền thu vào (Total Collected) ⚠️ CRITICAL

  • Mô tả: Tiền KH thực trả khi mua thẻ trả trước (tiền mặt + CK + quẹt thẻ).
  • Công thức: total_collected = SUM(invoice.amount WHERE parent_id IS NULL AND canceled_at IS NULL) cho các đơn order_kind = 'prepaid', paid_at IS NOT NULL.
  • ⚠️ Bắt buộc filter parent_id IS NULL (xem dev-spec Section 0.2 Rule 0): Hệ thống tạo 1 parent invoice + N sub_invoices cho prepaid order. KHÔNG filter → cộng trùng cả cha lẫn con → KPI sai 2x.
  • Biến số:
    • invoice.amount: số tiền của parent invoice (giá trị tổng đơn). Sub_invoices chỉ phân bổ theo order_item, không cộng vào tổng.
  • Đơn vị: đ (VND)
  • Ví dụ: Đơn 10tr (1 parent invoice = 10tr + 2 sub_invoices = 7tr + 3tr). total_collected = 10tr (CHỈ parent), KHÔNG phải 20tr.
  • Edge cases:
    • Invoice canceled (canceled_at IS NOT NULL) → exclude
    • Order chưa paid_at → exclude (chưa kết toán)
    • Parent invoice canceled nhưng sub_invoices vẫn active → vẫn exclude theo rule (parent canceled = đơn canceled)

FORMULA-002: Nạp ví (Total Wallet Top-up) ⚠️ CRITICAL

  • Mô tả: Tổng giá trị thực nạp vào ví KH (= ví chính + ví KM).
  • Công thức: total_wallet_topup = SUM(order_item.prepaid_value_into_wallet) cho các order_item của đơn prepaid đã thanh toán.
  • Biến số:
    • order_item.prepaid_value_into_wallet: đã là line total (codebase set giá trị nạp ví của cả dòng item, KHÔNG phải đơn giá)
  • ⚠️ KHÔNG nhân với quantity — sẽ phóng đại N lần khi quantity > 1
  • Đơn vị: đ
  • Ví dụ: Đơn mua 2 thẻ VIP 10tr (tổng nạp 20tr) → 1 row order_item với quantity=2, prepaid_value_into_wallet=20.000.000. Tổng nạp ví = 20tr (KHÔNG phải 40tr).

FORMULA-003: Ví Diva (Main Wallet Top-up) ⏳ PROVISIONAL — REVIEW L8 BLOCKER

  • Mô tả: Phần nạp vào ví chính (KH dùng được, có thể rút theo policy).
  • Công thức ĐỀ XUẤT (chưa khóa, V9 BLOCKER): vi_diva = SUM(parent_invoice.reference_amount) cho prepaid order — theo evidence codebase, FE tính getValueIntoWallet = prepaid_card_base_value × customer_paid_amount / prepaid_card_value rồi gửi parent_invoice.reference_amount theo giá trị này.
  • Công thức CŨ (giả định 1:1 với cash flow — đã chứng minh SAI khi giá bán ≠ base value): vi_diva = total_collected → KHÔNG dùng (sai khi có discount).
  • Bằng chứng codebase cần verify (V9):
    • PrepaidOrderCreate.tsx: divaWallet = quantity * prepaid_card_base_value
    • usePrepaidOrderItem: getValueIntoWallet = prepaid_card_base_value * customer_paid_amount / prepaid_card_value
    • payment_order.go + invoice_insert_update.go: parent_invoice ghi reference_amount = giá trị Diva
  • Đơn vị: đ
  • Ví dụ: Thẻ base 10tr giảm giá còn 8tr → KH trả 8tr → Ví Diva = 8 × 10/10 = 8tr? Hoặc theo base = 10tr? BE phải confirm rule với evidence từ codebase.
  • 🛑 Edge cases khi áp công thức cũ (= total_collected) sẽ SAI:
    • Discount: KH trả 8tr nhưng Ví Diva theo policy = 10tr (giá trị thẻ)
    • Phụ thu: KH trả 12tr nhưng Ví Diva chỉ = 10tr

FORMULA-004: Ví KM (Promotion Wallet Top-up) ⏳ PROVISIONAL — REVIEW L8 BLOCKER

  • Mô tả: Phần KM spa bù thêm vào ví khuyến mãi (KH chỉ dùng được, không rút).
  • Công thức ĐỀ XUẤT (chưa khóa, V10 BLOCKER): vi_km = SUM(parent_invoice.wallet_promotion_amount) cho prepaid order — theo evidence codebase, KM nạp ghi vào wallet_promotion_amount lúc bán thẻ.
  • Công thức CŨ (derive từ total_collected — đã chứng minh SAI): vi_km = total_wallet_topup − total_collected → KHÔNG dùng.
  • Đơn vị: đ
  • Verify (V10): BE confirm wallet_promotion_amount trên parent_invoice của prepaid order = giá trị Ví KM nạp lúc bán; có cần lọc payment_method_id riêng không?
  • 🛑 Edge cases khi áp công thức cũ: Khi giá bán ≠ base hoặc có discount → derive sai

FORMULA-005: DT ghi nhận (Revenue Recognized) ⏳ PROVISIONAL — REVIEW L8 BLOCKER

  • Mô tả: Tiền KH đã dùng từ ví trả dịch vụ (DT thực tế kế toán). Semantics chưa chốt — V11 BLOCKER.
  • Công thức ĐỀ XUẤT (DEC-B06 — chỉ ví chính, KHÔNG include ví KM): dt_ghi_nhan = SUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id = 'wallet' AND canceled_at IS NULL
  • Conflict với codebase report cũ: search_report_service + dashboard hiện hữu vẫn split wallet vs wallet_promotion thành 2 bucket revenue riêng (đều dùng i.reference_amount theo payment_method_id). DEC-B06 hiện đang đổi semantics — CHƯA reconcile với báo cáo cũ.
  • Bắt buộc:
    • Filter parent_id IS NULL (Section 0.2 Rule 0)
    • Filter payment_method_id còn tranh cãi: chỉ 'wallet' (DEC-B06) hay cả IN ('wallet', 'wallet_promotion') (codebase cũ) — PO + Kế toán phải confirm
    • Filter status: (status = 'invoice_completed' OR status IS NULL) — codebase cũ pattern
  • 🛑 Action items trước khi khóa:
    1. PO + Kế toán review báo cáo cũ → confirm rule
    2. Reconcile DT theo cả 2 cách (chỉ wallet vs wallet+promo) trên 3 tháng dataset → so sánh với báo cáo cũ
    3. Quyết định cuối: nếu match báo cáo cũ → cả 2 ví; nếu kế toán muốn cash basis → chỉ ví chính

FORMULA-005b: KM đã sử dụng ⏳ PROVISIONAL — REVIEW L8 BLOCKER

  • Mô tả: Phần ví KM mà KH đã dùng trả dịch vụ.
  • Công thức ĐỀ XUẤT (chưa khóa, V12 BLOCKER): Cần confirm field nào biểu diễn "ví KM đã sử dụng" trong codebase:
    • (A) SUM(invoice.reference_amount WHERE payment_method_id = 'wallet_promotion') — codebase pattern hiện hữu (reference_amount cho mọi payment method)
    • (B) SUM(invoice.wallet_promotion_amount WHERE payment_method_id = 'wallet_promotion') — KHÔNG dùng vì wallet_promotion_amount thực ra dùng để lưu KM nạp lúc bán thẻ (lẫn với "KM đã nạp")
  • Đơn vị: đ
  • Action: BE clarify field exact + sample query.

FORMULA-006: Tổng dư ví (Total Wallet Balance) — Nợ phải trả

  • Mô tả: Tổng số dư ví của tất cả KH có thẻ trả trước = NỢ PHẢI TRẢ của spa.
  • Công thức: total_wallet_balance = SUM(wallet_balance_diva + wallet_balance_km) cho tất cả KH có ít nhất 1 đơn prepaid. Nếu source BE chỉ expose wallet_balance.balance, V5 phải reconcile wallet_balance.balance = wallet_balance_diva + wallet_balance_km.
  • Đơn vị: đ (cache 10 phút qua Redis từ wallet DB)
  • Ví dụ: 5.000 KH có thẻ, tổng dư ví 2,1 tỷ → là nghĩa vụ dịch vụ tương lai
  • Edge cases: Real-time data, không nằm trong MV (xem dev-spec Section 9.5 Redis cache)

FORMULA-007: Công nợ (Debt / Receivable)

  • Mô tả: Tiền KH chưa trả đủ cho đơn mua thẻ.
  • Công thức: debt = SUM(order.total − order.paid_amount) WHERE paid_amount < total AND order_kind = 'prepaid' AND deleted_at IS NULL
  • Đơn vị: đ
  • Ví dụ: Đơn 10tr, KH mới trả 7tr → Công nợ = 3tr
  • Edge cases:
    • Đơn chưa paid_at (chưa kết toán) → vẫn tính công nợ nếu paid_amount < total
    • Đơn canceled → paid_amount = 0, total > 0 → có nên tính không? Default: exclude canceled (filter canceled_at IS NULL ở order level)

FORMULA-008: Hoa hồng (Commission) — REWRITTEN DEC-T08 (2026-05-12)

  • Mô tả: Tiền thưởng NV bán thẻ trả trước. Chỉ 1 trạng thái thực tế = "Đã chi" (invoice_commission.invoice_status = 'invoice_completed').
  • Công thức (gross — chưa trừ refund): commission_gross = SUM(invoice_commission.amount) WHERE invoice_status = 'invoice_completed' AND order_id IN (prepaid orders trong kỳ)
  • Công thức (net — sau refund, dùng cho finance): commission_net = commission_gross − refund_commission
    • refund_commission = SUM(ABS(transaction_request.amount)) WHERE behavior_id = 'refund_commission' AND order_id IN (prepaid orders trong kỳ)
    • Hoặc: SUM(-transaction_request.amount) (vì amount đã lưu sẵn dạng âm)
  • Đơn vị: đ
  • Ví dụ:
    • NV bán thẻ 10tr, HH 5% → invoice_commission insert 500K, invoice_status='invoice_completed' → commission_gross = 500K
    • Sau 3 ngày đơn bị hủy → tạo transaction_request(behavior_id='refund_commission', amount=-500000) → commission_net = 500K − 500K = 0
  • Edge cases:
    • Commission invoice_status = 'draft' → exclude (chưa hoàn tất, không tính)
    • KHÔNG dùng transaction_request_user với behavior_id='transaction_commission' — code path này đã bị disabled (order_commission_user_insert.go:16-58 commented out)
    • Phân biệt với refund_order (hoàn tiền KH) — không liên quan tới commission

FORMULA-009: KM đã nạp (Promotion Top-up Cost) ⏳ PROVISIONAL — V10 BLOCKER

  • Mô tả: Phần spa bù thêm = chi phí KM lúc bán thẻ. = FORMULA-004 (Ví KM nạp) — same metric, alias dùng cho góc nhìn finance/cost.
  • Công thức canonical (PRD A10.0 hàng #3): km_da_nap = SUM(parent_invoice.wallet_promotion_amount) cho prepaid order — chờ V10 BE confirm
  • ❌ Công thức CŨ (đã chứng minh SAI Review L8 — FORBIDDEN): km_da_nap = total_wallet_topup − total_collected → SAI khi giá bán ≠ base value hoặc có discount/phụ thu
  • Đơn vị: đ
  • Ví dụ: Đơn thẻ 10tr giá bán 8tr (discount), KM 2tr → derive cũ: 8tr nạp − 8tr thu = 0 ❌ (sai vì thực tế KM = 2tr). Canonical: parent_invoice.wallet_promotion_amount = 2tr
  • Edge cases: KHÁC "KM đã sử dụng" (xem FORMULA-005b — phần ví KM mà KH đã dùng trả dịch vụ, lấy từ invoice.reference_amount filter payment_method_id='wallet_promotion', KHÔNG phải wallet_promotion_amount).

FORMULA-010: Lợi nhuận gộp (Gross Profit) ⏳ PROVISIONAL — depends on V10

  • Mô tả: Lợi nhuận sơ bộ từ bán thẻ trả trước. KHÔNG phải lãi ròng — chưa trừ chi phí vận hành.
  • Công thức: gross_profit = total_collected − commission − km_da_nap
  • Phụ thuộc: km_da_nap từ FORMULA-009 (V10 BLOCKER). Gross Profit chỉ LOCK được sau khi V10 confirmed.
  • Đơn vị: đ
  • Ví dụ: 850tr − 38tr − 70tr = 742tr (giả định V10 cho km_da_nap = 70tr)
  • Edge cases: Phải >= 0 trong điều kiện thường. Nếu < 0 → cảnh báo: KM nạp + HH > tiền thu → cấu trúc giá lỗ.

FORMULA-011: Giá trị đơn hàng trung bình (AOV)

  • Mô tả: Doanh thu TB mỗi đơn mua thẻ. Đo độ lớn 1 lần mua.
  • Công thức: aov = total_collected / order_count
  • Biến số:
    • order_count = COUNT(DISTINCT order.id) cho đơn prepaid trong kỳ + filter
  • Đơn vị: đ/đơn
  • Ví dụ: 1.000 đơn, tổng thu 2,4 tỷ → AOV = 2.400.000 đ/đơn
  • Edge cases: order_count = 0 → display "—" (không hiện 0 hoặc NaN)

FORMULA-012: Tỷ lệ tái nạp (Repurchase Rate)

  • Mô tả: % KH mua thẻ ≥ 2 lần / Tổng KH có thẻ (đo retention).
  • Công thức: repurchase_rate = COUNT(DISTINCT customer WHERE prepaid_order_count >= 2) / COUNT(DISTINCT customer WHERE prepaid_order_count >= 1) × 100
  • Đơn vị: % (2 decimal)
  • Ví dụ: 1.000 KH có thẻ, 680 mua ≥ 2 lần → 68%

FORMULA-013: Chu kỳ trung bình (Repurchase Cycle)

  • Mô tả: Khoảng cách TB giữa 2 lần mua thẻ liên tiếp của một khách điển hình. Dùng cho timing campaign nhắc nhở tái nạp (gửi ưu đãi trước khi hết chu kỳ ~5 ngày).
  • Phương pháp tính (canonical): Trung bình theo khách (mean of means) — KHÔNG phải trung bình tất cả khoảng. Quyết định: xem Z3 DEC-T09. Lý do: tránh bias từ KH power user và phản ánh đúng "hành vi điển hình per-customer" theo use case Marketing.
  • Công thức 2 bước:
    • Bước 1 — per customer: customer_avg_cycle[c] = AVG(paid_at[n+1] − paid_at[n]) cho tất cả khoảng (n, n+1) của KH c thoả 2 điều kiện scope dưới
    • Bước 2 — aggregate: cycle_days = AVG(customer_avg_cycle[c]) trên tập KH Ccycle_count[c] >= 1
  • Scope cycles (BẮT BUỘC):
    • Cycle-closes-in-period: một khoảng (n, n+1) chỉ được tính khi paid_at[n+1] ∈ [filter.from, filter.to]. Đảm bảo trend % so kỳ trước fair (cùng calendar window). KHÔNG lấy toàn bộ lịch sử KH.
    • Outlier cap: loại các khoảng > 180 ngày (đó là reactivation sau ngủ đông, không phải cycle định kỳ).
  • Biến số:
    • paid_at[n], paid_at[n+1]: timestamps 2 đơn liên tiếp của 1 KH, order_kind='prepaid', paid_at IS NOT NULL, deleted_at IS NULL — nguồn: order.paid_at
    • cycle_count[c]: số khoảng hợp lệ của KH c sau khi áp scope + cap
  • Đơn vị: ngày (2 decimal)
  • Ví dụ thực tế:
    • 1 KH VIP nạp 20 lần/năm (gap ~18 ngày) + 100 KH thường nạp 2 lần/năm (gap ~180 ngày)
    • ❌ Interval-weighted: (19×18 + 100×180) / 119 = 154 ngày — méo (KH VIP đè 99% khách còn lại)
    • ✅ Customer-weighted (canonical): (18 + 180×100) / 101 = 178 ngày — phản ánh đúng khách điển hình
  • Ví dụ đơn lẻ: KH mua lần 1 ngày 01/03, lần 2 ngày 25/03 → cycle = 24 ngày; nếu chỉ có 1 KH → cycle_days = 24 ngày
  • Edge cases:
    • KH có 1 đơn duy nhất → cycle_count = 0 → exclude khỏi denominator
    • KH có nhiều đơn nhưng tất cả gap > 180d → cycle_count = 0 → exclude
    • Không có KH nào thoả → display (KHÔNG hiện 0 hay NaN)
    • Trend % so kỳ trước: nếu kỳ trước cũng → ẩn label trend
  • Tuỳ chọn nâng cấp (Phase 3+): đổi Bước 2 sang PERCENTILE_CONT(0.5) (median of means) để robust hơn với behavioral data right-skewed. Phase 1 dùng MEAN để đơn giản + cap 180d đã đủ giảm outlier impact.

FORMULA-014: % Đã dùng ví (Wallet Consumption %) ⏳ PROVISIONAL — REVIEW L8

  • Mô tả: Tỷ lệ KH đã dùng ví so với tổng đã nạp (per customer).
  • Công thức: consumption_rate = total_wallet_used / total_wallet_topup × 100 cho 1 KH
  • Biến số (chưa khóa — V12 BLOCKER):
    • total_wallet_used = SUM(invoice.reference_amount) WHERE parent_id IS NULL AND payment_method_id IN ('wallet', 'wallet_promotion') AND canceled_at IS NULL AND (status = 'invoice_completed' OR status IS NULL) của KH đó
    • Lưu ý: reference_amount là field chuẩn cho amount theo payment_method (theo codebase pattern). KHÔNG cộng thêm wallet_promotion_amount để tránh double count (review L8).
    • total_wallet_topup = SUM(order_item.prepaid_value_into_wallet) (KHÔNG × quantity — Rule 1) của KH đó
  • Đơn vị: % (2 decimal)
  • Ví dụ: KH nạp 12tr, dùng 10tr → 10/12 × 100 = 83,33%
  • Edge cases: total_wallet_topup = 0 → display "—"; consumption > 100% là dấu hiệu data lỗi (kiểm tra parent_id filter + status filter)

FORMULA-015: Tỷ lệ KH đã dùng ví (Wallet Usage Rate)

  • Mô tả: % KH có ít nhất 1 lần dùng ví / Tổng KH đã mua thẻ trong kỳ (chỉ số toàn hệ thống).
  • Công thức: usage_rate = COUNT(DISTINCT customer WHERE has_wallet_usage) / COUNT(DISTINCT customer WHERE bought_prepaid) × 100
  • Đơn vị: % (2 decimal)
  • Ví dụ: 100 KH mua thẻ, 78 KH dùng ví → 78%

FORMULA-016: KPI "Thẻ đã bán" (Sold Cards)

  • Mô tả: Tổng số lượng thẻ bán trong kỳ (kể cả gói nạp linh hoạt).
  • Công thức: sold_cards = SUM(order_item.quantity) cho đơn prepaid đã thanh toán + filter
  • Sub-breakdown: X = SUM(quantity) WHERE prepaid_card_view.flexible = false · Y = SUM(quantity) WHERE flexible = true
  • Đơn vị: thẻ
  • Ví dụ: 156 thẻ = 120 Cố định + 36 Linh hoạt
  • Edge cases: Đơn có nhiều order_item → cộng tất cả

FORMULA-017 (reference): CLV — Phase 3+ TBD

  • Phase 1 KHÔNG dùng. Giữ làm reference cho Phase 3+ khi có data tích lũy ≥ 6 tháng.
  • Công thức (reference): clv = SUM(dt_ghi_nhan_in_period) / COUNT(DISTINCT customer with activity) / months_in_period — đơn vị đ/KH/tháng

FORMULA-018 (reference): Churn Rate — Phase 3+ TBD

  • Phase 1 KHÔNG hiển thị (proxy bằng segment "🔴 KH Rủi ro mất").
  • Công thức (reference): churn = COUNT(DISTINCT customer WHERE last_used_at < NOW() − 90d AND no_new_orders) / COUNT(DISTINCT customer ever_had_card) × 100 — đơn vị %

FORMULA-019: Period Comparison (Kỳ trước) ⚠️ CRITICAL

  • Mô tả: Cách tính khoảng thời gian "kỳ trước" để so sánh trend ↑↓% cho mọi KPI card. Dev/FE PHẢI dùng rule này — KHÔNG tự define.
  • Quy tắc tổng: Mỗi filter preset có rule riêng để giữ cho trend % fair (cùng độ dài + cùng tính chất calendar period).

Bảng rule chuẩn:

Filter presetKỳ này (current)Kỳ trước (previous)Trend label
Hôm naytodaytoday − 1 day"so với hôm qua"
Hôm quatoday − 1 daytoday − 2 days"so với 2 hôm trước"
7 ngày quatoday−6 → today (7 ngày rolling)today−13 → today−7 (7 ngày liền trước)"so với 7 ngày trước"
30 ngày quatoday−29 → today (30 ngày rolling)today−59 → today−30"so với 30 ngày trước"
Tháng này (partial — đang chạy)month_start → today (vd 1-15/4 nếu hôm nay 15/4, N=15)Cùng độ dài, tháng trước: prev_month_start → prev_month_start + (N−1) days (vd 1-15/3)"so với tháng trước"
Tháng trước (đã đóng full)prev_month_start → prev_month_end (full tháng)prev_prev_month_start → prev_prev_month_end (full tháng trước nữa)"so với tháng trước nữa"
Quý này (partial)quarter_start → todayCùng độ dài, quý trước (sliding)"so với quý trước"
Tùy chọn (custom)User-picked from → to, độ dài N = to − from + 1(from − N) → (from − 1) — sliding N ngày liền trước"so với kỳ trước (N ngày)"

Pseudo-code (FE composable):

typescript
function calculatePreviousPeriod(currentFrom: Date, currentTo: Date, presetKey: string) {
  const N = daysBetween(currentFrom, currentTo) + 1  // inclusive

  // Special case 1: "Tháng này" (đang chạy) → cùng độ dài tháng trước
  if (presetKey === 'this_month') {
    const prevMonthStart = startOfPreviousMonth(currentFrom)
    return { from: prevMonthStart, to: addDays(prevMonthStart, N - 1) }
  }
  // Special case 2: "Tháng trước" (đã đóng) → full tháng trước nữa
  if (presetKey === 'last_month') {
    return { from: startOfMonth(addMonths(currentFrom, -1)), to: endOfMonth(addMonths(currentFrom, -1)) }
  }
  // Special case 3: "Quý này" / "Quý trước" — tương tự pattern trên
  if (presetKey === 'this_quarter') { /* same length sliding from prev quarter start */ }
  if (presetKey === 'last_quarter') { /* full prev-prev quarter */ }

  // Default (today/yesterday/last_7d/last_30d/custom): sliding N ngày liền trước
  return { from: addDays(currentFrom, -N), to: addDays(currentFrom, -1) }
}

Edge cases:

  • previous = 0: trend% display "—" (KHÔNG hiện 0% hay NaN)
  • Timezone: TẤT CẢ date convert về Asia/Ho_Chi_Minh trước khi so sánh
  • Inclusive endpoints: from ≤ date ≤ to (cả 2 đầu)
  • YoY (same period last year): KHÔNG mặc định Phase 1 — feature opt-in defer Phase 3+

Ví dụ số (today = 2026-04-15):

FilterCurrent from → toPrevious from → toN
Hôm nay2026-04-15 → 2026-04-152026-04-14 → 2026-04-141
7 ngày qua2026-04-09 → 2026-04-152026-04-02 → 2026-04-087
30 ngày qua2026-03-17 → 2026-04-152026-02-15 → 2026-03-1630
Tháng này (partial 15 ngày)2026-04-01 → 2026-04-152026-03-01 → 2026-03-15 (KHÔNG full tháng 3)15
Tháng trước (full)2026-03-01 → 2026-03-312026-02-01 → 2026-02-2831 vs 28
Quý này (Q2 partial)2026-04-01 → 2026-04-152026-01-01 → 2026-01-1515
Custom 10-12/42026-04-10 → 2026-04-122026-04-07 → 2026-04-093

Lỗi thường gặp (cấm):

  1. ❌ Default về full calendar month cho "Tháng này" → 15 ngày vs 31 ngày → trend % skew
  2. ❌ Sliding cho "Tháng trước" (đã đóng) → 31 ngày vs 28-29 ngày → tính trùng/lệch ngày
  3. ❌ Year-over-year mặc định → user expect kỳ liền trước, không phải năm trước
  4. ❌ Bao gồm paid_at IS NULL orders → chỉ tính order đã kết toán (paid_at IS NOT NULL)
  5. ❌ Mix timezone UTC/local → convert hết về Asia/Ho_Chi_Minh trước

Hasura GraphQL pattern:

graphql
query KpiOverview(
  $currentFrom: date!  # 2026-04-01
  $currentTo: date!    # 2026-04-15
  $prevFrom: date!     # 2026-03-01 (FE compute từ FORMULA-019)
  $prevTo: date!       # 2026-03-15
  $branchIds: [uuid!]
) {
  current: mv_prepaid_order_daily_aggregate(
    where: { report_date: { _gte: $currentFrom, _lte: $currentTo }, branch_id: { _in: $branchIds } }
  ) { aggregate { sum { total_collected, total_wallet_topup } } }
  previous: mv_prepaid_order_daily_aggregate(
    where: { report_date: { _gte: $prevFrom, _lte: $prevTo }, branch_id: { _in: $branchIds } }
  ) { aggregate { sum { total_collected, total_wallet_topup } } }
}

FE tính: trend_pct = (current - previous) / previous × 100 (display 1 decimal, edge previous=0 → "—").

Áp dụng cho:

  • ✅ TẤT CẢ KPI cards có ↑↓% trend (Tổng quan 8 cards · Giao dịch 7 sum cards · Tài chính 8 cards · Behavior Bar 3 metrics)
  • ✅ Charts time-series có "đường nét đứt kỳ trước" (Section 3.2 ui-spec)
  • ❌ KHÔNG áp dụng cho Tổng dư ví (real-time, không có concept "kỳ trước")
  • ❌ KHÔNG áp dụng cho Segment Cards (snapshot at point-in-time)

FORMULA-020: ROI Marketing & Trạng thái hiệu quả chiến dịch ⏳ Phase 3+ deferred

Phase status: Marketing sub-tab defer Phase 3+ — ref DEC-B05. FORMULA-020 lưu canonical trong A10 để Phase 3+ ramp up không phải re-define.

  • Mô tả: Tỷ suất lợi nhuận marketing — đo doanh thu sinh ra trên mỗi đồng chi phí marketing. Dùng cho cột "Trạng thái" bảng Chiến dịch (sub-tab Marketing) và highlight ROI card.
  • Công thức: ROI = (revenue_campaign + revenue_affiliate − marketing_cost) / marketing_cost × 100
  • Biến số:
    • revenue_campaign — Doanh thu từ đơn có order.campaign_ids IS NOT NULL (FORMULA-001 filtered theo campaign)
    • revenue_affiliate — Doanh thu từ đơn có order.ref_code IS NOT NULL (FORMULA-001 filtered theo ref_code)
    • marketing_cost — Hoa hồng affiliate (invoice_commission.amount) + campaign.budget (nếu có). Field budget hiện CHƯA tồn tại trên campaign (ref DEC-B04) → Phase 3+ phải bổ sung field này trước khi đưa Marketing sub-tab vào MVP.
  • Đơn vị: % (1 decimal)
  • Bảng phân loại trạng thái (canonical):
Trạng tháiNgưỡng ROIÝ nghĩa nghiệp vụUI badge
🟢 Hiệu quảROI > 200%Doanh thu ≥ 3× chi phí — chiến dịch nên scale / nhân bảnQBadge $positive
🟡 Trung bình100% ≤ ROI ≤ 200%Doanh thu 2×–3× chi phí — chấp nhận được, cần tối ưu kênh / nội dungQBadge $warning
🔴 KémROI < 100%Doanh thu < 2× chi phí — chi phí marketing chiếm > 50% revenue, cần xem lại hoặc dừngQBadge $negative
  • Logic căn cứ ngưỡng:
    • ROI = 0%: Doanh thu = Chi phí (hòa vốn, chưa tính giá vốn sản phẩm + vận hành)
    • ROI = 100%: Doanh thu = 2× Chi phí (chi phí MKT đúng bằng 50% revenue)
    • ROI = 200%: Doanh thu = 3× Chi phí (industry benchmark CD hiệu quả ngành spa/beauty)
  • Ví dụ:
    • CD A: DT 300tr, Chi phí 50tr → ROI = (300 − 50) / 50 × 100 = 500% → 🟢 Hiệu quả
    • CD B: DT 80tr, Chi phí 30tr → ROI = (80 − 30) / 30 × 100 = 166.7% → 🟡 Trung bình
    • CD C: DT 50tr, Chi phí 40tr → ROI = (50 − 40) / 40 × 100 = 25% → 🔴 Kém
    • CD D: DT 100tr, Chi phí 0đ (chưa có budget) → ROI = undefined → hiển thị "—" + tag "Chưa có budget"
  • Edge cases:
    • marketing_cost = 0 (campaign chưa có budget AND không có hoa hồng affiliate) → hiển thị "—" + tag "Chưa có budget" (KHÔNG hiển thị 0% hay ∞ hay NaN; KHÔNG gán "Kém")
    • marketing_cost = NULL → coi như chưa có data, xử lý như case trên
    • ROI < 0% (lỗ — Doanh thu < Chi phí) → vẫn hiển thị giá trị âm, xếp nhóm 🔴 Kém
    • Boundary chính xác: ROI = 100% thuộc Trung bình (không phải Kém); ROI = 200% thuộc Trung bình (không phải Hiệu quả) — dùng dấu < strict ở Kém, > strict ở Hiệu quả
  • Phase 1 status: KHÔNG implement. Marketing sub-tab toàn bộ defer Phase 3+ (ref DEC-B05). Giữ trong A10 làm canonical source khi Phase 3+ ramp up.

A9) Thuật ngữ (Glossary)

Bảng này bắt buộc phải hiểu trước khi implement. Nhầm lẫn thuật ngữ → tính sai số liệu. (ref: DEC-B03)

Thuật ngữ (VI)Thuật ngữ (EN)Định nghĩaPhân biệt với
Tiền thu vàoTotal CollectedTiền KH thực trả (mặt/CK/thẻ) khi mua thẻ prepaid. VD: Mua thẻ 10tr, trả 10tr → Thu = 10tr≠ Nạp ví (có thể > tiền thu do KM)
Nạp víWallet Top-upGiá trị nạp vào ví KH (có thể > tiền thu do KM). VD: Mua 10tr, KM thêm 2tr → Nạp ví = 12tr≠ Tiền thu vào (tiền thực KH trả)
KM đã nạp ⏳ V10Promotion Top-up CostPhần spa bù thêm vào ví KH lúc bán thẻ (= FORMULA-004 alias, ref A10.0 hàng #3). Lấy từ parent_invoice.wallet_promotion_amount. ❌ KHÔNG derive Nạp ví − Tiền thu — Review L8 chứng minh SAI khi giá bán ≠ base/discount/phụ thu. VD: thẻ 10tr giá bán 8tr (discount), KM 2tr → derive cũ ra 4tr ❌, canonical lấy thẳng field = 2tr ✅≠ Hoa hồng (KM cho KH, HH cho NV) · ≠ KM đã sử dụng (FORMULA-005b — usage, V12)
DT ghi nhậnRevenue RecognizedTiền KH đã dùng từ ví chính (Ví Diva) trả dịch vụ — phần KH thực trả lúc mua thẻ giờ đã sử dụng. KHÔNG tính ví KM (ref DEC-B06). VD: KH dùng 500K (300K chính + 200K KM) → DT = 300K≠ Tiền thu vào (chưa dùng) · ≠ KM đã sử dụng (FORMULA-005b — phần ví KM đã dùng, track riêng)
Tổng dư víTotal Wallet BalanceTiền KH chưa dùng = NỢ PHẢI TRẢ của spa. VD: 5000 KH dư tổng 2.1 tỷ≠ Công nợ (dư ví = spa nợ KH; công nợ = KH nợ spa)
Công nợDebt / ReceivableTiền KH chưa trả đủ cho đơn mua thẻ. VD: Đơn 10tr, trả 7tr → Nợ = 3tr≠ Tổng dư ví (công nợ = KH nợ spa)
Hoa hồngCommissionTiền thưởng NV bán thẻ (chỉ tính đã chi thành công). VD: NV bán 10tr, HH 5% → 500K≠ KM đã nạp (HH cho NV, KM cho KH)
LN gộpGross ProfitTiền thu − HH − KM đã nạp. KHÔNG phải lãi ròng. VD: 850tr − 38tr − 70tr = 742tr≠ Lãi ròng (chưa trừ chi phí vận hành)
CN muaPurchase BranchCN nơi bán thẻ cho KH. VD: KH mua thẻ ở Q.1≠ CN sử dụng (KH có thể dùng ở CN khác)
CN sử dụngUsage BranchCN nơi KH dùng ví trả dịch vụ (có thể khác CN mua). VD: KH dùng ở Bình Thạnh≠ CN mua
Hoàn víWallet RefundTiền hoàn lại ví khi hủy đơn. VD: Hủy đơn → hoàn 500K vào ví KH≠ Hoàn HH (hoàn ví cho KH, hoàn HH cho NV)
KH VIPVIP CustomerTổng nạp ≥ 20tr HOẶC lần nạp ≥ 5 HOẶC (Hoạt động + dư ≥ 5tr) (ref: DEC-B02)≠ KH Hoạt động (VIP là giá trị, Hoạt động là trạng thái)
KH Hoạt độngActive CustomerKH có dùng ví trong 30 ngày gần nhất. Là nhóm tiếp tục mang doanh thu≠ KH Ngủ đông (30–60d không dùng)
KH Ngủ đôngDormant CustomerKH không dùng ví 30–60 ngày, vẫn còn dư ví. Cần nhắc nhở SMS/ZNS≠ KH Rủi ro mất (> 60d không dùng)
KH Rủi ro mấtAt-risk CustomerKH không dùng ví > 60 ngày, vẫn còn dư ví. Spa đang giữ tiền nhưng KH không quay lại≠ KH Ngủ đông (mức nhẹ hơn)
KH MớiNew CustomerKH lần đầu mua thẻ trả trước trong khoảng thời gian đang xem≠ KH Tái nạp (đã mua ≥ 2 lần)
Giá trị đơn hàng trung bình (AOV)Average Order ValueDoanh thu TB mỗi đơn mua thẻ = Tổng tiền thu / Số đơn prepaid. Đơn vị: đ/đơn. VD: 1.000 đơn × 2,4 tỷ tổng thu → AOV = 2,4 tr/đơn≠ CLV (AOV đo độ lớn 1 đơn; CLV đo giá trị 1 KH suốt vòng đời)
Tỷ lệ tái nạpRepurchase Rate% KH mua thẻ trả trước ≥ 2 lần / Tổng KH có thẻ trong kỳ. Cao = KH trung thành≠ Tỷ lệ KH đã dùng ví (đã dùng ≠ mua lại)
Chu kỳ trung bìnhRepurchase CycleKhoả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) — 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 lấy AVG các giá trị đó. Cap khoảng > 180 ngày (reactivation, không tính). 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). Ref: FORMULA-013, DEC-T09≠ Tần suất sử dụng ví (mua thẻ ≠ dùng dịch vụ); ≠ Interval-weighted AVG (bị bias bởi power user)
CLV (reference, không hiển thị Phase 1)Customer Lifetime ValueDT TB mỗi KH trong 1 tháng = Tổng DT ghi nhận / Số KH có hoạt động / Số tháng. Đơn vị: đ/KH/tháng. Phase 1 không dùng vì chưa có data tích lũy đủ (cần ≥ 6 tháng). Có thể bổ sung Phase 3+
Tỷ lệ rời bỏ (Churn) (reference, không hiển thị)Churn Rate% KH không dùng ví > 90 ngày VÀ không có giao dịch mới / Tổng KH từng có thẻ. Phase 1 không hiển thị — duplicate signal với phân khúc "🔴 KH Rủi ro mất". Có thể bổ sung Phase 3+ làm KPI≠ KH Rủi ro mất (Rủi ro mất > 60d vẫn cứu được; Churn > 90d gần như mất hẳn)
Tỷ lệ KH đã dùng víWallet Usage Rate% KH có ít nhất 1 lần dùng ví / Tổng KH đã mua thẻ trả trước trong kỳ≠ % Đã dùng ví (đó là tỷ lệ tiền đã dùng / tổng nạp của 1 KH)
% Đã dùng víWallet Consumption %(Đã dùng ví / Tổng tiền đã nạp) × 100 — của 1 KH cụ thể. Cao = KH đang tích cực dùng≠ Tỷ lệ KH đã dùng ví (chỉ số toàn hệ thống)
Lần dùng ví cuốiLast Wallet UsageNgày gần nhất KH dùng ví trả dịch vụ. Dùng để phân khúc Hoạt động / Ngủ đông / Rủi ro≠ Lần mua thẻ cuối (mua thẻ ≠ dùng ví)
Số lần mua thẻPrepaid Order CountSố đơn mua thẻ trả trước (lũy kế) của 1 KH≠ Lần dùng ví (mỗi lần dùng dịch vụ)
Tổng tiền đã nạpTotal Top-upLũy kế tiền KH đã nạp vào ví qua tất cả lần mua thẻ≠ Tổng dư ví (đã trừ phần đã dùng)
Thẻ Cố địnhFixed CardThẻ mệnh giá cố định (1tr, 5tr, 10tr, 20tr, ...). prepaid_card.flexible = false≠ Thẻ Linh hoạt (số tiền tùy chọn)
Thẻ Linh hoạtFlexible CardKH tự chọn số tiền nạp, không có mệnh giá cố định. prepaid_card.flexible = true≠ Thẻ Cố định

Bảng đầy đủ với data source: dev-spec.md Section 16.1.


9. Tài liệu tham chiếu

Tài liệuĐường dẫnNội dung
Design Specdev-spec.mdKiến trúc, SQL, công thức, 17 sections chi tiết
UI Specui-spec.mdLayout, component tree, wireframe, interactions
Implementation Planplan.md23 tasks, 3 chunks, file structure
Tooltip & Cộtdev-spec.md Section 16Tooltip tiếng Việt cho mọi KPI + định nghĩa cột
MV SQLdev-spec.md Section 9.2SQL chi tiết cho 6 materialized views
Alert functiondev-spec.md Section 9.9compute_prepaid_alerts SQL
Search functiondev-spec.md Section 10.2search_prepaid_global SQL

10. Checklist trước khi bắt đầu Sprint

Hạ tầng & dependencies:

  • [ ] Migration branch_region đã chạy, 70 CN đã gán khu vực
  • [ ] pg_trgm extension đã có (verify) — chỉ cần khi build Phase 2 (Global search), Phase 1 không bắt buộc
  • [ ] Redis, MinIO/GCS, export-api, notification-api đều running
  • [ ] Chart.js + vue-chart-3 đã có trong package.json (verify)
  • [ ] Feature flag FEATURE_PREPAID_ANALYTICS_V2 đã setup

Phase scope (Phase 1):

  • [ ] Router chỉ expose 4 routes: /overview, /transactions, /customers, /finance. KHÔNG thêm /marketing, /staff
  • [ ] Sub-tab bar render đúng 4 tabs P1 theo thứ tự: Tổng quan · Giao dịch · Khách hàng · Tài chính
  • [ ] Migration list chỉ chạy 4 MVs P1: mv_prepaid_order_daily ⭐ · mv_prepaid_card_daily ⭐ · mv_prepaid_customer_stats · mv_prepaid_finance_daily. KHÔNG chạy mv_prepaid_campaign_stats, mv_prepaid_staff_stats (P3+ defer) và search_prepaid_global function (P2 defer). Topology v3 fix double-count + parent_id filter (xem dev-spec Section 0.2)
  • [ ] Hasura permissions chỉ deploy queries P1 — bỏ qua queries Phase 3+

Tài liệu:

  • [ ] Team đã đọc hiểu Bảng thuật ngữ A9 — quiz nhanh 5 phút
  • [ ] QA/Tester đã đọc hiểu Acceptance Criteria từng US thuộc Phase 1 (US-1.x, US-2.x, US-3.x, US-4.x). US-5.x (Marketing), US-6.x (Nhân viên) bỏ qua trong Phase 1
  • [ ] Design spec, UI spec đã share cho toàn team