Skip to content

Bug Fix: Wording + Filter status + BUG format + TZ — Báo cáo Doanh số/Thực thu & Chu kỳ khách hàng

v1.1 — 12/05/2026

Thay đổiSectionẢnh hưởng
🐛 BUG P0 — Fix format *_amount_rate hiển thị VND thay vì %FR-008FE
FR-009 — Thống nhất TZ trong SQL convertion_visits_cteFR-009BE SQL
DEC-T06 — Dùng allowlist tường minh cho isAmount checkZ3 DecisionsFE
DEC-T07 — Đồng nhất AT TIME ZONE thay vì + INTERVAL '7 hour'Z3 DecisionsBE SQL
DEC-007 — Tooltip card 2 nói rõ "bao gồm đơn còn công nợ"Z1 DecisionsFE/Content
DEC-008 — BUG P0 nâng severity cả package thành highZ1 DecisionsAll
C3.1.b — Sửa code isAmount line 138 ở componentC3) Component fixesFE
C6 — SQL migration mới fix TZC6) SQL migrationBE SQL
TC-10/11/12 — Test cases mới cho BUG format + TZ fixD) QA Test CasesQA
Tooltip conversion_rate thêm clarification về paid_amountC2) i18n wording + C3.1.aFE

Quick Reference cho QA

Trên dashboardTrướcSau
🐛 Card "Tỉ lệ đóng góp thực thu Telesale" & "...CRM"Hiển thị , "Tăng 4đ" (sai — đang format VND)Hiển thị 4,00%, "Tăng 4%" (đúng)
Card "Tỉ lệ khách phát sinh doanh số" — tooltip"Tổng số khách hàng phát sinh / Tổng số khách hàng""Tổng số lượt khách phát sinh doanh số / Tổng số lượt khách đến"
Card "Tỉ lệ khách phát sinh doanh số từ Telesale" — tooltipnhắc "khách"nhắc "đơn hàng có hoa hồng Telesale"
Card "Tỉ lệ khách phát sinh doanh số từ CRM" — tooltipnhắc "khách"nhắc "đơn hàng có hoa hồng CRM (bao gồm Telesale)"
Báo cáo Chu kỳ — Card đầu"Số lượng khách bắt đầu mua""Số khách có phát sinh đơn trong kỳ" + icon ℹ️ tooltip
Báo cáo Chu kỳ — Số khách (7.281)Gồm cả đơn order_new/prepaid_new chưa thanh toánChỉ gồm đơn đã thanh toán (status whitelist)
Typo "Tống số khách" trong vi.ts"Tống""Tổng"
SQL convertion_visits_cte (TZ)visit_date::timestamp + INTERVAL '7 hour'AT TIME ZONE 'Asia/Ho_Chi_Minh' (đồng bộ)

Tác động dự kiến:

  • Số 7.281 trên báo cáo Chu kỳ sẽ giảm sau khi fix C1 (chưa biết bao nhiêu — cần chạy SQL kiểm chứng ở Phụ lục C-1 trước khi triển khai để communicate stakeholder).
  • Card Telesale/CRM amount rate hiện đang sai 100% — sau fix sẽ hiển thị % đúng (số thực thu không đổi).

Z) Decisions

Z1) Business Decisions

IDQuyết địnhLý doStatus
DEC-001Tooltip 3 chỉ số chuyển đổi (conversion_rate, telesale_conversion_rate, crm_conversion_visits_rate) ghi đúng đơn vị đo (lượt visit / đơn hàng), không gọi chung "khách hàng"Tránh BoD/PO hiểu nhầm metric → KPI sai✅ Chốt
DEC-002Label "Số lượng khách bắt đầu mua" → "Số khách có phát sinh đơn trong kỳ" + thêm tooltip giải thíchCụm "bắt đầu mua" gợi ý là khách mới (first-time) — sai bản chất✅ Chốt
DEC-003Card này đo "khách thực sự mua" (đã có giao dịch tiền tệ) — KHÔNG gồm đơn chưa thanh toánIntent business chuẩn: PO chốt✅ Chốt
DEC-004Giữ nguyên order_kind = prepaid (mua thẻ trả trước) trong bộ đếm — coi là "đã mua"Khách đã thanh toán thật, có thu tiền✅ Chốt
DEC-005Filter ngày khác nhau giữa các chỉ số (visit_date / order.created_at / invoice.paid_at) — chỉ document, không sửa logicMỗi field phục vụ mục đích nghiệp vụ khác nhau✅ Chốt
DEC-006Mỗi tooltip có 1 ví dụ số ngắn (1 dòng) để PO/BA hiểu nhanhGiảm bounce-off khi đọc dashboard✅ Chốt
DEC-007Tooltip card conversion_rate nói rõ "phát sinh DS = đã chốt đơn (total > 0, không phải bảo hành); bao gồm cả đơn còn công nợ chưa thanh toán"is_zero_order ở BE không check paid_amount — manager dễ hiểu nhầm "phát sinh DS = đã có tiền vào quỹ"✅ Chốt
DEC-008🐛 BUG P0*_amount_rate đang bị FE format thành VND. PRIORITY HIGHEST trong package, fix cùng wave wordingHiển thị , Tăng 4đ → BoD/manager đọc số 100% SAI; không thể giải thích✅ Chốt

