Skip to content

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

IDQuyết địnhLý doNgày
DEC-B01Dashboard 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 campaign2026-03-19
DEC-B02Gộp voucher campaign + gift campaign trong cùng bảngCùng data source (user_vouchers + voucher_campaigns), chỉ khác voucher_type2026-03-19
DEC-B03Gift ad-hoc (manual toggle) ngoài scopeGift manual không có campaign_id, không nhóm theo chiến dịch được. Xem ở Voucher Gift Order Report2026-03-19
DEC-B04Gift 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ại2026-03-19
DEC-B056 chỉ số core, chi phí + ROI hoãn phase sauChi phí quà tặng (mua quà, in ấn) không có trong DB — cần thêm field nhập tay, effort lớn2026-03-19
DEC-B06Doanh 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ích2026-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át2026-03-19

Z2 — UX Decisions

IDQuyết địnhLý doNgày
DEC-U01Bảng sort/filter, không cần biểu đồ v1Bảng đủ dùng cho so sánh. Biểu đồ thêm sau nếu cần2026-03-19
DEC-U02Export client-side sync (không async)Nhất quán với pattern Voucher Gift Order Report. Data chỉ ~100-500 campaigns2026-03-19
DEC-U03Default filter: Đang phát hành + Kết thúc, 3 tháng gần nhấtChiến dịch Draft/Hủy ít cần xem. 3 tháng đủ cover 1 quý2026-03-19

Z3 — Technical Decisions

IDQuyết địnhLý doNgày
DEC-T012 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-T02TB 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ủy2026-03-19
DEC-T03Filter chi nhánh v1: chỉ check applicable_branchCần verify excluded_branch trong code hiện có trước. Functions cũ chưa check → v1 cũng chưa2026-03-19
DEC-T04Không cần Go code — DB functions + Hasura exposePattern giống get_voucher_analytics_summary. Hasura track function → expose qua GraphQL2026-03-19
DEC-T05Paginate campaigns TRƯỚC, tính metrics SAUHasura 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-T06voucher_logs dùng ROW_NUMBER() pattern, không dùng nhiều JOIN riêng4 JOIN riêng với voucher_logs = 4× scan cùng dataset (bottleneck B3). Pattern proven trong migration 17695855140002026-03-19
DEC-T07Aggregate function KHÔNG join order tableSummary Cards chỉ cần count campaigns + vouchers (từ user_vouchers). JOIN 1M+ orders chỉ khi cần revenue → chỉ trong list function cho 20 campaigns2026-03-19
DEC-T08FE: 2 GraphQL queries riêng biệt, aggregate dùng cache policyAggregate không đổi khi đổi page → cache-and-network. List query → network-only per page2026-03-19

Z4 — QA Decisions

IDQuyết địnhLý doNgày
DEC-Q01Test 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-Q02Test doanh số khi ĐH bị hủy → voucher restoredVoucher chuyển về activated → tự động exclude khỏi dashboard. Verify revenue không bị tính sai2026-03-19

A) PRD

A1. Blueprint

Thuộc tínhGiá trị
Tên tính năngDashboard Tổng hợp Chiến dịch Voucher
LoạiNew feature (màn hình mới trong module hiện có)
ComplexitySmall
Modules ảnh hưởngcms (voucher)
Services ảnh hưởngcontroller (Hasura)
PlatformAdmin 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

GoalMetricTarget
Xem tổng hợp chiến dịch nhanhThời gian từ login → xem dashboard< 3 clicks, < 3s load
So sánh hiệu quả chiến dịchSố chiến dịch hiển thị cùng lúc20 per page, sort theo bất kỳ chỉ số
Export báo cáo cho sếpExport Excel hoạt độngĐúng data, đúng filter

A4. Personas

PersonaVai tròNhu cầu chính
Admin/BODQuản lý toàn hệ thốngSo sánh hiệu quả tất cả chiến dịch, export báo cáo
Manager chi nhánhQuản lý 1 chi nhánhXem chiến dịch áp dụng tại chi nhánh mình, doanh số phát sinh
Kế toánĐối soátExport doanh thu từ voucher/gift per chiến dịch

A5. Functional Requirements

FR IDMô tảAcceptance CriteriaRef
FR-001Thêm màn hình Dashboard tổng hợp chiến dịch tại /cms/voucher-management/dashboardUser có quyền VOUCHER_REPORT_ACCESS truy cập → thấy bảng chiến dịch + 4 KPI cards. Load < 3sDEC-B01
FR-0024 Summary Cards: Tổng chiến dịch, Tổng phát ra, Tổng đã sử dụng, Tỷ lệ sử dụng TBCards 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-003Bả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ụngMỗi campaign = 1 row. Sort ↑↓ theo bất kỳ cột số nào. Pagination 20 rows/page. Click row → chi tiết campaignDEC-B02, DEC-B06
FR-0045 Filter: thời gian, trạng thái, loại chiến dịch, chi nhánh, tìm kiếmFilter á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 conditionsDEC-U03, DEC-T03
FR-005Export ExcelExport 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.xlsxDEC-U02
FR-006Bổ sung Manager vào VOUCHER_REPORT_ACCESS_ROLESManager 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ìnhDEC-B01
FR-007Hiể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ủyBadge đú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-008Hiển thị 6 loại voucher: Giảm % / Giảm VNĐ / Tặng DV / Tặng SP / Lời chúc / KhácBadge đú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-009Tooltip 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

