Skip to content

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

ModuleLoạiThay đổi
reportFEThêm tab mới + 2 components + 1 GraphQL query + i18n + types
ecommerce DBDB4 indexes mới (migration)
ecommerce DBDBSeed 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ửaRisk
RevenueReport.tsx5 tabs, shared filterThêm tab 6 + conditional filter renderMed
types.tsRoute constants + REPORT_TREEThêm constant + update REPORT_TREELow
report_revenue.graphql5 queriesThêm 1 query mới (3 operations)Low
i18n/vi.tsExisting labelsThêm ~15 labels mớiLow
order_item tableKhông có index trên gifted_id/voucher_discount_amount2 indexes mớiHigh
order tableKhông có index trên created_at1 composite index mớiHigh
user_vouchers tableKhông có index trên gift_type1 index mớiMed

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

TableDùng choKey columns
orderĐơn hàngid, code, created_at, branch_id, total, paid_amount, paid_at, canceled_at, customer_confirmed_at, order_kind
order_itemChi tiết dòng đơn hàngid, order_id, gifted_id, gifted_from, voucher_discount_amount
user_vouchersVoucher/quà tặngid, voucher_name, voucher_code, gift_type, campaign_id, gift_from
voucher_campaignsChiến dịch voucherid, 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.orderorder (FK: order_id)
  • order_item.giftuser_vouchers (FK: gifted_id)
  • user_vouchers.voucher_campaignvoucher_campaigns (FK: campaign_id)
  • order.addressesorder_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_type

Error Contract

ScenarioError codeStatusFE handling
Query timeout (> 30s)TIMEOUT200 (GraphQL error)Show error state + "Vui lòng thu hẹp khoảng thời gian"
UnauthorizedUNAUTHORIZED401Redirect login
No data200 (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""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 valueMô tả
DVgift_type_serviceTặng dịch vụ
SPgift_type_cosmeticTặng sản phẩm
% giảmgift_type_discount_percentGiảm giá theo %
VNĐgift_type_discount_vndGiảm giá theo VNĐ
Lời chúcgift_type_greetingLời chúc (không có giá trị giảm)
Khácgift_type_otherLoại khác

Label nên lấy từ master_data.description WHERE type = '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 quaygifted_from_wheel_of_fortune
Vouchergifted_from_voucher
Bạn bègifted_from_friend
Thủ cônggifted_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:

  1. Client-side sync: createExcelBuilder() + getAllDataByPaging() → browser download (dùng bởi tất cả report tabs hiện có)
  2. 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.tscreateExcelBuilder(), 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

DBLatest existingNew migration(s)
ecommerce17690736090001774000000001_add_voucher_gift_report_indexes
ecommerce1774000000002_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)

ActionStaffManagerAdmin
Xem tabBranch mình (nếu có quyền report)Branch quản lý
Export Excel✅ (theo scope)✅ (theo scope)

Hasura Row-Level Security

TableRoleSelect filterGhi chú
orderuser{} (no filter)⚠️ Xem cảnh báo bảo mật bên dưới
order_itemuser{} (no filter)⚠️ Xem cảnh báo bảo mật bên dưới
user_vouchersuser{} (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.go dòng 273-290) KHÔNG trả X-Hasura-Branch-Id session variable
  • Frontend client (api/graphql/client.ts) KHÔNG gửi X-Hasura-Branch-Id header
  • 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 enforcement

Quyế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

  1. Branch filter ở FE WHERE builder là bắt buộc: order.branch_id IN (globalStore.account.branches) (Ref: DEC-006)
  2. Tab ẩn hoàn toàn khi user không có quyền — không disable (Ref: RBAC pattern)
  3. 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

MetricTargetĐo bằngGhi chú
List query (20 items)< 500msEXPLAIN ANALYZESau khi deploy indexes
Order aggregate< 3sEXPLAIN ANALYZEDate range 3 tháng, all branches
Order-item aggregate< 3sEXPLAIN ANALYZEDate range 3 tháng, all branches
Page load (FE)< 3sBrowser Performance APIBao gồm 3 queries + render
Export 10,000 rows< 30sBrowser consoleClient-side sync (exceljs)
Export 50,000 rows< 120sBrowser consoleWorst case — sau user confirm