Z3) Technical Decisions

IDQuyết địnhLý do
DEC-T01KHÔNG rename i18n key (vd conversion_rate, divisionValue) — chỉ đổi valueGiảm ripple sang component khác
DEC-T02KHÔNG đổi công thức tính (SQL function, Go logic của conversion_rate)Wording fix only
DEC-T03KHÔNG đổi GraphQL field name, type, structureGiữ contract API
DEC-T04Filter status mới (DEC-003) dùng whitelist thay vì NINRõ ràng + dễ maintain
DEC-T05Component ActualRevenueReportDetailEfficiency.tsx đang dùng tooltipContents hardcoded — phải sửa trực tiếp mảng đó, không chỉ sửa i18ni18n description block hiện là dead code trong flow này
DEC-T06isAmount check ở ActualRevenueReportDetailEfficiency.tsx:138 dùng allowlist tường minh (avg_payment_amount_per_visit only), không dùng key.includes("amount") lỏngBug đã xảy ra do match lỏng — telesale_amount_rate/crm_amount_rate chứa "amount" → bị format thành VND
DEC-T07Fix TZ cho convertion_visits_cte trong SQL search_dashboard_telesale_amount: dùng AT TIME ZONE 'Asia/Ho_Chi_Minh' (đồng nhất với CTE khác trong cùng file)+ INTERVAL '7 hour' là hard-code shift, hiện chỉ tồn tại ở CTE này → tử và mẫu của conversion_rate đang dùng 2 cách convert khác nhau cho cùng cột visit_date

Z4) QA Decisions

IDQuyết định
DEC-Q01Trước khi triển khai C1: chạy SQL kiểm chứng (Phụ lục C-1) trên prod để estimate số 7.281 sẽ giảm bao nhiêu
DEC-Q02Visual regression trên 2 dashboard, đảm bảo wording đúng vị trí + không vỡ layout
DEC-Q03Grep negative coverage để không sót wording cũ ở module khác

A) PRD

A1) Blueprint

Hạng mụcGiá trị
ProfileS (bug-fix compact)
Modulereport (FE) + ecommerce-api (BE Go) + controller/migrations/ecommerce (BE SQL)
ModeIncremental Enhancement + Bug fix
Reuse strategyExtend (i18n + 4 component edits + 1 Go filter change + 1 SQL TZ migration)
Backwards compatibility100% — không đổi GraphQL contract / công thức tổng quát
Layers ảnh hưởngFE (4 thay đổi) · BE Go (2 thay đổi) · BE SQL (1 migration mới)

A2) Context

PO/BA + BoD đặt câu hỏi về cách hiểu chỉ số Tỉ lệ khách phát sinh doanh số = 35,33% trên dashboard Doanh số/Thực thu. Audit code phát hiện:

  1. Tooltip hiện tại mô tả sai bản chất công thức — code chia theo lượt visit, tooltip lại nói khách hàng (xem ActualRevenueReportDetailEfficiency.tsx:32-39vi.ts:246-290).
  2. Card "Số lượng khách bắt đầu mua" gợi ý là khách mới — thực ra đếm distinct customer có đơn không-huỷ trong kỳ (gồm cả khách cũ).
  3. Card đó gồm cả đơn order_new/prepaid_new chưa thanh toán — không match intent business "khách thực sự mua".

A3) Goals

IDMục tiêuĐo lường
G1PO/BA/BoD đọc tooltip hiểu đúng đơn vị đo100% tooltip ghi đúng "lượt" hoặc "đơn hàng" hoặc "khách" theo bản chất công thức
G2Card "Số khách có phát sinh đơn" chỉ đếm khách đã thực sự mua0% khách trong số đếm có đơn duy nhất ở status order_new/prepaid_new
G3Không có thuật ngữ "bắt đầu mua" / "Tống" còn sót0 grep match (xem TC-09)
G4🐛 Card 3 & 5 (Telesale/CRM amount rate) hiển thị đúng %TC-10 pass; không có instance hiển thị Tăng Xđ cho 2 card này
G5conversion_rate không bao giờ > 100% ở biên ngàyTC-12 pass; query manual khớp với API

A4) Functional Requirements (FR) & Acceptance Criteria (AC)

FR-001 — Sửa tooltip conversion_rate (Báo cáo Doanh số/Thực thu)

