Appearance
PRD — Dashboard Tổng hợp Chiến dịch Voucher
Version: 1.0 Ngày: 2026-03-19 Tác giả: PO/BA Trạng thái: Spec
Z) Decision Log
Z1 — Business Decisions
| ID | Quyết định | Lý do | Ngày |
|---|---|---|---|
| DEC-B01 | Dashboard là màn hình riêng (không gộp vào chi tiết campaign) | Cần so sánh nhiều chiến dịch cùng lúc — Report tab hiện có chỉ xem 1 campaign | 2026-03-19 |
| DEC-B02 | Gộp voucher campaign + gift campaign trong cùng bảng | Cùng data source (user_vouchers + voucher_campaigns), chỉ khác voucher_type | 2026-03-19 |
| DEC-B03 | Gift ad-hoc (manual toggle) ngoài scope | Gift manual không có campaign_id, không nhóm theo chiến dịch được. Xem ở Voucher Gift Order Report | 2026-03-19 |
| DEC-B04 | Gift có kế hoạch → đi qua campaign (Hướng 3) | Gift sự kiện cần lifecycle tracking (phát → dùng → hết hạn). Gift ad-hoc giữ flow bật nhãn hiện tại | 2026-03-19 |
| DEC-B05 | 6 chỉ số core, chi phí + ROI hoãn phase sau | Chi phí quà tặng (mua quà, in ấn) không có trong DB — cần thêm field nhập tay, effort lớn | 2026-03-19 |
| DEC-B06 | Doanh số = Thực thu (order.paid_amount) | Sếp hỏi "phát sinh bao nhiêu doanh số" = tiền KH thực trả. Khác với Report tab chi tiết campaign (dùng gross revenue từ voucher_logs) — cần tooltip giải thích | 2026-03-19 |
| DEC-B07 | "Tổng phát" = COUNT user_vouchers WHERE status != 'cancelled' | Voucher cancelled đã bị hủy, không còn giá trị → không tính vào tổng phát | 2026-03-19 |
Z2 — UX Decisions
| ID | Quyết định | Lý do | Ngày |
|---|---|---|---|
| DEC-U01 | Bảng sort/filter, không cần biểu đồ v1 | Bảng đủ dùng cho so sánh. Biểu đồ thêm sau nếu cần | 2026-03-19 |
| DEC-U02 | Export client-side sync (không async) | Nhất quán với pattern Voucher Gift Order Report. Data chỉ ~100-500 campaigns | 2026-03-19 |
| DEC-U03 | Default filter: Đang phát hành + Kết thúc, 3 tháng gần nhất | Chiến dịch Draft/Hủy ít cần xem. 3 tháng đủ cover 1 quý | 2026-03-19 |
Z3 — Technical Decisions
| ID | Quyết định | Lý do | Ngày |
|---|---|---|---|
| DEC-T01 | 2 DB functions: _list (paginated) + _aggregate (summary cards) | Summary Cards cần aggregate toàn bộ campaigns, không tính từ paginated rows (20/page) | 2026-03-19 |
| DEC-T02 | TB ngày sử dụng tính từ voucher_logs (không dùng redeemed_at) | Nhất quán với DEC-T05 trong voucher-enhancement PRD — xử lý đúng case voucher restored khi ĐH hủy | 2026-03-19 |
| DEC-T03 | Filter chi nhánh v1: chỉ check applicable_branch | Cần verify excluded_branch trong code hiện có trước. Functions cũ chưa check → v1 cũng chưa | 2026-03-19 |
| DEC-T04 | Không cần Go code — DB functions + Hasura expose | Pattern giống get_voucher_analytics_summary. Hasura track function → expose qua GraphQL | 2026-03-19 |
| DEC-T05 | Paginate campaigns TRƯỚC, tính metrics SAU | Hasura không push-down LIMIT/OFFSET vào function (bottleneck B1 đã xác nhận). Function PHẢI self-paginate: lọc 20 campaigns trước → chỉ tính metrics cho 20 đó | 2026-03-19 |
| DEC-T06 | voucher_logs dùng ROW_NUMBER() pattern, không dùng nhiều JOIN riêng | 4 JOIN riêng với voucher_logs = 4× scan cùng dataset (bottleneck B3). Pattern proven trong migration 1769585514000 | 2026-03-19 |
| DEC-T07 | Aggregate function KHÔNG join order table | Summary Cards chỉ cần count campaigns + vouchers (từ user_vouchers). JOIN 1M+ orders chỉ khi cần revenue → chỉ trong list function cho 20 campaigns | 2026-03-19 |
| DEC-T08 | FE: 2 GraphQL queries riêng biệt, aggregate dùng cache policy | Aggregate không đổi khi đổi page → cache-and-network. List query → network-only per page | 2026-03-19 |
Z4 — QA Decisions
| ID | Quyết định | Lý do | Ngày |
|---|---|---|---|
| DEC-Q01 | Test conversion_rate edge case: campaign Draft (0 voucher) | Chia cho 0 → phải hiển thị "—", không hiện NaN hay 0% | 2026-03-19 |
| DEC-Q02 | Test doanh số khi ĐH bị hủy → voucher restored | Voucher chuyển về activated → tự động exclude khỏi dashboard. Verify revenue không bị tính sai | 2026-03-19 |
A) PRD
A1. Blueprint
| Thuộc tính | Giá trị |
|---|---|
| Tên tính năng | Dashboard Tổng hợp Chiến dịch Voucher |
| Loại | New feature (màn hình mới trong module hiện có) |
| Complexity | Small |
| Modules ảnh hưởng | cms (voucher) |
| Services ảnh hưởng | controller (Hasura) |
| Platform | Admin web |
A2. Context
Hiện trạng:
- Module voucher management có Report tab trong chi tiết từng campaign — xem funnel, thống kê theo chi nhánh/sản phẩm/ngày cho 1 campaign
- Không có cách xem tổng hợp nhiều chiến dịch cùng lúc để so sánh hiệu quả
- Voucher Gift Order Report (đang phát triển) xem theo góc ĐH — mỗi row = 1 lượt dùng voucher/gift
Vấn đề:
- Sếp muốn: "Sự kiện 8/3 phát 1000 voucher + 1000 quà tặng → vài tháng sau muốn biết bao nhiêu đã dùng, hết hạn, doanh số phát sinh"
- Hiện phải vào từng campaign → xem Report tab → ghi nhớ số liệu → so sánh thủ công
- Không có overview nào cho toàn bộ chiến dịch
Giải pháp:
- Dashboard riêng hiển thị tất cả campaigns dạng bảng, mỗi row = 1 campaign với 6 chỉ số core
- Sort/filter để so sánh, export Excel
A3. Goals & Success Metrics
| Goal | Metric | Target |
|---|---|---|
| Xem tổng hợp chiến dịch nhanh | Thời gian từ login → xem dashboard | < 3 clicks, < 3s load |
| So sánh hiệu quả chiến dịch | Số chiến dịch hiển thị cùng lúc | 20 per page, sort theo bất kỳ chỉ số |
| Export báo cáo cho sếp | Export Excel hoạt động | Đúng data, đúng filter |
A4. Personas
| Persona | Vai trò | Nhu cầu chính |
|---|---|---|
| Admin/BOD | Quản lý toàn hệ thống | So sánh hiệu quả tất cả chiến dịch, export báo cáo |
| Manager chi nhánh | Quản lý 1 chi nhánh | Xem chiến dịch áp dụng tại chi nhánh mình, doanh số phát sinh |
| Kế toán | Đối soát | Export doanh thu từ voucher/gift per chiến dịch |
A5. Functional Requirements
| FR ID | Mô tả | Acceptance Criteria | Ref |
|---|---|---|---|
| FR-001 | Thêm màn hình Dashboard tổng hợp chiến dịch tại /cms/voucher-management/dashboard | User có quyền VOUCHER_REPORT_ACCESS truy cập → thấy bảng chiến dịch + 4 KPI cards. Load < 3s | DEC-B01 |
| FR-002 | 4 Summary Cards: Tổng chiến dịch, Tổng phát ra, Tổng đã sử dụng, Tỷ lệ sử dụng TB | Cards hiển thị aggregate toàn bộ campaigns matching filter (không chỉ page hiện tại). VD: 12 chiến dịch, 5200 phát, 1820 dùng, 35.0% | DEC-T01 |
| FR-003 | Bảng 12 cột per campaign: tên, loại, trạng thái, thời gian, tổng phát, đã dùng, chưa dùng, hết hạn, tỷ lệ %, thực thu, thực thu TB/KH, TB ngày sử dụng | Mỗi campaign = 1 row. Sort ↑↓ theo bất kỳ cột số nào. Pagination 20 rows/page. Click row → chi tiết campaign | DEC-B02, DEC-B06 |
| FR-004 | 5 Filter: thời gian, trạng thái, loại chiến dịch, chi nhánh, tìm kiếm | Filter áp dụng cho cả bảng + summary cards. Default: Đang phát hành + Kết thúc, 3 tháng gần nhất. Chi nhánh filter theo applicable_branch conditions | DEC-U03, DEC-T03 |
| FR-005 | Export Excel | Export tất cả 12 cột + mã chiến dịch + kênh phát. Theo filter đang chọn. Client-side sync. File: dashboard-chien-dich-YYYY-MM-DD.xlsx | DEC-U02 |
| FR-006 | Bổ sung Manager vào VOUCHER_REPORT_ACCESS_ROLES | Manager truy cập Dashboard → chỉ thấy campaigns áp dụng tại chi nhánh mình + doanh số chỉ tính ĐH tại chi nhánh mình | DEC-B01 |
| FR-007 | Hiển thị 5 trạng thái campaign: Bản nháp / Đang phát hành / Tạm dừng / Kết thúc / Hủy | Badge đúng mapping: vc_status_draft → Bản nháp, vc_status_published → Đang phát hành, vc_status_stopped → Tạm dừng, vc_status_ended → Kết thúc, vc_status_cancelled → Hủy | — |
| FR-008 | Hiển thị 6 loại voucher: Giảm % / Giảm VNĐ / Tặng DV / Tặng SP / Lời chúc / Khác | Badge đúng mapping: gift_type_discount_percent → Giảm %, gift_type_discount_vnd → Giảm VNĐ, gift_type_service → Tặng DV, gift_type_cosmetic → Tặng SP, gift_type_greeting → Lời chúc, gift_type_other → Khác | — |
| FR-009 | Tooltip giải thích "Thực thu" | Cột "Thực thu" hiển thị tooltip: "Thực thu = tổng thanh toán ĐH có sử dụng voucher/gift (sau giảm giá)" | DEC-B06 |
A6. Assumptions
| ID | Assumption | Impact nếu sai |
|---|---|---|
| ASM-01 | user_vouchers.order_id được set đúng khi voucher redeemed | FORMULA-002 (Thực thu) sẽ thiếu data → revenue = 0 |
| ASM-02 | Voucher restored khi ĐH hủy (restore_voucher_on_order_cancel event hoạt động đúng) | Revenue bị tính sai (ĐH hủy vẫn cộng doanh số) |
| ASM-03 | voucher_campaigns_conditions lưu applicable_branch cho campaigns có giới hạn chi nhánh | Filter chi nhánh không hoạt động → hiển thị sai campaigns |
A7. Risks
| ID | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| RSK-01 | "Thực thu" khác số liệu trong Report tab chi tiết campaign (dùng gross revenue) → sếp confused | Medium | Medium | Tooltip giải thích rõ (FR-009). Xem xét thống nhất cách tính trong tương lai |
| RSK-02 | Manager không thấy Dashboard (chưa có trong VOUCHER_REPORT_ACCESS_ROLES) | High | High | FR-006 bổ sung quyền. BLOCKER nếu không làm |
| RSK-03 | Query chậm khi JOIN voucher_logs cho FORMULA-004 | Low | Medium | ROW_NUMBER() pattern (1 JOIN thay 4) + index idx_voucher_logs_action_redeemed đã có |
| RSK-04 | Sort theo revenue/avg_days cần tính metrics trước khi paginate → chậm | Medium | Medium | Khi sort theo metrics (cột 9-12), function phải tính metrics TẤT CẢ campaigns trước rồi mới sort + paginate. Mitigations: (1) Default sort = start_at (không cần metrics), (2) Sort theo metrics chỉ khi user chọn, (3) Có thể cache kết quả nếu quá chậm |
C3) Business Formulas
FORMULA-001: Tỷ lệ sử dụng (Conversion Rate)
- Mô tả: Phần trăm voucher/gift đã được KH sử dụng so với tổng phát
- Công thức:
conversion_rate = total_redeemed / total_issued × 100 - Biến số:
total_redeemed: COUNTuser_vouchersWHEREcampaign_id = XANDstatus = 'redeemed'— nguồn:user_vouchers.statustotal_issued: COUNTuser_vouchersWHEREcampaign_id = XANDstatus != 'cancelled'— nguồn:user_vouchers.status
- Đơn vị: % (1 decimal)
- Ví dụ: Campaign "8/3" phát 1000 voucher, 350 đã dùng → 350/1000 × 100 = 35.0%
- Edge cases:
total_issued = 0→ hiển thị "—" (không hiện 0% hay NaN)- Campaign status = Draft (chưa phát) →
total_issued = 0→ "—"
FORMULA-002: Thực thu (Net Revenue)
- Mô tả: Tổng tiền KH thực trả trong các ĐH có sử dụng voucher/gift của chiến dịch
- Công thức:
total_revenue = SUM(order.paid_amount) WHERE order.id IN (redeemed_order_ids) - Biến số:
redeemed_order_ids: SELECTorder_idFROMuser_vouchersWHEREcampaign_id = XANDstatus = 'redeemed'ANDorder_id IS NOT NULL— nguồn:user_vouchers.order_idorder.paid_amount: tổng thanh toán ĐH (đã trừ giảm giá voucher) — nguồn:order.paid_amount
- Đơn vị: VNĐ (format: 1,234,567đ)
- Ví dụ: 350 voucher redeemed, mỗi ĐH TB 500K → 350 × 500,000 = 175,000,000đ
- Edge cases:
- ĐH bị hủy (
order.canceled_at IS NOT NULL) → eventrestore_voucher_on_order_cancel→user_vouchers.statuschuyển về'activated'→ tự động exclude khỏi WHEREstatus = 'redeemed' order_id = NULL→ exclude khỏi SUM- Tất cả voucher chưa redeemed → hiển thị "0đ"
- ĐH bị hủy (
- Lưu ý: Khác với Report tab chi tiết campaign (dùng gross revenue từ
voucher_logs.before_voucher_values). Dashboard hiển thị tooltip (FR-009)
FORMULA-003: Thực thu trung bình per KH
- Mô tả: Trung bình thực thu per KH unique đã sử dụng voucher/gift
- Công thức:
avg_revenue = total_revenue / count_distinct_customers - Biến số:
total_revenue: → FORMULA-002count_distinct_customers: COUNT DISTINCTuser_vouchers.customer_idWHEREcampaign_id = XANDstatus = 'redeemed'— nguồn:user_vouchers.customer_id
- Đơn vị: VNĐ (format: 1,234,567đ)
- Ví dụ: Thực thu 175M, 280 KH unique → 175,000,000 / 280 = 625,000đ
- Edge cases:
count_distinct_customers = 0→ hiển thị "—"- 1 KH dùng nhiều voucher cùng campaign → tính 1 KH, cộng tổng revenue
FORMULA-004: TB ngày sử dụng (Avg Days to Redeem)
- Mô tả: Trung bình số ngày từ khi voucher được kích hoạt đến khi sử dụng
- Công thức:
avg_days = AVG(days_to_redeem)tính từvoucher_logs - Biến số:
days_to_redeem: Với mỗi voucher redeemed, lấyvoucher_logs.created_at(WHEREaction = 'voucher_redeemed', lần redeem cuối cùng) -user_vouchers.activated_at, tính theo ngày- Pattern:
DISTINCT ON (voucher_id) WHERE action = 'voucher_redeemed' ORDER BY created_at DESC— nguồn:voucher_logs.created_at,user_vouchers.activated_at
- Đơn vị: Ngày (1 decimal)
- Ví dụ: 3 voucher: 5 ngày, 12 ngày, 7 ngày → AVG = 8.0 ngày
- Edge cases:
- Voucher activated nhưng chưa redeemed → exclude
- Voucher restored rồi redeem lại → lấy lần redeem cuối (DISTINCT ON + ORDER BY DESC)
activated_at = NULL→ exclude- Không có voucher nào redeemed → hiển thị "—"
- Ref: Nhất quán với DEC-T05 trong voucher-enhancement PRD
B) Impact Map
Modules ảnh hưởng
| Module | Thay đổi |
|---|---|
| cms/voucher | Thêm route Dashboard, navigation button, permission update |
Services ảnh hưởng
| Service | Thay đổi |
|---|---|
| controller (Hasura) | 2 DB functions mới, 1 index, 2 Hasura function YAML |
Database changes
| Thay đổi | Object | Ghi chú |
|---|---|---|
| CREATE FUNCTION | get_voucher_campaign_dashboard_list | Paginated rows cho bảng |
| CREATE FUNCTION | get_voucher_campaign_dashboard_aggregate | Summary cards |
| CREATE INDEX | idx_user_vouchers_campaign_status | Composite (campaign_id, status) WHERE NOT NULL |
| ALTER permission | VOUCHER_REPORT_ACCESS_ROLES | Bổ sung Manager role |
Frontend changes
| File/Component | Thay đổi | Loại |
|---|---|---|
vouchers/dashboard/VoucherCampaignDashboard.tsx | Trang chính | 🆕 |
vouchers/dashboard/DashboardSummaryCards.tsx | 4 KPI cards | 🆕 |
vouchers/dashboard/DashboardTable.tsx | Bảng 12 cột + sort + pagination | 🆕 |
vouchers/dashboard/DashboardFilters.tsx | 5 filters | 🆕 |
vouchers/dashboard/useDashboardExport.ts | Hook export Excel | 🆕 |
VoucherTab.tsx | Thêm route /dashboard | Sửa |
VoucherCampaignLayout.tsx | Thêm nút "Dashboard tổng hợp" | Sửa |
voucher.graphql | Thêm 2 queries | Sửa |
voucher-permissions.ts | Thêm Manager vào VOUCHER_REPORT_ACCESS_ROLES | Sửa |
Backend changes
| File | Thay đổi | Loại |
|---|---|---|
controller/migrations/ecommerce/[ts]_voucher_campaign_dashboard/up.sql | 2 functions + 1 index | 🆕 |
controller/metadata/.../functions/public_get_voucher_campaign_dashboard_list.yaml | Hasura tracking + permissions | 🆕 |
controller/metadata/.../functions/public_get_voucher_campaign_dashboard_aggregate.yaml | Hasura tracking + permissions | 🆕 |
Không cần sửa Go code — DB functions + Hasura expose.
C) Technical Notes
C1. DB Function Signatures & Query Strategy
Performance Strategy (ref: DEC-T05, DEC-T06, DEC-T07)
Hệ thống có 1M+ orders. Các bottleneck đã xác nhận từ perf-fix-campaign-detail.md:
- B1: Hasura không push-down LIMIT/OFFSET vào function → function PHẢI self-paginate
- B3: Nhiều JOIN riêng với voucher_logs → dùng 1 JOIN + ROW_NUMBER()
- B4: Expression COALESCE không match index → cần expression index
Nguyên tắc: Paginate campaigns TRƯỚC (nhẹ, ~500 rows) → tính metrics CHỈ cho 20 campaigns trên page hiện tại.
Function 1: List (paginated)
sql
get_voucher_campaign_dashboard_list(
p_start_date TIMESTAMPTZ,
p_end_date TIMESTAMPTZ,
p_statuses TEXT[],
p_voucher_types TEXT[],
p_branch_ids UUID[],
p_search TEXT,
p_sort_column TEXT,
p_sort_direction TEXT,
p_limit INT DEFAULT 20,
p_offset INT DEFAULT 0
)
RETURNS TABLE (
campaign_id UUID,
campaign_name TEXT,
campaign_code TEXT,
voucher_type TEXT,
status TEXT,
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
online_quota INT,
offline_quota INT,
print_quota INT,
total_issued INT,
total_redeemed INT,
total_active INT,
total_expired INT,
conversion_rate NUMERIC(5,1),
total_revenue BIGINT,
avg_revenue_per_customer BIGINT,
avg_days_to_redeem NUMERIC(8,1),
total_count INT
)Query execution order (CRITICAL):
sql
-- Bước 1: Lọc + đếm tổng + paginate campaigns TRƯỚC (nhẹ — ~500 rows max)
WITH filtered_campaigns AS (
SELECT vc.id, vc.name, vc.code, vc.voucher_type, vc.status,
vc.start_at, vc.end_at,
vc.online_quota, vc.offline_quota, vc.print_quota,
COUNT(*) OVER() as total_count -- tổng cho pagination
FROM voucher_campaigns vc
WHERE (p_statuses IS NULL OR vc.status = ANY(p_statuses))
AND (p_voucher_types IS NULL OR vc.voucher_type = ANY(p_voucher_types))
AND (p_start_date IS NULL OR vc.end_at >= p_start_date)
AND (p_end_date IS NULL OR vc.start_at <= p_end_date)
AND (p_search IS NULL OR vc.name ILIKE '%' || p_search || '%'
OR vc.code ILIKE '%' || p_search || '%')
-- branch filter qua voucher_campaigns_conditions (xem C3)
),
-- Bước 1b: Paginate (chỉ lấy 20 campaigns cần hiển thị)
target_campaigns AS (
SELECT * FROM filtered_campaigns
ORDER BY <dynamic sort column>
LIMIT p_limit OFFSET p_offset
),
-- Bước 2: Voucher stats CHỈ cho 20 campaigns (dùng index campaign_status)
voucher_stats AS (
SELECT uv.campaign_id,
COUNT(*) FILTER (WHERE uv.status != 'cancelled') as total_issued,
COUNT(*) FILTER (WHERE uv.status = 'redeemed') as total_redeemed,
COUNT(*) FILTER (WHERE uv.status IN ('issued','activated')) as total_active,
COUNT(*) FILTER (WHERE uv.status = 'expired') as total_expired
FROM user_vouchers uv
WHERE uv.campaign_id IN (SELECT id FROM target_campaigns)
GROUP BY uv.campaign_id
),
-- Bước 3: Revenue CHỈ cho 20 campaigns (JOIN order — giới hạn scope)
revenue_stats AS (
SELECT uv.campaign_id,
SUM(o.paid_amount) as total_revenue,
COUNT(DISTINCT uv.customer_id) as unique_customers
FROM user_vouchers uv
JOIN "order" o ON o.id = uv.order_id
WHERE uv.campaign_id IN (SELECT id FROM target_campaigns)
AND uv.status = 'redeemed'
AND uv.order_id IS NOT NULL
GROUP BY uv.campaign_id
),
-- Bước 4: Cycle time — 1 JOIN + ROW_NUMBER() (ref: DEC-T06)
voucher_logs_ranked AS (
SELECT
vl.voucher_id,
vl.created_at as redeemed_at,
ROW_NUMBER() OVER (
PARTITION BY vl.voucher_id
ORDER BY vl.created_at DESC
) as rn
FROM voucher_logs vl
WHERE vl.voucher_id IN (
SELECT uv.id FROM user_vouchers uv
WHERE uv.campaign_id IN (SELECT id FROM target_campaigns)
AND uv.status = 'redeemed'
)
AND vl.action = 'voucher_redeemed'
),
cycle_stats AS (
SELECT uv.campaign_id,
AVG(EXTRACT(EPOCH FROM (vlr.redeemed_at - uv.activated_at)) / 86400.0) as avg_days
FROM voucher_logs_ranked vlr
JOIN user_vouchers uv ON uv.id = vlr.voucher_id
WHERE vlr.rn = 1 -- chỉ lấy lần redeem cuối
AND uv.activated_at IS NOT NULL
GROUP BY uv.campaign_id
)
-- Bước 5: JOIN tất cả
SELECT
tc.id, tc.name, tc.code, tc.voucher_type, tc.status,
tc.start_at, tc.end_at,
tc.online_quota, tc.offline_quota, tc.print_quota,
COALESCE(vs.total_issued, 0),
COALESCE(vs.total_redeemed, 0),
COALESCE(vs.total_active, 0),
COALESCE(vs.total_expired, 0),
CASE WHEN vs.total_issued > 0
THEN ROUND(vs.total_redeemed::numeric / vs.total_issued * 100, 1)
ELSE NULL END,
COALESCE(rs.total_revenue, 0),
CASE WHEN rs.unique_customers > 0
THEN rs.total_revenue / rs.unique_customers
ELSE NULL END,
ROUND(cs.avg_days::numeric, 1),
tc.total_count
FROM target_campaigns tc
LEFT JOIN voucher_stats vs ON vs.campaign_id = tc.id
LEFT JOIN revenue_stats rs ON rs.campaign_id = tc.id
LEFT JOIN cycle_stats cs ON cs.campaign_id = tc.id;Function 2: Aggregate (Summary Cards)
sql
get_voucher_campaign_dashboard_aggregate(
p_start_date TIMESTAMPTZ,
p_end_date TIMESTAMPTZ,
p_statuses TEXT[],
p_voucher_types TEXT[],
p_branch_ids UUID[],
p_search TEXT
)
RETURNS TABLE (
total_campaigns INT,
total_vouchers_issued INT,
total_vouchers_redeemed INT,
overall_conversion_rate NUMERIC(5,1)
)KHÔNG JOIN order table (ref: DEC-T07). Chỉ aggregate từ user_vouchers:
sql
WITH filtered_campaigns AS (
-- Cùng filter logic với function 1 (KHÔNG paginate)
SELECT vc.id FROM voucher_campaigns vc WHERE ...
)
SELECT
COUNT(DISTINCT fc.id) as total_campaigns,
SUM(vs.total_issued) as total_vouchers_issued,
SUM(vs.total_redeemed) as total_vouchers_redeemed,
CASE WHEN SUM(vs.total_issued) > 0
THEN ROUND(SUM(vs.total_redeemed)::numeric / SUM(vs.total_issued) * 100, 1)
ELSE NULL END
FROM filtered_campaigns fc
LEFT JOIN (
SELECT campaign_id,
COUNT(*) FILTER (WHERE status != 'cancelled') as total_issued,
COUNT(*) FILTER (WHERE status = 'redeemed') as total_redeemed
FROM user_vouchers
WHERE campaign_id IN (SELECT id FROM filtered_campaigns)
GROUP BY campaign_id
) vs ON vs.campaign_id = fc.id;C2. Indexes (3 indexes — BLOCKER, deploy trước feature)
sql
-- Index 1: Voucher stats per campaign (COUNT by status)
-- Covers: Bước 2 (voucher_stats) trong function list + aggregate
CREATE INDEX CONCURRENTLY idx_user_vouchers_campaign_status
ON user_vouchers (campaign_id, status)
WHERE campaign_id IS NOT NULL;
-- Index 2: Redeemed vouchers with order_id (revenue JOIN)
-- Covers: Bước 3 (revenue_stats) — chỉ scan redeemed vouchers có order
CREATE INDEX CONCURRENTLY idx_user_vouchers_redeemed_order
ON user_vouchers (campaign_id, order_id)
WHERE status = 'redeemed' AND order_id IS NOT NULL;
-- Index 3: voucher_logs cho cycle time (đã có idx_voucher_logs_action_redeemed)
-- Verify: idx_voucher_logs_action_redeemed ON (action, voucher_id) WHERE action='voucher_redeemed'
-- → ĐÃ ĐỦ cho Bước 4. Không cần thêm index.Tổng: 2 indexes mới + 1 index đã có (verify).
C3. Chi nhánh Filter Logic
Campaign không thuộc 1 chi nhánh — áp dụng qua voucher_campaigns_conditions:
sql
-- v1: Chỉ check applicable_branch
WHERE vc.id NOT IN (
SELECT campaign_id FROM voucher_campaigns_conditions
WHERE reference_type = 'vc_condition_applicable_branch'
)
OR vc.id IN (
SELECT campaign_id FROM voucher_campaigns_conditions
WHERE reference_type = 'vc_condition_applicable_branch'
AND (reference_value = 'ALL' OR reference_value LIKE '%' || p_branch_id || '%')
)Doanh số khi filter chi nhánh: chỉ tính order.branch_id = p_branch_id.
C4. Performance
| Scenario | Target | Ghi chú |
|---|---|---|
| List 20 rows (initial load) | < 1.5s | Paginate campaigns trước, metrics cho 20 |
| Aggregate (summary cards) | < 1s | Không JOIN order, chỉ count user_vouchers |
| Tổng page load (2 queries song song) | < 2s | FE gọi parallel |
| Đổi page (page 2, 3...) | < 1.5s | Chỉ refetch list, aggregate cached |
| Export 500 campaigns | < 30s | Client-side sync |
| Worst case: 500 campaigns × 200K vouchers × 1M+ orders | < 3s (list) | Nhờ paginate-first strategy |
Scale hiện tại: ~100-500 campaigns, ~200K-500K user_vouchers, 1M+ orders, ~100K-500K voucher_logs.
Performance so sánh:
| Approach | Estimated time (1M+ orders) | Lý do |
|---|---|---|
| ❌ Tính metrics TẤT CẢ campaigns → paginate | 10-15s | JOIN 1M orders cho 500 campaigns |
| ✅ Paginate campaigns → tính metrics 20 | 1-2s | JOIN orders chỉ cho 20 campaigns |
| Gain | ~8-10x |
C5. FE Query Strategy (ref: DEC-T08)
graphql
# Query 1: List — refetch per page change
query GetCampaignDashboardList($args: ..., $offset: Int, $limit: Int) {
get_voucher_campaign_dashboard_list(args: $args) {
campaign_id, campaign_name, ...metrics...
total_count # cho pagination
}
}
# Query 2: Aggregate — cache persistent, fetch 1 lần per filter change
query GetCampaignDashboardAggregate($args: ...) {
get_voucher_campaign_dashboard_aggregate(args: $args) {
total_campaigns, total_vouchers_issued, total_vouchers_redeemed,
overall_conversion_rate
}
}URQL request policies:
- List:
requestPolicy: "network-only"— refetch per page - Aggregate:
requestPolicy: "cache-and-network"— chỉ refetch khi filter thay đổi, không refetch khi đổi page
C6. Security
- Permission:
VOUCHER_REPORT_ACCESS_ROLES(bổ sung Manager) - Manager branch scoping: FE filter
branch_idtừ user session (pattern hiện có) - Lưu ý: Branch scoping chỉ ở FE (RSK-005 hệ thống — không thuộc scope feature này)
C7. Dependencies
- Độc lập với voucher-enhancement (Phase 1-4)
- Độc lập với voucher-gift-order-report
- Chỉ đọc data → không conflict với bất kỳ feature nào
- Có thể phát triển song song
Hướng dẫn đọc
| Audience | Đọc sections | Mục đích |
|---|---|---|
| PO/BA | Z, A | Xác nhận requirements, decisions |
| Tech Lead | Z3, B, C | Review impact, data model, function signatures |
| Backend Dev | C (Technical Notes) | Implement DB functions, Hasura metadata |
| Frontend Dev | A5 (FRs), B (Frontend changes) | Implement UI components |
| QA | A5 (AC), Z4, C3 (Formulas edge cases) | Viết test cases |
Changelog
| Version | Ngày | Thay đổi |
|---|---|---|
| 1.0 | 2026-03-19 | Initial PRD — chuyển từ design doc (v1.2, 2 rounds review passed) |
| 1.1 | 2026-03-19 | Performance optimization: paginate-first strategy, ROW_NUMBER() pattern, 2 indexes thêm, aggregate không JOIN order, FE query caching, RSK-04 sort metrics |