Index Strategy

IndexTableColumnsMục đích
idx_order_item_voucher_giftorder_item(order_id) WHERE gifted_id IS NOT NULL OR voucher_discount_amount > 0CRITICAL — giảm scan từ ~5M xuống ~500K rows
idx_order_created_at_branch_confirmedorder(created_at DESC, branch_id) WHERE customer_confirmed_at IS NOT NULLTime range filter + sort
idx_order_item_gifted_fromorder_item(gifted_from) WHERE gifted_from IS NOT NULLNguồn quà filter
idx_user_vouchers_gift_typeuser_vouchers(gift_type) WHERE gift_type IS NOT NULLLoại giảm filter

Scale Estimation

MetricHiện tại1 nămGhi chú
order rows~1-2M~3-4MTăng ~150K/tháng
order_item rows~3-5M~8-12MTrung bình 3 items/order
Items có voucher/gift~300K-750K (10-15%)~1-2MPartial index chỉ cover phần này
user_vouchers rows~200K-500K~800K-1.5MTă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

MetricTypeLabelsMục đích
report_voucher_gift_query_durationHistogramquery_type: list/order_agg/item_aggMonitor query performance

Đo ở FE: performance.now() trước và sau URQL query. Log nếu > 5s.


C11) Tasks

Phase 1 — Database (BLOCKER)

#TaskEstimateDependencyOwner
P1-01Tạo migration 4 indexes0.5dBE Dev
P1-02Deploy indexes lên staging0.5dP1-01BE Dev
P1-03EXPLAIN ANALYZE verify trên staging0.5dP1-02BE Dev
P1-04Seed data report_role0.5dP1-01BE Dev

Phase 2 — Frontend

#TaskEstimateDependencyOwner
P2-01Thêm constant + REPORT_TREE vào types.ts0.5dFE Dev
P2-02Thêm GraphQL query + codegen0.5dP2-01FE Dev
P2-03Tạo VoucherGiftOrderReportFilter.tsx1dP2-02FE Dev
P2-04Tạo VoucherGiftOrderReportTable.tsx (bảng + summary bar)2dP2-02FE Dev
P2-05Tích hợp vào RevenueReport.tsx (conditional filter + tab)1dP2-03, P2-04FE Dev
P2-06Export Excel0.5dP2-04FE Dev
P2-07i18n labels0.5dP2-04FE 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

FRFE ComponentBE ArtifactTC
FR-001RevenueReport.tsx (tab) + types.tsreport_role seedTC-001-*
FR-002VoucherGiftOrderReportTable.tsx (summary bar)GraphQL aggregate queriesTC-002-*
FR-003VoucherGiftOrderReportTable.tsx (table)GraphQL list queryTC-003-*
FR-004VoucherGiftOrderReportFilter.tsxGraphQL WHERE builderTC-004-*
FR-005VoucherGiftOrderReportTable.tsx (export)Client-side: createExcelBuilder() + getAllDataByPaging()TC-005-*
FR-006Migration indexesTC-006-*
FR-007types.ts + RevenueReport.tsxMigration seedTC-007-*

Decision → Implementation Mapping

DECImplementationVerify
DEC-003Query order_item level, usePagination trên order_itemPagination count = order_item count, not order count
DEC-006orderBuilder.setBranches() patternStaff xem chỉ branch mình, Manager xem branch quản lý
DEC-007order.addresses[0].name + primary_phoneKhông có remote HTTP call trong query
DEC-008WHERE order_kind: { _in: ["service", "cosmetic", "prepaid"] }Đơn chuyển kho/vật tư không xuất hiện
DEC-010isRepeatedOrder flag trong render loopDòng 2+ cùng đơn dim cột order-level
DEC-0124 indexes CONCURRENTLYEXPLAIN ANALYZE < 500ms (list), < 3s (agg)
DEC-013VoucherGiftOrderReportFilter.tsx riêngFilter không ảnh hưởng các tab khác