Khi user hover icon ℹ️ trên card "Tỉ lệ lượt khách phát sinh doanh số" trong /r/reports/actual_revenue_report_group, hệ thống phải hiển thị tooltip:

  • Tiêu đề: Tỉ lệ lượt khách phát sinh doanh số
  • Tử số: Tổng số lượt khách phát sinh doanh số
  • Mẫu số: Tổng số lượt khách đến
  • Mô tả: "Tỉ lệ chuyển đổi tại điểm chạm: trong tổng lượt khách ghé, có bao nhiêu lượt phát sinh đơn. Một khách ghé nhiều lần được đếm nhiều lượt. VD: 20.184 lượt ghé, 7.131 lượt có đơn → 35,33%."

AC:

  • AC-001.1: Tooltip hiển thị đủ 3 dòng + 1 mô tả + ví dụ số.
  • AC-001.2: Không còn cụm "Tổng số khách hàng" làm mẫu số tooltip này.
  • AC-001.3: Typo "Tống" đã sửa thành "Tổng".

FR-002 — Sửa tooltip telesale_conversion_rate

Tương tự FR-001 nhưng cho card Telesale:

  • Tiêu đề: Tỉ lệ đơn hàng có hoa hồng Telesale
  • Tử số: Số đơn hàng có nhân viên nhóm Telesale nhận hoa hồng
  • Mẫu số: Tổng số đơn hàng (service/cosmetic/prepaid, chưa huỷ) trong kỳ
  • Mô tả: "Tỉ trọng đơn hàng có sự đóng góp của team Telesale. Tính theo đơn hàng, không phải khách hay lượt."

AC-002.1: Tooltip không còn cụm "khách có đơn hàng".

FR-003 — Sửa tooltip crm_conversion_visits_rate

Tương tự FR-002 cho card CRM:

  • Tiêu đề: Tỉ lệ đơn hàng có hoa hồng CRM
  • Tử số: Số đơn hàng có nhân viên CRM (bao gồm Telesale) nhận hoa hồng
  • Mẫu số: Tổng số đơn hàng (service/cosmetic/prepaid, chưa huỷ) trong kỳ
  • Mô tả: "Tỉ trọng đơn hàng có sự đóng góp của team CRM (bao gồm Telesale). Tính theo đơn hàng."

AC-003.1: Tooltip nhắc rõ "CRM bao gồm Telesale".

FR-004 — Sửa server-side title

Response GraphQL reportSalesRevenue phải trả title đúng:

  • conversion_rate.title = "Tỉ lệ lượt khách phát sinh doanh số"
  • telesale_conversion_rate.title = "Tỉ lệ đơn hàng có hoa hồng Telesale"
  • crm_conversion_visits_rate.title = "Tỉ lệ đơn hàng có hoa hồng CRM"

AC-004.1: Response API chứa title mới khi gọi từ Postman/curl.

FR-005 — Đổi label + thêm tooltip card Chu kỳ

Trong /r/reports/customer_cycle_report_group tab Chu Kỳ Mua Hàng, card đầu:

  • Label mới: Số khách có phát sinh đơn trong kỳ
  • Icon ℹ️ bên cạnh label
  • Tooltip: "Đếm số khách hàng khác nhau (distinct) đã có ít nhất 1 đơn hàng dịch vụ/mỹ phẩm/thẻ trả trước (chưa huỷ, đã thanh toán) trong khoảng thời gian lọc. Bao gồm cả khách cũ mua lại, KHÔNG chỉ khách mới. VD: 7.281 khách có đơn trong tháng 5/2026."

AC-005.1: Label hardcoded cũ "Số lượng khách bắt đầu mua" đã xoá, thay bằng t('report.label.customer_cycle_purchase.titleTolal.total_customer'). AC-005.2: Icon ℹ️ render đúng vị trí, hover hiện tooltip.

FR-006 — Fix filter status (Cảnh báo 1)

reportPurchaseCycle backend phải chỉ đếm đơn đã thanh toán:

  • Loại: order_new, prepaid_new (chưa thanh toán)
  • Giữ: order_in_progress, order_completed, order_force_completed, prepaid_inprogress, prepaid_completed, prepaid_force_completed
  • Vẫn loại (như cũ): order_canceled, prepaid_canceled

AC-006.1: Số trên card "Số khách có phát sinh đơn" sau fix bằng giá trị từ query whitelist (so với Phụ lục C-1). AC-006.2: Bảng chu kỳ phía dưới (tỷ lệ chưa quay lại / khách có khả năng rời bỏ) cập nhật mẫu số mới.

FR-007 — Tooltip note cảnh báo filter ngày (Cảnh báo 3)

Thêm 1 dòng note nhỏ ở footer 2 báo cáo:

