Appearance
Dev Spec — Báo cáo Đơn hàng Voucher/Quà tặng
Ref: PRD v1.0 | Date: 19/03/2026
C1) Scope
Modules ảnh hưởng
| Module | Loại | Thay đổi |
|---|---|---|
report | FE | Thêm tab mới + 2 components + 1 GraphQL query + i18n + types |
ecommerce DB | DB | 4 indexes mới (migration) |
ecommerce DB | DB | Seed data report_role |
Không thuộc scope Dev Spec
- Sửa logic các tab Revenue Report hiện có
- Tạo bảng/table mới trong database
- Thêm Hasura relationship mới
- Tạo backend Go action/handler mới
- Materialized view (Phase 2 nếu cần)
C2) Impact Summary
| Component | Đã có | Cần thêm/sửa | Risk |
|---|---|---|---|
RevenueReport.tsx | 5 tabs, shared filter | Thêm tab 6 + conditional filter render | Med |
types.ts | Route constants + REPORT_TREE | Thêm constant + update REPORT_TREE | Low |
report_revenue.graphql | 5 queries | Thêm 1 query mới (3 operations) | Low |
i18n/vi.ts | Existing labels | Thêm ~15 labels mới | Low |
order_item table | Không có index trên gifted_id/voucher_discount_amount | 2 indexes mới | High |
order table | Không có index trên created_at | 1 composite index mới | High |
user_vouchers table | Không có index trên gift_type | 1 index mới | Med |
C3) Rules & Formulas
FORMULA-001: Tỷ lệ giảm trung bình
discount_rate = total_voucher_discount / (total_paid_amount + total_voucher_discount) × 100
Đơn vị: % (1 decimal)
Ví dụ: voucher_discount = 45.200.000đ, paid_amount = 320.500.000đ
→ 45.200.000 / (320.500.000 + 45.200.000) × 100 = 12.4%
Edge case:
- total_paid_amount + total_voucher_discount = 0 → hiển thị "—"
- Chỉ có gift type "Lời chúc" (discount = 0) → tỷ lệ = 0.0%FORMULA-002: Logic render cột Campaign
SWITCH (order_item.gifted_from):
CASE "gifted_from_voucher" → gift.voucher_campaign.name
CASE "gifted_from_gamification" → gift.voucher_name
CASE "gifted_from_wheel_of_fortune" → gift.voucher_name
CASE "gifted_from_friend" → "Quà từ bạn bè" (static)
CASE "gifted_from_manual" → "Tặng thủ công" (static)
DEFAULT → "—"
Ghi chú: gamification/wheel dùng voucher_name vì tên voucher đã chứa thông tin chương trình.
Chỉ cần query riêng bảng gamification/reward_wheel cho filter dropdown, không cho cột hiển thị.FORMULA-003: Visual grouping (dim repeated cells)
Khi render bảng, group order_items theo order_id:
- Dòng đầu tiên của mỗi order: render đầy đủ 13 cột
- Dòng 2+ cùng order: ẩn cột 2-5, 11-13 (order-level), chỉ render STT (cột 1) + cột 6-10 (item-level)
Cột STT luôn hiển thị (đánh liên tục 1, 2, 3... không reset khi group).
Implementation: Trong loop render, so sánh order_id hiện tại với order_id dòng trước.
Nếu giống → render empty cells cho cột order-level (trừ STT).FORMULA-004: Filter "Trạng thái đơn" → WHERE condition mapping
| UI Option | WHERE condition |
|------------------|----------------------------------------------------------|
| Trừ ĐH đã hủy | canceled_at: { _is_null: true } | ← DEFAULT
| Tất cả | (bỏ điều kiện canceled_at) |
| Hoàn tất | canceled_at: { _is_null: true }, paid_at: { _is_null: false } |
| Đang xử lý | canceled_at: { _is_null: true }, paid_at: { _is_null: true } |
| Đã hủy | canceled_at: { _is_null: false } |FORMULA-005: Logic render cột "Trạng thái" (cột 12)
SWITCH:
IF order.canceled_at IS NOT NULL → "Đã hủy"
IF order.paid_at IS NOT NULL → "Hoàn tất"
ELSE → "Đang xử lý"
Nguồn field: order.canceled_at, order.paid_at
Ghi chú: Giống logic render trạng thái ở OrderReportTable hiện có.C4) Data Model
Bảng hiện có (READ ONLY)
Database: ecommerce
| Table | Dùng cho | Key columns |
|---|---|---|
order | Đơn hàng | id, code, created_at, branch_id, total, paid_amount, paid_at, canceled_at, customer_confirmed_at, order_kind |
order_item | Chi tiết dòng đơn hàng | id, order_id, gifted_id, gifted_from, voucher_discount_amount |
user_vouchers | Voucher/quà tặng | id, voucher_name, voucher_code, gift_type, campaign_id, gift_from |
voucher_campaigns | Chiến dịch voucher | id, name, code, status |
order_address | Địa chỉ đơn hàng (customer info) | id, order_id, name, primary_phone, customer_code |
Bảng mới
Không tạo bảng mới.
C5) API + Hasura
Hasura Metadata
Không cần thêm/sửa Hasura metadata. Tất cả relationships đã có sẵn:
order_item.order→order(FK: order_id)order_item.gift→user_vouchers(FK: gifted_id)user_vouchers.voucher_campaign→voucher_campaigns(FK: campaign_id)order.addresses→order_address(FK: order_id)
GraphQL Queries
File: src/modules/report/graphql/report_revenue.graphql
graphql
# Query chính: danh sách + aggregate (gộp trong 1 document)
query ReportVoucherGiftOrderRevenue(
$where_item: order_item_bool_exp!
$where_order_agg: order_bool_exp!
$where_item_agg: order_item_bool_exp!
$order_by: [order_item_order_by!]
$offset: Int
$limit: Int
) {
# List query (order_item level)
order_item(
where: $where_item
order_by: $order_by
offset: $offset
limit: $limit
) {
id
order_id
voucher_discount_amount
gifted_from
gift {
id
voucher_name
voucher_code
gift_type
voucher_campaign {
id
name
}
}
order {
id
code
created_at
total
paid_amount
canceled_at
paid_at
order_kind
branch {
id
name
}
addresses {
name
primary_phone
customer_code
}
}
}
# Order-level aggregate (tổng đơn + thực thu)
order_aggregate(where: $where_order_agg) {
aggregate {
count
sum {
paid_amount
}
}
}
# Order-item aggregate (tổng lượt dùng + tổng giảm)
order_item_aggregate(where: $where_item_agg) {
aggregate {
count
sum {
voucher_discount_amount
}
}
}
}WHERE builder pattern:
typescript
// List query where
const whereItem: order_item_bool_exp = {
_or: [
{ gifted_id: { _is_null: false } },
{ voucher_discount_amount: { _gt: 0 } }
],
order: {
customer_confirmed_at: { _is_null: false },
created_at: { _gte: from, _lte: to },
branch_id: { _in: branchIds },
order_kind: { _in: ["service", "cosmetic", "prepaid"] },
// canceled_at filter depends on Trạng thái đơn filter selection
},
// Optional filters:
// gifted_from: { _in: selectedSources },
// gift: { gift_type: { _in: selectedGiftTypes } },
};
// Default order_by (sort order.created_at DESC qua nested field)
const defaultOrderBy: order_item_order_by = {
order: { created_at: 'desc' }
};
// Order aggregate where — PHẢI sync item-level filters (DEC-014)
// Khi user filter gift_type/gifted_from, summary bar phải phản ánh đúng filter đó
const whereOrderAgg: order_bool_exp = {
order_items: {
_or: [
{ gifted_id: { _is_null: false } },
{ voucher_discount_amount: { _gt: 0 } }
],
// Sync item-level filters vào đây:
...(selectedSources.length && { gifted_from: { _in: selectedSources } }),
...(selectedGiftTypes.length && { gift: { gift_type: { _in: selectedGiftTypes } } }),
},
customer_confirmed_at: { _is_null: false },
created_at: { _gte: from, _lte: to },
branch_id: { _in: branchIds },
order_kind: { _in: ["service", "cosmetic", "prepaid"] },
...statusFilter, // canceled_at filter — xem FORMULA-004
};
// Order-item aggregate where (same conditions as whereItem, without pagination)
const whereItemAgg = { ...whereItem }; // inherits all filters including gifted_from, gift_typeError Contract
| Scenario | Error code | Status | FE handling |
|---|---|---|---|
| Query timeout (> 30s) | TIMEOUT | 200 (GraphQL error) | Show error state + "Vui lòng thu hẹp khoảng thời gian" |
| Unauthorized | UNAUTHORIZED | 401 | Redirect login |
| No data | — | 200 (empty array) | Show empty state |
C6) Frontend Components
File Structure
src/modules/report/
├── pages/
│ └── RevenueReport.tsx ← EDIT (thêm tab + conditional filter)
├── components/
│ └── revenue/
│ ├── VoucherGiftOrderReportTable.tsx ← NEW
│ └── VoucherGiftOrderReportFilter.tsx ← NEW
├── graphql/
│ └── report_revenue.graphql ← EDIT (thêm query)
├── types.ts ← EDIT (thêm constant + REPORT_TREE)
└── i18n/
└── vi.ts ← EDIT (thêm labels)⚠️ Lưu ý về shared/utils/gift.ts
File gift.ts hiện chỉ có 2 giá trị GiftSource: "wheel_of_fortune" và "manual" (không có prefix gifted_from_). Feature này cần 5 giá trị có prefix (gifted_from_gamification, gifted_from_wheel_of_fortune, gifted_from_voucher, gifted_from_friend, gifted_from_manual). Dev cần tạo constant mapping mới trong component hoặc mở rộng gift.ts.
Mapping "Loại giảm" label → gift_type value
| Label UI (abbreviated) | user_vouchers.gift_type value | Mô tả |
|---|---|---|
| DV | gift_type_service | Tặng dịch vụ |
| SP | gift_type_cosmetic | Tặng sản phẩm |
| % giảm | gift_type_discount_percent | Giảm giá theo % |
| VNĐ | gift_type_discount_vnd | Giảm giá theo VNĐ |
| Lời chúc | gift_type_greeting | Lời chúc (không có giá trị giảm) |
| Khác | gift_type_other | Loại khác |
Label nên lấy từ
master_data.descriptionWHEREtype = 'gift_type'thay vì hardcode.
Mapping "Nguồn quà" label → gifted_from value
| Label UI (abbreviated) | order_item.gifted_from value |
|---|---|
| Lắc xì | gifted_from_gamification |
| Vòng quay | gifted_from_wheel_of_fortune |
| Voucher | gifted_from_voucher |
| Bạn bè | gifted_from_friend |
| Thủ công | gifted_from_manual |
Core Logic (pseudocode)
typescript
// VoucherGiftOrderReportTable.tsx
type VoucherGiftFilterType = {
time: { from: string; to: string };
branch: string[];
gifted_from: string[]; // gift source filter
campaign_id: string[]; // campaign/program filter
gift_type: string[]; // discount type filter
order_status: string; // 'all' | 'exclude_cancelled' | 'cancelled' | ...
};
function getVoucherFilterInitValue(): VoucherGiftFilterType {
return {
time: { from: startOfMonth, to: today },
branch: [], // populated from globalStore.account.branches
gifted_from: [],
campaign_id: [],
gift_type: [],
order_status: 'exclude_cancelled',
};
}
// Visual grouping logic
function renderTable(items: OrderItem[]) {
let prevOrderId = null;
return items.map(item => {
const isRepeated = item.order_id === prevOrderId;
prevOrderId = item.order_id;
return { ...item, isRepeatedOrder: isRepeated };
});
}Export Excel — Implementation Detail
Hệ thống hiện có 2 pattern export:
- Client-side sync:
createExcelBuilder()+getAllDataByPaging()→ browser download (dùng bởi tất cả report tabs hiện có) - Server-side async: action handler
reportSalary(duy nhất, hardcode cho salary) → upload MinIO/GCP → trả URL
Cho feature này: dùng client-side sync (pattern 1), không giới hạn row.
Khi data > 10,000 dòng → pause fetch, hiện confirmation dialog cho user chọn tiếp tục hoặc hủy. Consistent với pattern các tab hiện có (không cap).
typescript
// Export flow (client-side, giống pattern useVoucherExport.ts)
async function handleExport(filter: VoucherGiftFilterType) {
const WARNING_THRESHOLD = 10_000;
const PAGE_SIZE = 500;
// 1. Kiểm tra tổng số dòng TRƯỚC KHI fetch (dùng aggregate count đã có sẵn từ summary bar)
// order_item_aggregate.count đã được query khi load page — tái sử dụng giá trị này
const totalCount = orderItemAggregateCount; // từ summary bar query result
if (totalCount > WARNING_THRESHOLD) {
const confirmed = await showConfirmDialog(
i18n.t('export_large_dataset_confirm')
// "Dữ liệu có hơn 10,000 dòng, quá trình export có thể mất vài phút. Tiếp tục?"
);
if (!confirmed) return; // User chọn Hủy → abort, không fetch
}
// 2. Fetch toàn bộ data (recursive pagination, không cap)
let allItems: OrderItem[] = [];
let offset = 0;
while (true) {
const result = await executeQuery({
where: buildWhere(filter),
limit: PAGE_SIZE,
offset,
});
const items = result.data?.order_item ?? [];
allItems = [...allItems, ...items];
offset += PAGE_SIZE;
if (items.length < PAGE_SIZE) break; // no more data
}
// 3. Build Excel (follow pattern WorkingSchedule.tsx / useVoucherExport.ts)
const builder = createExcelBuilder({
sheetName: 'Voucher/Quà tặng',
title: `Báo cáo đơn hàng voucher/quà tặng - ${formatDate(filter.time.from)} đến ${formatDate(filter.time.to)}`,
});
// Header row — dùng addCells() (KHÔNG dùng setColumns)
const headers = [
'STT', 'Mã đơn hàng', 'Ngày tạo', 'Khách hàng', 'Số điện thoại',
'Chi nhánh', 'Tên voucher/quà', 'Mã voucher', 'Loại giảm giá',
'Số tiền giảm (VNĐ)', 'Nguồn quà', 'Campaign/Chương trình',
'Tổng đơn hàng (VNĐ)', 'Thực thu (VNĐ)', 'Trạng thái',
];
builder.addCells(headers, { font: { bold: true } });
// Data rows — dùng convertObjectsToRows() + addRows()
const rows = allItems.map((item, idx) => ({
stt: idx + 1,
order_code: item.order.code,
created_at: formatDateTime(item.order.created_at),
customer_name: item.order.addresses?.[0]?.name ?? '',
customer_phone: item.order.addresses?.[0]?.primary_phone ?? '',
branch_name: item.order.branch?.name ?? '',
voucher_name: item.gift?.voucher_name ?? '',
voucher_code: item.gift?.voucher_code ?? '',
gift_type_label: getGiftTypeLabel(item.gift?.gift_type),
discount_amount: item.voucher_discount_amount ?? 0,
gift_source_label: getGiftSourceLabel(item.gifted_from),
campaign_name: getCampaignName(item), // FORMULA-002 logic
order_total: item.order.total ?? 0,
paid_amount: item.order.paid_amount ?? 0,
order_status: getOrderStatus(item.order), // FORMULA-005 logic
}));
builder.addRows(convertObjectsToRows(rows));
// 4. Download
downloadExcelFile(builder, `don-hang-voucher-qua-tang_${formatDate(today)}`);
}Ref files pattern:
src/shared/utils/excel.ts—createExcelBuilder(),downloadExcelFile()src/modules/cms/components/voucher/vouchers/hooks/useVoucherExport.ts— client-side export pattern
Nếu tương lai cần server-side async (VD: dataset > 200K rows gây crash browser): Tạo action handler mới trong export-api, follow pattern reportSalary. Nhưng hiện tại chưa cần — client-side xử lý được 50K-100K rows.
C7) Migration Strategy
| DB | Latest existing | New migration(s) |
|---|---|---|
| ecommerce | 1769073609000 | 1774000000001_add_voucher_gift_report_indexes |
| ecommerce | — | 1774000000002_seed_report_role_voucher_gift |
sql
-- Migration 1: 1774000000001_add_voucher_gift_report_indexes/up.sql
-- INDEX 1: Partial index cho order_item filter chính
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_item_voucher_gift
ON order_item (order_id)
WHERE (gifted_id IS NOT NULL OR voucher_discount_amount > 0);
-- INDEX 2: Composite index cho order time range + branch
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_created_at_branch_confirmed
ON "order" (created_at DESC, branch_id)
WHERE customer_confirmed_at IS NOT NULL;
-- INDEX 3: Index cho gifted_from filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_item_gifted_from
ON order_item (gifted_from)
WHERE gifted_from IS NOT NULL;
-- INDEX 4: Index cho gift_type filter trên user_vouchers
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_vouchers_gift_type
ON user_vouchers (gift_type)
WHERE gift_type IS NOT NULL;sql
-- Migration 2: 1774000000002_seed_report_role_voucher_gift/up.sql
-- Seed report_role cho tab mới
-- (xác nhận tên bảng + schema chính xác với BE team)
-- INSERT INTO report_role (report_id, ...) VALUES ('revenue_voucher_gift_order_report', ...);Deploy order: Indexes → Seed data → FE codegen → FE deploy
C8) Security
Permission Matrix (Application Layer)
| Action | Staff | Manager | Admin |
|---|---|---|---|
| Xem tab | Branch mình (nếu có quyền report) | Branch quản lý | ✅ |
| Export Excel | ✅ (theo scope) | ✅ (theo scope) | ✅ |
Hasura Row-Level Security
| Table | Role | Select filter | Ghi chú |
|---|---|---|---|
order | user | {} (no filter) | ⚠️ Xem cảnh báo bảo mật bên dưới |
order_item | user | {} (no filter) | ⚠️ Xem cảnh báo bảo mật bên dưới |
user_vouchers | user | {} (no filter) | ⚠️ Xem cảnh báo bảo mật bên dưới |
⚠️ Cảnh báo bảo mật: Branch scoping chỉ ở frontend (LỖ HỔNG HỆ THỐNG)
Hiện trạng (toàn hệ thống, không riêng feature này):
- Hasura permission trên
order,order_item,user_vouchersđều cófilter: {}— không filter branch ở DB level - Auth service (
auth/server/auth.godòng 273-290) KHÔNG trảX-Hasura-Branch-Idsession variable - Frontend client (
api/graphql/client.ts) KHÔNG gửiX-Hasura-Branch-Idheader - Branch scoping xử lý 100% ở frontend qua
globalStore.currentBranches→ WHERE builder
Rủi ro: User có kỹ thuật có thể sửa GraphQL variables (qua browser DevTools hoặc Hasura console) để query dữ liệu branch khác. Đây là authorization bypass, không chỉ là implementation detail.
Đánh giá impact cho feature này:
- Dữ liệu bị lộ: đơn hàng voucher/gift của branch khác (tên khách hàng, SĐT, số tiền)
- Severity: Medium-High (PII + financial data, nhưng yêu cầu authenticated user + kỹ thuật)
- Pattern hiện tại: tất cả Revenue Report tabs + Dashboard + CRM đều dùng cùng pattern này → lỗi hệ thống
Khuyến nghị fix (ngoài scope feature này, cần initiative riêng):
yaml
# Giải pháp đúng: Thêm X-Hasura-Branch-Id vào auth service response
# File: services/auth/server/auth.go
resp["X-Hasura-Branch-Ids"] = util.EncodePostgresArray(acc.BranchIDs)
# Sau đó cập nhật Hasura permission:
# File: metadata/databases/ecommerce/tables/public_order.yaml
select_permissions:
- role: user
permission:
filter:
branch_id:
_in: X-Hasura-Branch-Ids # ← Server-side enforcementQuyết định cho feature này (Ref: DEC-006):
- Follow pattern hiện tại (FE WHERE builder) để consistent với hệ thống — fix isolate 1 tab sẽ tạo false sense of security
- Ghi nhận RSK-005 trong PRD: branch scoping vulnerability cần fix ở system level
- Dev PHẢI implement branch filter ở FE — đây là tầng bảo vệ duy nhất hiện tại
Quy tắc bảo mật
- Branch filter ở FE WHERE builder là bắt buộc:
order.branch_id IN (globalStore.account.branches)(Ref: DEC-006) - Tab ẩn hoàn toàn khi user không có quyền — không disable (Ref: RBAC pattern)
- Không bao giờ bỏ qua branch filter trong WHERE builder — đây là tầng authorization duy nhất
C9) NFR (Non-Functional Requirements)
Performance Requirements
| Metric | Target | Đo bằng | Ghi chú |
|---|---|---|---|
| List query (20 items) | < 500ms | EXPLAIN ANALYZE | Sau khi deploy indexes |
| Order aggregate | < 3s | EXPLAIN ANALYZE | Date range 3 tháng, all branches |
| Order-item aggregate | < 3s | EXPLAIN ANALYZE | Date range 3 tháng, all branches |
| Page load (FE) | < 3s | Browser Performance API | Bao gồm 3 queries + render |
| Export 10,000 rows | < 30s | Browser console | Client-side sync (exceljs) |
| Export 50,000 rows | < 120s | Browser console | Worst case — sau user confirm |
Index Strategy
| Index | Table | Columns | Mục đích |
|---|---|---|---|
idx_order_item_voucher_gift | order_item | (order_id) WHERE gifted_id IS NOT NULL OR voucher_discount_amount > 0 | CRITICAL — giảm scan từ ~5M xuống ~500K rows |
idx_order_created_at_branch_confirmed | order | (created_at DESC, branch_id) WHERE customer_confirmed_at IS NOT NULL | Time range filter + sort |
idx_order_item_gifted_from | order_item | (gifted_from) WHERE gifted_from IS NOT NULL | Nguồn quà filter |
idx_user_vouchers_gift_type | user_vouchers | (gift_type) WHERE gift_type IS NOT NULL | Loại giảm filter |
Scale Estimation
| Metric | Hiện tại | 1 năm | Ghi chú |
|---|---|---|---|
order rows | ~1-2M | ~3-4M | Tăng ~150K/tháng |
order_item rows | ~3-5M | ~8-12M | Trung bình 3 items/order |
| Items có voucher/gift | ~300K-750K (10-15%) | ~1-2M | Partial index chỉ cover phần này |
user_vouchers rows | ~200K-500K | ~800K-1.5M | Tăng theo campaigns |
C10) Observability
Logging
Không cần thêm logging — feature chỉ đọc dữ liệu qua Hasura GraphQL (đã có request logging).
Metrics
| Metric | Type | Labels | Mục đích |
|---|---|---|---|
report_voucher_gift_query_duration | Histogram | query_type: list/order_agg/item_agg | Monitor query performance |
Đo ở FE:
performance.now()trước và sau URQL query. Log nếu > 5s.
C11) Tasks
Phase 1 — Database (BLOCKER)
| # | Task | Estimate | Dependency | Owner |
|---|---|---|---|---|
| P1-01 | Tạo migration 4 indexes | 0.5d | — | BE Dev |
| P1-02 | Deploy indexes lên staging | 0.5d | P1-01 | BE Dev |
| P1-03 | EXPLAIN ANALYZE verify trên staging | 0.5d | P1-02 | BE Dev |
| P1-04 | Seed data report_role | 0.5d | P1-01 | BE Dev |
Phase 2 — Frontend
| # | Task | Estimate | Dependency | Owner |
|---|---|---|---|---|
| P2-01 | Thêm constant + REPORT_TREE vào types.ts | 0.5d | — | FE Dev |
| P2-02 | Thêm GraphQL query + codegen | 0.5d | P2-01 | FE Dev |
| P2-03 | Tạo VoucherGiftOrderReportFilter.tsx | 1d | P2-02 | FE Dev |
| P2-04 | Tạo VoucherGiftOrderReportTable.tsx (bảng + summary bar) | 2d | P2-02 | FE Dev |
| P2-05 | Tích hợp vào RevenueReport.tsx (conditional filter + tab) | 1d | P2-03, P2-04 | FE Dev |
| P2-06 | Export Excel | 0.5d | P2-04 | FE Dev |
| P2-07 | i18n labels | 0.5d | P2-04 | FE Dev |
Dependency Graph
P1-01 (indexes) ──→ P1-02 (deploy) ──→ P1-03 (verify)
P1-01 ──→ P1-04 (seed)
↓
P2-01 (types) ──→ P2-02 (GraphQL) ──→ P2-03 (filter) ──→ P2-05 (integrate)
──→ P2-04 (table) ──→ P2-05
──→ P2-06 (export)
──→ P2-07 (i18n)C12) Traceability
| FR | FE Component | BE Artifact | TC |
|---|---|---|---|
| FR-001 | RevenueReport.tsx (tab) + types.ts | report_role seed | TC-001-* |
| FR-002 | VoucherGiftOrderReportTable.tsx (summary bar) | GraphQL aggregate queries | TC-002-* |
| FR-003 | VoucherGiftOrderReportTable.tsx (table) | GraphQL list query | TC-003-* |
| FR-004 | VoucherGiftOrderReportFilter.tsx | GraphQL WHERE builder | TC-004-* |
| FR-005 | VoucherGiftOrderReportTable.tsx (export) | Client-side: createExcelBuilder() + getAllDataByPaging() | TC-005-* |
| FR-006 | — | Migration indexes | TC-006-* |
| FR-007 | types.ts + RevenueReport.tsx | Migration seed | TC-007-* |
Decision → Implementation Mapping
| DEC | Implementation | Verify |
|---|---|---|
| DEC-003 | Query order_item level, usePagination trên order_item | Pagination count = order_item count, not order count |
| DEC-006 | orderBuilder.setBranches() pattern | Staff xem chỉ branch mình, Manager xem branch quản lý |
| DEC-007 | order.addresses[0].name + primary_phone | Không có remote HTTP call trong query |
| DEC-008 | WHERE order_kind: { _in: ["service", "cosmetic", "prepaid"] } | Đơn chuyển kho/vật tư không xuất hiện |
| DEC-010 | isRepeatedOrder flag trong render loop | Dòng 2+ cùng đơn dim cột order-level |
| DEC-012 | 4 indexes CONCURRENTLY | EXPLAIN ANALYZE < 500ms (list), < 3s (agg) |
| DEC-013 | VoucherGiftOrderReportFilter.tsx riêng | Filter không ảnh hưởng các tab khác |