IDAssumptionImpact nếu sai
ASM-01user_vouchers.order_id được set đúng khi voucher redeemedFORMULA-002 (Thực thu) sẽ thiếu data → revenue = 0
ASM-02Voucher 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-03voucher_campaigns_conditions lưu applicable_branch cho campaigns có giới hạn chi nhánhFilter chi nhánh không hoạt động → hiển thị sai campaigns

A7. Risks

IDRiskLikelihoodImpactMitigation
RSK-01"Thực thu" khác số liệu trong Report tab chi tiết campaign (dùng gross revenue) → sếp confusedMediumMediumTooltip giải thích rõ (FR-009). Xem xét thống nhất cách tính trong tương lai
RSK-02Manager không thấy Dashboard (chưa có trong VOUCHER_REPORT_ACCESS_ROLES)HighHighFR-006 bổ sung quyền. BLOCKER nếu không làm
RSK-03Query chậm khi JOIN voucher_logs cho FORMULA-004LowMediumROW_NUMBER() pattern (1 JOIN thay 4) + index idx_voucher_logs_action_redeemed đã có
RSK-04Sort theo revenue/avg_days cần tính metrics trước khi paginate → chậmMediumMediumKhi 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: COUNT user_vouchers WHERE campaign_id = X AND status = 'redeemed' — nguồn: user_vouchers.status
    • total_issued: COUNT user_vouchers WHERE campaign_id = X AND status != '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: SELECT order_id FROM user_vouchers WHERE campaign_id = X AND status = 'redeemed' AND order_id IS NOT NULL — nguồn: user_vouchers.order_id
    • order.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) → event restore_voucher_on_order_canceluser_vouchers.status chuyển về 'activated' → tự động exclude khỏi WHERE status = 'redeemed'
    • order_id = NULL → exclude khỏi SUM
    • Tất cả voucher chưa redeemed → hiển thị "0đ"
  • 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-002
    • count_distinct_customers: COUNT DISTINCT user_vouchers.customer_id WHERE campaign_id = X AND status = '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ấy voucher_logs.created_at (WHERE action = '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

ModuleThay đổi
cms/voucherThêm route Dashboard, navigation button, permission update

Services ảnh hưởng

ServiceThay đổi
controller (Hasura)2 DB functions mới, 1 index, 2 Hasura function YAML

Database changes

Thay đổiObjectGhi chú
CREATE FUNCTIONget_voucher_campaign_dashboard_listPaginated rows cho bảng
CREATE FUNCTIONget_voucher_campaign_dashboard_aggregateSummary cards
CREATE INDEXidx_user_vouchers_campaign_statusComposite (campaign_id, status) WHERE NOT NULL
ALTER permissionVOUCHER_REPORT_ACCESS_ROLESBổ sung Manager role

Frontend changes

File/ComponentThay đổiLoại
vouchers/dashboard/VoucherCampaignDashboard.tsxTrang chính🆕
vouchers/dashboard/DashboardSummaryCards.tsx4 KPI cards🆕
vouchers/dashboard/DashboardTable.tsxBảng 12 cột + sort + pagination🆕
vouchers/dashboard/DashboardFilters.tsx5 filters🆕
vouchers/dashboard/useDashboardExport.tsHook export Excel🆕
VoucherTab.tsxThêm route /dashboardSửa
VoucherCampaignLayout.tsxThêm nút "Dashboard tổng hợp"Sửa
voucher.graphqlThêm 2 queriesSửa
voucher-permissions.tsThêm Manager vào VOUCHER_REPORT_ACCESS_ROLESSửa

Backend changes

FileThay đổiLoại
controller/migrations/ecommerce/[ts]_voucher_campaign_dashboard/up.sql2 functions + 1 index🆕
controller/metadata/.../functions/public_get_voucher_campaign_dashboard_list.yamlHasura tracking + permissions🆕
controller/metadata/.../functions/public_get_voucher_campaign_dashboard_aggregate.yamlHasura 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

ScenarioTargetGhi chú
List 20 rows (initial load)< 1.5sPaginate campaigns trước, metrics cho 20
Aggregate (summary cards)< 1sKhông JOIN order, chỉ count user_vouchers
Tổng page load (2 queries song song)< 2sFE gọi parallel
Đổi page (page 2, 3...)< 1.5sChỉ refetch list, aggregate cached
Export 500 campaigns< 30sClient-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:

ApproachEstimated time (1M+ orders)Lý do
❌ Tính metrics TẤT CẢ campaigns → paginate10-15sJOIN 1M orders cho 500 campaigns
✅ Paginate campaigns → tính metrics 201-2sJOIN 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_id từ 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 sectionsMục đích
PO/BAZ, AXác nhận requirements, decisions
Tech LeadZ3, B, CReview impact, data model, function signatures
Backend DevC (Technical Notes)Implement DB functions, Hasura metadata
Frontend DevA5 (FRs), B (Frontend changes)Implement UI components
QAA5 (AC), Z4, C3 (Formulas edge cases)Viết test cases

Changelog

VersionNgàyThay đổi
1.02026-03-19Initial PRD — chuyển từ design doc (v1.2, 2 rounds review passed)
1.12026-03-19Performance optimization: paginate-first strategy, ROW_NUMBER() pattern, 2 indexes thêm, aggregate không JOIN order, FE query caching, RSK-04 sort metrics