ℹ️ Báo cáo này filter theo [visit_date / order.created_at / paid_at]. Khi đối chiếu chéo với các báo cáo khác, một bản ghi có thể rơi vào kỳ khác nhau tùy field nào được filter.

AC-007.1: Note hiển thị ở cuối mỗi báo cáo, font nhỏ, màu mờ.

FR-008 — 🐛 BUG P0: Fix format *_amount_rate hiển thị VND thay vì %

Vị trí: diva-admin/src/modules/report/components/actual-revenue-report/ActualRevenueReportDetailEfficiency.tsx:138

Gốc rễ:

ts
// Hiện tại (line 138):
const isAmount = key.includes("amount") || key === "avg_payment_amount_per_visit";

telesale_amount_rate, crm_amount_rate chứa chuỗi "amount" nên bị match lỏng → fall vào nhánh isAmount = true → render giá trị không nhân ×100 + đơn vị đ.

Hệ quả vận hành: BE trả 0.04 (= 4%) → FE hiển thị , Tăng 4đ thay vì 4,00%, Tăng 4%. BoD/manager đọc dashboard SAI 100%.

Khi dashboard render card telesale_amount_rate hoặc crm_amount_rate, hệ thống phải:

  • Hiển thị giá trị nhân ×100, đuôi %
  • valueChange cũng hiển thị %
  • Card avg_payment_amount_per_visit vẫn hiển thị đ (không regression)

AC:

  • AC-008.1: Card 3 (Tỉ lệ đóng góp thực thu Telesale) hiển thị giá trị % (vd 4,00%), không phải đ.
  • AC-008.2: Card 5 (Tỉ lệ đóng góp thực thu CRM) tương tự AC-008.1.
  • AC-008.3: Dòng Tăng X hiển thị Tăng X% không phải Tăng Xđ.
  • AC-008.4: Card 1 avg_payment_amount_per_visit vẫn hiển thị đ — không bị regression.

FR-009 — Thống nhất TZ trong SQL search_dashboard_telesale_amount

Vị trí: diva-backend/services/controller/migrations/ecommerce/<new>_fix_tz_telesale_amount/up.sql (migration mới)

Gốc rễ: Trong cùng SQL function search_dashboard_telesale_amount, convertion_visits_cte (line ~78 trong migration 1761037948000) đang dùng:

sql
AND acv.visit_date::timestamp + INTERVAL '7 hour' BETWEEN _from AND _to

Trong khi các CTE khác và search_dashboard_customer_visits.all_visits (line ~233) dùng:

sql
AND (acv.visit_date::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh') BETWEEN _from AND _to

→ Cùng cột visit_date, hai cách convert TZ khác nhau. Tử (conversion_visits từ convertion_visits_cte) và mẫu (total_customer_visits từ all_visits) của card conversion_rate đang dùng 2 cách lọc thời gian khác nhau. Ở biên giới ngày, có thể đếm khác số → hiếm trường hợp conversion_rate > 100%.

Fix: Đổi convertion_visits_cte (và mọi nơi còn dùng + INTERVAL '7 hour') sang AT TIME ZONE 'Asia/Ho_Chi_Minh' cho đồng nhất.

AC:

  • AC-009.1: Sau migration, EXPLAIN 2 CTE (convertion_visits_cteall_visits) cùng cách filter TZ.
  • AC-009.2: conversion_rate không vượt 100% trên bất kỳ data range nào (test với _from = 2026-05-01 00:00:00+07, _to = 2026-05-01 23:59:59+07).
  • AC-009.3: So sánh kết quả conversion_rate trước/sau migration trên 1 kỳ test — chênh lệch nếu có chỉ xảy ra ở biên ngày và ≤ 1% data.

A5) Risks

Rủi roMứcMitigation
Stakeholder quen số 7.281 thắc mắc khi số giảm sau fix C1🟡 MediumChạy Phụ lục C-1 trước, communicate impact > 10% với stakeholder + ghi vào release note
Sau khi fix FR-008, số trên card 3/5 sẽ "nhỏ đi 100 lần" (vd hiện Tăng 4đ → sau Tăng 4%)🟡 MediumRelease note nhấn rõ "đây là fix bug format, số thực thu không đổi"; trước/sau số THỰC vẫn nhất quán
Sót wording cũ ở module khác🟢 LowTC-09 grep coverage
Vỡ layout do chữ tooltip dài hơn🟢 LowQTooltip đã có maxWidth: 400px
Bảng chu kỳ phía sau cũng bị ảnh hưởng (vì mẫu số đổi)🟡 MediumQA verify cả bảng pur_cycle_table không chỉ card overview
FR-009 migration đụng prod, không có rollback data🟢 LowSQL CREATE OR REPLACE FUNCTION idempotent; nếu cần rollback chạy lại migration cũ 1761037948000

C) Dev Spec — Code Changes

C1) Filter status fix (Cảnh báo 1)

File: diva-backend/services/ecommerce-api/action/report_purchase_cycle.go

Trước (line 266-271):

go
whereOrder := map[string]interface{}{
    "created_at":           store.Comparison{store.GTE: input.Data.FromDate, store.LTE: input.Data.ToDate},
    "order_service_status": store.Comparison{store.NIN: []store.OrderServiceStatus{
        store.OrderServiceStatusCanceled,
        store.OrderPrepaidCanceled,
    }},
    "order_kind": store.Comparison{store.IN: []string{"service", "cosmetic", "prepaid"}},
}

Sau:

go
whereOrder := map[string]interface{}{
    "created_at": store.Comparison{store.GTE: input.Data.FromDate, store.LTE: input.Data.ToDate},
    "order_service_status": store.Comparison{store.IN: []store.OrderServiceStatus{
        store.OrderServiceStatusInProgress,       // "order_in_progress"
        store.OrderServiceStatusCompleted,        // "order_completed"
        store.OrderServiceStatusForceCompleted,   // "order_force_completed"
        store.OrderPrepaidInProgress,             // "prepaid_inprogress"
        store.OrderPrepaidCompleted,              // "prepaid_completed"
        store.OrderPrepaidForceCompleted,         // "prepaid_force_completed"
    }},
    "order_kind": store.Comparison{store.IN: []string{"service", "cosmetic", "prepaid"}},
}

Lý do: chỉ giữ đơn đã thanh toán hoặc đã hoàn tất, loại các đơn còn ở trạng thái new (chưa thanh toán).

C2) i18n wording fix

File: diva-admin/src/modules/report/i18n/vi.ts

Block actual_revenue_report — sửa 12 chuỗi (line ~246-290)

ts
actual_revenue_report: {
  titleTolal: {
    avg_payment_amount_per_visit: "Thực thu trung bình lượt khách đến",
    conversion_rate: "Tỉ lệ lượt khách phát sinh doanh số",                            // ← sửa
    telesale_amount_rate: "Tỉ lệ đóng góp thực thu Telesale",
    telesale_conversion_rate: "Tỉ lệ đơn hàng có hoa hồng Telesale",                   // ← sửa
    crm_amount_rate: "Tỉ lệ đóng góp thực thu CRM",
    crm_conversion_visits_rate: "Tỉ lệ đơn hàng có hoa hồng CRM",                      // ← sửa
  },
  divisionValue: {
    avg_payment_amount_per_visit: "Tổng thực thu phát sinh",
    conversion_rate: "Tổng số lượt khách phát sinh doanh số",                          // ← sửa + fix typo "Tống"
    telesale_amount_rate: "Tổng doanh số hoa hồng của nhân viên thuộc nhóm Telesale",
    telesale_conversion_rate: "Số đơn hàng có nhân viên nhóm Telesale nhận hoa hồng",  // ← sửa
    crm_amount_rate: "Tổng doanh số hoa hồng của nhân viên thuộc nhóm CRM",
    crm_conversion_visits_rate: "Số đơn hàng có nhân viên CRM (bao gồm Telesale) nhận hoa hồng", // ← sửa
  },
  dividedValue: {
    avg_payment_amount_per_visit: "Tổng số lượt khách đến",
    conversion_rate: "Tổng số lượt khách đến",                                         // ← sửa
    telesale_amount_rate: "Tổng thực thu phát sinh",
    telesale_conversion_rate: "Tổng số đơn hàng trong kỳ",                             // ← sửa
    crm_amount_rate: "Tổng thực thu phát sinh",
    crm_conversion_visits_rate: "Tổng số đơn hàng trong kỳ",                           // ← sửa
  },
  description: {
    avg_payment_amount_per_visit:
      "Thực thu trung bình mỗi lượt khách ghé cửa hàng trong kỳ.",
    conversion_rate:
      "Tỉ lệ chuyển đổi tại điểm chạm: trong tổng lượt khách ghé, có bao nhiêu lượt phát sinh đơn (đã chốt đơn, total > 0, không phải bảo hành; **bao gồm cả đơn còn công nợ chưa thanh toán**). Một khách ghé nhiều lần được đếm nhiều lượt. VD: 20.184 lượt ghé, 7.131 lượt có đơn → 35,33%.",
    telesale_amount_rate:
      "Tỉ lệ doanh thu (thực thu) đến từ đơn hàng có hoa hồng cho nhân viên nhóm Telesale.",
    telesale_conversion_rate:
      "Tỉ trọng đơn hàng có sự đóng góp của team Telesale. Tính theo đơn hàng, không phải khách hay lượt.",
    crm_amount_rate:
      "Tỉ lệ doanh thu (thực thu) đến từ đơn hàng có hoa hồng cho nhân viên nhóm CRM (bao gồm Telesale).",
    crm_conversion_visits_rate:
      "Tỉ trọng đơn hàng có sự đóng góp của team CRM (bao gồm Telesale). Tính theo đơn hàng.",
  },
},

Note: titleTolal/divisionValue/dividedValue được component dùng qua t(...) → sửa ở đây có hiệu lực. Riêng description block hiện là dead code (component dùng mảng tooltipContents hardcoded — xem C3). Vẫn nên sửa cho đồng bộ.

Thêm block mới customer_cycle_purchase

ts
customer_cycle_purchase: {
  titleTolal: {
    total_customer: "Số khách có phát sinh đơn trong kỳ",
  },
  description: {
    total_customer:
      "Đếm số khách hàng khác nhau (distinct) đã có ít nhất 1 đơn hàng dịch vụ/mỹ phẩm/thẻ trả trước (chưa huỷ, đã thanh toán) trong khoảng thời gian lọc. Bao gồm cả khách cũ mua lại, KHÔNG chỉ khách mới. VD: 7.281 khách có đơn trong tháng 5/2026.",
  },
},

C3) Component fixes

C3.1) ActualRevenueReportDetailEfficiency.tsx — sửa tooltipContents hardcoded + 🐛 FIX BUG isAmount

File: diva-admin/src/modules/report/components/actual-revenue-report/ActualRevenueReportDetailEfficiency.tsx

C3.1.a) Sửa mảng tooltipContents (line 32-39)
tsx
const tooltipContents = [
  `Số tiền thực tế khách hàng mang lại mỗi lượt ghé cửa hàng trong kỳ.`,
  `Tỉ lệ chuyển đổi tại điểm chạm: trong tổng lượt khách ghé, có bao nhiêu lượt phát sinh đơn (đã chốt đơn, total > 0, không phải bảo hành; bao gồm cả đơn còn công nợ chưa thanh toán). Một khách ghé nhiều lần được đếm nhiều lượt. VD: 20.184 lượt ghé, 7.131 lượt có đơn → 35,33%.`,
  `Tỉ lệ doanh thu (thực thu) đến từ đơn hàng có hoa hồng cho nhân viên nhóm Telesale.`,
  `Tỉ trọng đơn hàng có sự đóng góp của team Telesale. Tính theo đơn hàng, không phải khách hay lượt.`,
  `Tỉ lệ doanh thu (thực thu) đến từ đơn hàng có hoa hồng cho nhân viên nhóm CRM (bao gồm Telesale).`,
  `Tỉ trọng đơn hàng có sự đóng góp của team CRM (bao gồm Telesale). Tính theo đơn hàng.`,
] as const;
C3.1.b) 🐛 BUG FIX — sửa isAmount check (line 138)

Trước (BUG):

tsx
const isAmount = key.includes("amount") || key === "avg_payment_amount_per_visit";

telesale_amount_rate, crm_amount_rate chứa "amount" nên bị bắt nhầm.

Sau (FIX):

tsx
const AMOUNT_KEYS = new Set<string>(["avg_payment_amount_per_visit"]);
// ...trong setup() hoặc đầu component:
const isAmount = AMOUNT_KEYS.has(key);

Hoặc inline (giữ scope đơn dòng):

tsx
const isAmount = key === "avg_payment_amount_per_visit";

Lý do dùng allowlist tường minh: mọi card mới thêm sau này mặc định là %, chỉ những key explicit nằm trong allowlist mới hiển thị đ. Tránh tái phát bug match lỏng.

C3.1.c) (Optional follow-up) Refactor về i18n

Tại line 152 thay description={tooltipContents[idx]} bằng description={t(\report.label.actual_revenue_report.description.${key}`)}rồi xoá mảng hardcoded. Khi đó blockdescription` ở C2 mới có hiệu lực. Khuyến nghị cho follow-up PR, không gộp vào fix này để giữ scope nhỏ + dễ revert.

C3.2) CustomerCyclePurchaseViewChart.tsx — đổi label + thêm icon tooltip

File: diva-admin/src/modules/report/components/customer-cycle/CustomerCyclePurchaseViewChart.tsx, line 143.

Trước:

tsx
<QText class="q-my-sm">Số lượng khách bắt đầu mua</QText>

Sau:

tsx
<QRow style={{ alignItems: "center" }}>
  <QText class="q-my-sm">
    {t("report.label.customer_cycle_purchase.titleTolal.total_customer")}
  </QText>
  <QIcon name={ICON_INFORMATION} size="20px" class="q-ml-sm" style={{ cursor: "pointer" }}>
    <QTooltip self="top middle" style={{ background: "#141414", maxWidth: "400px" }}>
      <QText>{t("report.label.customer_cycle_purchase.description.total_customer")}</QText>
    </QTooltip>
  </QIcon>
</QRow>

C4) Server-side titles

File: diva-backend/services/ecommerce-api/action/report_sales_revenue.go

go
// line 368
output.ConversionRate.Title = "Tỉ lệ lượt khách phát sinh doanh số"
// line 370
output.TelesaleConversionVisitsRate.Title = "Tỉ lệ đơn hàng có hoa hồng Telesale"
// line 372
output.CrmConversionVisitsRate.Title = "Tỉ lệ đơn hàng có hoa hồng CRM"

Thêm component note ở cuối mỗi báo cáo (FE). Wording đề xuất ở FR-007. Component có thể dùng <QText class="text-grey-7 text-caption"> hoặc tương tự.

C6) SQL migration — Fix TZ trong convertion_visits_cte (FR-009)

File mới: diva-backend/services/controller/migrations/ecommerce/<timestamp>_fix_tz_telesale_amount_convertion_visits/up.sql

Nội dung: Re-create search_dashboard_telesale_amount với CTE convertion_visits_cte đã đổi TZ filter.

Diff (chỉ phần thay đổi trong CTE):

sql
-- Trước (migration 1761037948000 line ~78):
convertion_visits_cte AS (
  SELECT COUNT(*) AS cnt
  FROM all_customer_visits acv
  WHERE (_branch_ids IS NULL OR acv.branch_id::text = ANY(_branch_ids))
    AND acv.visit_date::timestamp + INTERVAL '7 hour' BETWEEN _from AND _to  -- ❌
    AND acv.is_zero_order = false
)

-- Sau:
convertion_visits_cte AS (
  SELECT COUNT(*) AS cnt
  FROM all_customer_visits acv
  WHERE (_branch_ids IS NULL OR acv.branch_id::text = ANY(_branch_ids))
    AND (acv.visit_date::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh') BETWEEN _from AND _to  -- ✅
    AND acv.is_zero_order = false
)

down.sql: Copy nguyên function từ migration 1761037948000_update_func_search_dashboard_sales_revenue/up.sql (CTE giữ + INTERVAL '7 hour').

Lưu ý: CREATE OR REPLACE FUNCTION idempotent; deploy không cần downtime. Không có data migration.


D) QA Test Cases

#SetupKỳ vọng
TC-01Hover icon ℹ️ trên card "Tỉ lệ lượt khách phát sinh doanh số" tại /r/reports/actual_revenue_report_groupTooltip hiện đúng tử/mẫu + ví dụ "20.184 / 7.131 → 35,33%". KHÔNG còn "Tống" hay "Tổng số khách hàng"
TC-02Hover tooltip telesale_conversion_rateTử = "Số đơn hàng có NV Telesale nhận hoa hồng", mẫu = "Tổng số đơn hàng trong kỳ"
TC-03Hover tooltip crm_conversion_visits_rateMô tả nhắc rõ "CRM bao gồm Telesale"
TC-04Gọi GraphQL reportSalesRevenue từ PostmanResponse 3 title mới khớp với FE tooltip
TC-05Vào /r/reports/customer_cycle_report_group tab Chu Kỳ Mua HàngLabel card đầu = "Số khách có phát sinh đơn trong kỳ", có icon ℹ️
TC-06Hover icon ℹ️ card vừa kiểmTooltip mô tả distinct customer + nhắc rõ "đã thanh toán" + ví dụ "7.281 khách"
TC-07Filter kỳ 01/05–31/05/2026 trên prod, so sánh số card trước/sau fix C1Số sau fix ≤ số trước fix (vì loại đơn order_new/prepaid_new). Confirm với Phụ lục C-1
TC-08Bảng chu kỳ phía dưới cardMẫu số tỷ lệ chưa quay lại / khách rời bỏ tính theo TotalCustomer mới — không có hàng nào > 100%
TC-09Grep negative coverage trên diva-admin/rg "Tỉ lệ khách phát sinh doanh số|Số lượng khách bắt đầu mua|Tống số khách|Tỉ trọng khách phát sinh doanh số|Tỉ lệ chuyển đổi khách hàng, phần trăm khách hàng đã mua dịch vụ" → 0 match (trừ docs/)
TC-10🐛 BUG fix verify — mở dashboard kỳ có dữ liệu Telesale (vd 01/05-31/05/2026), nhìn card 3 (Tỉ lệ đóng góp thực thu Telesale) và card 5 (CRM)Hiển thị 4,00% (hoặc giá trị %), KHÔNG phải hay . Đơn vị bên dưới là %, không phải đ. Dòng "Tăng X" hiển thị Tăng X%
TC-11Regression check — card 1 avg_payment_amount_per_visitVẫn hiển thị giá trị đ (vd 1.250.000đ), không bị fix lệch sang %
TC-12FR-009 TZ fix — filter sát biên ngày _from = 2026-05-01 00:00:00+07, _to = 2026-05-01 23:59:59+07. Tính tay trong DB: SELECT COUNT(*) FROM all_customer_visits WHERE visit_date = '2026-05-01' AND is_zero_order = false so với conversion_visits từ APIKhớp 100%. conversion_rate không bao giờ > 1 (100%) trong bất kỳ kỳ test nào

E) Go-Live

  • Sửa trên branch dev → PR review → merge.
  • Trước merge: chạy Phụ lục C-1 query, communicate stakeholder nếu impact > 10%.
  • Release note ghi rõ:

    "🐛 Bug fix: Card 'Tỉ lệ đóng góp thực thu Telesale/CRM' đã hiển thị đúng % thay vì đ (lỗi format FE — số thực không đổi).

    Chuẩn hoá tooltip + label báo cáo doanh số/chu kỳ. Đồng thời fix filter status để card 'Số khách có phát sinh đơn' chỉ đếm đơn đã thanh toán (loại đơn new chưa thanh toán). Số liệu trên card này có thể giảm so với trước, các tỷ lệ trong bảng chu kỳ cập nhật mẫu số mới.

    Cải thiện kỹ thuật: chuẩn hoá TZ filter trong SQL search_dashboard_telesale_amount — tránh hiếm trường hợp conversion_rate > 100% ở biên ngày."

  • Rollback:
    • FE (FR-001..FR-008): revert commits, không có schema/data migration.
    • BE filter status (FR-006): revert Go code 1 file.
    • BE SQL TZ (FR-009): chạy lại migration 1761037948000 (idempotent).

Phụ lục C-1 — SQL kiểm chứng tác động C1

Chạy query này trên prod để estimate số 7.281 sẽ giảm bao nhiêu sau fix:

sql
SELECT 
  COUNT(DISTINCT customer_id) FILTER (
    WHERE order_service_status NOT IN ('order_canceled', 'prepaid_canceled')
  )                                            AS current_total_customer,        -- = ~7.281
  COUNT(DISTINCT customer_id) FILTER (
    WHERE order_service_status IN (
      'order_in_progress', 'order_completed', 'order_force_completed',
      'prepaid_inprogress', 'prepaid_completed', 'prepaid_force_completed'
    )
  )                                            AS fixed_total_customer,          -- số mới sau fix
  COUNT(DISTINCT customer_id) FILTER (
    WHERE order_service_status IN ('order_new', 'prepaid_new')
  )                                            AS pending_only_customer          -- khách CHỈ có đơn pending
FROM "order"
WHERE created_at BETWEEN '2026-05-01' AND '2026-05-31 23:59:59'
  AND order_kind IN ('service', 'cosmetic', 'prepaid')
  AND customer_id IS NOT NULL;

Decision rule:

  • Nếu pending_only_customer / current_total_customer < 5% → fix luôn không cần thông báo riêng.
  • Nếu 5-15% → release note đủ.
  • Nếu > 15% → email/Slack stakeholder trước khi merge, giải thích lý do.

Phụ lục C-2 — Status enum order

Từ diva-backend/pkg/store/order.go:170-192:

StatusCoi là "đã mua" sau fix C1?
order_new❌ Loại
order_in_progress✅ Giữ
order_completed✅ Giữ
order_force_completed✅ Giữ
order_canceled❌ (đang loại)
prepaid_new❌ Loại
prepaid_inprogress✅ Giữ
prepaid_completed✅ Giữ
prepaid_force_completed✅ Giữ
prepaid_canceled❌ (đang loại)

Phụ lục C-3 — Audit findings (3 cảnh báo gốc)

Cảnh báo 1 (đã FIX trong C1)

report_purchase_cycle.go:269 cũ chỉ loại 2 status order_canceled, prepaid_canceled → bao gồm cả đơn order_new/prepaid_new chưa thanh toán. Không khớp intent business "khách thực sự mua".

Cảnh báo 2 (GIỮ NGUYÊN)

order_kind = prepaid (mua thẻ trả trước) được tính là "đã mua". PO chốt: đã thanh toán = đã mua. Không sửa code, ghi rõ trong tooltip C2 (description block).

Cảnh báo 3 (DOCUMENT ONLY — FR-007)

3 filter ngày khác nhau:

  • all_customer_visits.visit_date → Lượt khách / Số khách / Conversion rate
  • order.created_at → Số khách có phát sinh đơn
  • invoice.paid_at → Doanh số / Thực thu / Telesale_amount / CRM_amount

Mỗi field phục vụ mục đích nghiệp vụ khác nhau (đo traffic / đo ý định mua / đo thu tiền thực). Không fix code, chỉ thêm tooltip note ở footer mỗi báo cáo (FR-007).