Skip to content

PRD — Tab Chu kỳ khách ghé không mua

Version: 2.8
Date: 11/05/2026
Profile: M
Canonical Inputs: EVIDENCE_PACK.md, SOURCE_OF_TRUTH.md, decision-brief.md.

v2.8 — 11/05/2026

Thay đổiSectionẢnh hưởng
Thêm 4 DEC: DEC-027 umbrella, DEC-028 info strip, DEC-029 sub-text + rename T6, DEC-030 tooltip 19 requiredZ) Nhật ký quyết địnhAll
Thêm FR-007 Info strip luôn hiển thịA5) FR-007FE
Update AC FR-003 thêm sub-text 3 cards overviewA5) FR-003FE
Update AC FR-004 thêm sub-text matrix + column rename T6A5) FR-004FE
Update AC FR-005 thêm popup column tooltip 6 ℹ️ iconA5) FR-005FE
A11 traceability — thêm FR-007 + DEC mới + TC mớiA11) TraceabilityAll

PRD này là hợp đồng nghiệp vụ. Nếu conflict với UI/Dev/QA, ưu tiên SOURCE_OF_TRUTH.md; công thức nghiệp vụ ở A10 thắng implementation notes trong dev-spec.md.

Z) Nhật ký quyết định

IDNhómQuyết địnhLý doStatus
DEC-001BusinessTách tab mới Chu kỳ khách ghé không muaKhông lẫn visit no purchase với repurchase theo orderLocked
DEC-002BusinessPhase 1 chỉ tính consultantdo_serviceĐúng hành vi khách đã ghé/tư vấn/làm dịch vụLocked
DEC-003DataKhách ghé không mua = qualified visit có is_zero_order=trueReuse rule Không phát sinh doanh số của CRM VisitLocked
DEC-004UXReuse shell tab mua hàng nhưng rewrite wording/data bindingGiảm learning cost nhưng giữ đúng nghĩaLocked
DEC-005UXRow Số khách có khả năng rời bỏ đổi thành KH chưa quay lại ghéNhóm này chưa phải churn chính thứcLocked
DEC-006Techby_month = duration * 30 ngàyBám thuật toán hiện cóLocked
DEC-007UX/DataPopup 1 khách/row; branch theo latest qualified visit, multi-branch cùng ngày hiển thị Nhiều chi nhánhDeterministic, dùng được cho CSKHLocked
DEC-008BusinessĐơn mua gần nhất (rename T1) lấy đơn hợp lệ gần nhất đến to_dateCho CSKH ngữ cảnh lịch sửLocked
DEC-009DeliveryPopup phase 1 không export, không filter dịch vụ/nhóm dịch vụKhóa scope và tránh lẫn purchase popupLocked
DEC-010TechThêm action/query mới riêngBảo vệ tab Chu kỳ mua hàngLocked
DEC-011UXTab mới persist sau refreshHành vi report group nhất quánLocked
DEC-012UX/TechKhông có data trả payload rỗng hợp lệEmpty là trạng thái bình thườngLocked
DEC-013Security/DeliveryExport deferredCần permission/export contract riêngLocked
DEC-014BusinessLoại khách đã chuyển đổi sau ghé: nếu max(order.created_at) > last_qualified_visit_date (trong kỳ, đến to_date), khách không được tínhMarketing chỉ target nhóm chưa chuyển đổi; tránh CSKH gọi nhầm khách đã muaLocked
DEC-015Tech/UXFix kèm bug CustomerCycleReport.tsx:37 (to: CUSTOMER_CYCLE_PURCHASECUSTOMER_CYCLE_CUSTOMER) và whitelist getSavedTab() đủ 4 key đang render (loại CUSTOMER_CYCLE_RATE không có panel)Tránh tab cũ rớt về Overview sau refresh; tab mới persist nhất quánLocked
DEC-016Security/TechBE action enforce branch scope: query user.branches của ctx.Access.UserID; nếu user.branches rỗng = no branch restriction, nếu non-empty = intersect với input.BranchIDs; intersection rỗng → empty payload; không tin FEDiva RBAC; tránh leak data cross-branchLocked
DEC-017UXCột Loại ghé popup: thứ tự cố định Tư vấnLàm dịch vụ, separator +Determinism, CSKH đọc nhất quánLocked
DEC-018UX/TechBucket by_month tab mới: ≤ 3 tháng, 4 - 6 tháng, 7 - 9 tháng, > 9 tháng thay quirk 3 tháng / 3-6 / 6-9 của tab purchaseQuirk gây hiểu nhầm; tab cũ giữ nguyên (DEC-010)Locked
DEC-019UXCell matrix value 0/rỗng → cursor default, KHÔNG mở popupTránh popup empty vô nghĩaLocked
DEC-020Tech/NFRP95 < 3s @ 100k qualified visits, < 5s @ 500k. Fail target → switch sang SQL aggregation (PostgreSQL function/window)Phòng scale issue; pull-all-to-Go pattern không đủ với 500k+Locked
DEC-021UXPopup title {Row label} • {Column label} (separator )Consistency riêng cho tab mới; tab cũ giữ format (Chu kỳ: ...)Locked
DEC-022UXTab label responsive: ≥768px Chu kỳ khách ghé không mua, <768px Khách ghé không muaTránh overflow mobile; vẫn rõ nghĩaLocked
DEC-023UX/TechDate range filter max 365 ngày; vượt → FE clamp + toast warningPerformance + readability matrixLocked
DEC-024UX/TechBước chu kỳ: by_day [1,180], by_month [1,12]; out-of-range → FE inline error + BE rejectInput safety; tránh empty matrix do bucket=0Locked
DEC-026Tech/DataDetail action nhận thêm branch_ids; BE re-apply resolveBranchScope rồi filter qualified_visits trong popupKhớp matrix bucket; tránh drift khi khách có visit ở nhiều branchLocked (review C-03)
DEC-027UXAdopt Hybrid clarity approach (v2.8 umbrella) — 3 layer info strip + sub-text + tooltipPO feedback 11/05/2026 "mờ hồ"Locked
DEC-028UXInfo strip permanent trên đầu tab, KHÔNG dismiss, KHÔNG localStorageAlways-visible contextLocked
DEC-029UXInline sub-text policy: 8 widget có space dùng sub-text dưới label, 6 popup column dùng tooltip ℹ️. Rename cột matrix Tỷ lệ% trên tổng (rename T6)Info readily visibleLocked
DEC-030UXTooltip backlog → required: 11 lift (3 filter + 3 overview + 4 matrix + 1 popup) + 1 new (popup Chi nhánh) = 19 tooltip required v2.8 (kèm 7 v2.7 đã required). Copy lift nguyên từ B9 backlogDoD discovery cho user mớiLocked

A0) Tổng quan tính năng

Ý tưởng cốt lõi

Marketing và CSKH cần biết nhóm khách đã ghé chi nhánh, có tư vấn hoặc làm dịch vụ, nhưng lượt ghé đó không tạo doanh số. Tab Chu kỳ mua hàng hiện tại không trả lời đúng câu hỏi này vì nguồn dữ liệu của tab đó là order.

Feature thêm tab riêng trong cùng report group. Tab mới dùng all_customer_visits làm nguồn gốc, tự khóa is_zero_order=true và source consultant/do_service, rồi hiển thị card, donut, matrix và popup chăm sóc khách.

Luồng tổng thể

text
User mở /r/reports/customer_cycle_report_group
  -> chọn tab Chu kỳ khách ghé không mua
  -> filter theo chu kỳ/thời gian/chi nhánh
  -> BE lấy qualified visits từ all_customer_visits
  -> FE hiển thị card + donut + matrix
  -> user click bucket
  -> popup hiển thị danh sách khách để CSKH xử lý tiếp

Mapping FR

FRScreenDev surfaceDecision
FR-001 Tab mới + persistenceSCR-01FE tab constants/panelDEC-001, DEC-011, DEC-015, DEC-022
FR-002 Qualified visit filterSCR-01BE aggregate actionDEC-002, DEC-003, DEC-006, DEC-014, DEC-016, DEC-023, DEC-024
FR-003 Overview visit-basedSCR-01FE chart + BE overview outputDEC-004, DEC-005, DEC-012
FR-004 Matrix Chu kỳ khách ghéSCR-01FE matrix + BE table outputDEC-004, DEC-005, DEC-006, DEC-018, DEC-019
FR-005 Popup detailSCR-02Detail action + popup columnsDEC-007, DEC-008, DEC-009, DEC-017, DEC-021
FR-006 Scope guard/regressionSCR-01/SCR-02Separate actions + testsDEC-010, DEC-013, DEC-016, DEC-020
FR-007 Info strip permanentSCR-01FE componentDEC-028

A1) Bức tranh nghiệp vụ

FieldValue
FeatureTab Chu kỳ khách ghé không mua
Product areaBáo cáo khách hàng / CRM
PlatformWeb Admin
Primary usersMarketing Manager, CSKH/CRM, Quản lý chi nhánh
ComplexityM
Release styleAdditive report tab

A2) Bối cảnh

Hiện trạng

  • CustomerCycleReport.tsx có report group với tab Tổng quan, Chu kỳ mua hàng, Chu kỳ khách hàng.
  • Chu kỳ mua hàng tính trên order.created_at, không phù hợp để đo khách đã ghé nhưng chưa mua.
  • Màn CRM Khách ghé cửa hàng đã có filter Không phát sinh doanh số; backend lưu tương ứng all_customer_visits.is_zero_order=true.

Vấn đề

Nếu Marketing dùng tab Chu kỳ mua hàng để suy luận nhóm khách ghé không mua, họ sẽ đọc sai nguồn dữ liệu: khách chưa có đơn sẽ không thể hiện đúng trong chu kỳ mua hàng. Kết quả là danh sách chăm sóc lại thiếu khách đã có tín hiệu ghé/tư vấn/làm dịch vụ.

Phương án

Thêm tab mới cùng route để giữ discoverability, nhưng tách data contract sang visit-based. Tab mới không tự động tạo campaign, không export và không thay đổi tab mua hàng.

A3) Mục tiêu và chỉ số thành công

Mục tiêuChỉ sốTarget
Đọc đúng nhóm khách đã ghé nhưng chưa muaQualified visit dùng all_customer_visits + is_zero_order=true100%
Loại khách đã chuyển đổiKhách trong popup không có order với created_at > last_qualified_visit_date100% data correct
Tránh lẫn với repurchaseTab mới không còn wording mua lại, rời bỏ, bắt đầu mua100% copy mới
Giữ an toàn tab cũreportPurchaseCyclereportPurchaseCycleDetail không đổi output contract0 regression P0
CSKH dùng được popupPopup 1 khách/row, có branch/latest visit/days since last visitUAT pass

A4) Persona

PersonaNhu cầuTần suất
Marketing ManagerChọn nhóm khách đã ghé nhưng chưa mua để remarketingHàng tuần
CSKH/CRMTìm khách cần gọi lại sau tư vấn/làm dịch vụ chưa muaHàng ngày
Quản lý chi nhánhTheo dõi chi nhánh có nhiều khách ghé nhưng chưa chuyển đổiHàng tuần

A5) Yêu cầu chức năng

FR-001: Thêm tab mới trong report group

Ref: DEC-001, DEC-011, DEC-015, DEC-022
Priority: Must
Screen: SCR-01

Khi user có quyền xem report group Chu kỳ khách hàng, hệ thống phải hiển thị thêm tab Chu kỳ khách ghé không mua trong cùng route để user xem report visit-based mà không rời khỏi nhóm báo cáo hiện tại. Đồng thời, fix kèm bug tab persistence hiện tại để các tab cũ persist đúng.

Acceptance Criteria

  • Màn /r/reports/customer_cycle_report_group có tab label Chu kỳ khách ghé không mua.
  • Tab mới có key riêng CUSTOMER_CYCLE_VISIT_NO_PURCHASE, không dùng lại CUSTOMER_CYCLE_PURCHASE.
  • Sau khi user đang đứng ở tab mới và refresh trang, hệ thống vẫn mở lại tab mới nếu key trong localStorage.currentTab hợp lệ.
  • Tab label responsive (DEC-022): viewport ≥ 768px hiển thị Chu kỳ khách ghé không mua (28 ký tự); viewport < 768px hiển thị Khách ghé không mua (19 ký tự). Tab key + URL không đổi giữa 2 viewport.
  • Sau fix DEC-015: tabs[CUSTOMER_CYCLE_CUSTOMER].to phải bằng CUSTOMER_CYCLE_CUSTOMER (hiện tại sai bằng CUSTOMER_CYCLE_PURCHASE).
  • Sau fix DEC-015: getSavedTab() whitelist phải gồm đúng 4 key đang render: CUSTOMER_CYCLE_OVERVIEW, CUSTOMER_CYCLE_PURCHASE, CUSTOMER_CYCLE_CUSTOMER, CUSTOMER_CYCLE_VISIT_NO_PURCHASE. Loại CUSTOMER_CYCLE_RATE khỏi whitelist nếu không có panel render.
  • Tab Chu kỳ khách hàng (CUSTOMER_CYCLE_CUSTOMER) vẫn active sau refresh (regression).
  • Các tab hiện có vẫn render đúng panel và label hiện tại.
  • Nếu user không có quyền report group, tab không xuất hiện theo cơ chế quyền hiện tại.

FR-002: Xác định tập qualified visit

Ref: DEC-002, DEC-003, DEC-006, DEC-014, DEC-016, DEC-023, DEC-024
Priority: Must
Screen: SCR-01

Khi user đổi filter chu kỳ/thời gian/chi nhánh, hệ thống phải tính report từ các lượt ghé hợp lệ trong all_customer_visits, loại khách đã chuyển đổi, và đảm bảo branch scope không bị bypass từ FE.

Acceptance Criteria

  • Filter hiển thị gồm Loại chu kỳ, Bước chu kỳ, Thời gian báo cáo, Chi nhánh, Đặt lại.
  • Backend luôn áp dụng is_zero_order=true.
  • Backend chỉ nhận visit có visit_source giao với consultant hoặc do_service.
  • Nếu khách có visit cùng ngày nhưng phát sinh doanh số hợp lệ, visit đó không thuộc tập qualified visit.
  • Nếu khách có order hợp lệ với created_at > last_qualified_visit_date (trong kỳ, đến to_date), khách bị loại khỏi tập "khách ghé không mua".
  • Order hợp lệ = order_service_status NOT IN ('order_canceled', 'prepaid_canceled') AND order_kind IN ('service', 'cosmetic', 'prepaid') (canonical theo pkg/store/order.go:174,191; cùng định nghĩa với tab Chu kỳ mua hàng).
  • Nếu order tạo trước hoặc cùng ngày last_qualified_visit_date, khách vẫn được tính (đơn cũ không phủ định tín hiệu ghé sau đó).
  • Nếu type_cycle=by_monthduration=3, bucket tính theo 90 ngày.
  • Nếu user không chọn chi nhánh, FE gửi danh sách branch user có quyền giống pattern purchase report.
  • Branch scope BE enforcement (DEC-016): Backend phải query user.branches của ctx.Access.UserID trước khi query all_customer_visits; user.branches=[] nghĩa là no branch restriction, non-empty thì intersect với input.BranchIDs. Không tin FE-trusted branch_ids.
  • Edge case Manager bypass: Manager Q1 gửi branch_ids=[Q3] (không thuộc allowed scope) → BE filter ra branch không thuộc scope, intersection rỗng → trả empty payload (theo DEC-012), không error nghiệp vụ.
  • Edge case no branch restriction: User có user.branches=[] theo pattern admin/HQ → dùng input.BranchIDs trực tiếp; nếu input rỗng, không filter branch. Không xác định bypass bằng Actor.IsAdmin().
  • Date range max (DEC-023): Nếu to_date - from_date > 365 ngày, FE clamp from_date = to_date - 365 ngày và hiển thị toast Phạm vi báo cáo tối đa 1 năm. Đã điều chỉnh ngày bắt đầu thành DD/MM/YYYY.. BE cũng validate defense in depth: nếu nhận range > 365 ngày trả error invalid_date_range.
  • Bước chu kỳ validation (DEC-024): type_cycle=by_dayduration ∈ [1, 180]; type_cycle=by_monthduration ∈ [1, 12]. Out-of-range → FE inline error trên input Bước chu kỳ phải từ 1 đến {max} {đơn vị}, không gọi BE. Nếu BE vẫn nhận được (ví dụ qua DevTools), trả error invalid_duration.

FR-003: Hiển thị tổng quan visit-based

Ref: DEC-004, DEC-005, DEC-012
Priority: Must
Screen: SCR-01

Khi aggregate action trả dữ liệu, hệ thống phải hiển thị tổng quan bằng ngôn ngữ visit-based để Marketing biết số khách có lượt ghé không mua, cơ cấu khách đã/chưa quay lại ghé và thời gian chưa ghé lại.

Acceptance Criteria

  • Card 1 label Số lượng khách có lượt ghé không mua.
  • Donut 1 label Cơ cấu khách: đã/chưa quay lại ghé (rename T5 — canonical theo UI B0.2/B6), gồm KH đã quay lại ghéKH chưa quay lại ghé.
  • Donut 2 label Tỉ lệ KH theo thời gian chưa ghé lại.
  • Không dùng label/tooltip mua hàng, mua lại, rời bỏ, bắt đầu mua trong tab mới.
  • Khi total_customer=0, FE hiển thị empty state Chưa có khách ghé không mua trong điều kiện lọc này. Vui lòng đổi thời gian hoặc chi nhánh. và không hiện NaN.
  • Sub-text required (DEC-029):
    • Dưới label Card 1 Số lượng khách có lượt ghé không mua: hiển thị Đã loại khách chuyển đổi sau ghé.
    • Dưới label Donut 1 Cơ cấu khách: đã/chưa quay lại ghé: hiển thị Trên tổng {total_customer} KH (runtime interpolate).
    • Dưới label Donut 2 Tỉ lệ KH theo thời gian chưa ghé lại: hiển thị Trên tổng {total_customer} KH.
    • Style: font-size 12px, color muted (gray-500), margin-top 4px.
  • Tooltip required (DEC-030): 3 metric overview phải có icon ℹ️ + hover tooltip với nội dung theo UI B9 (lift từ backlog, không viết lại).

FR-004: Hiển thị matrix Chu kỳ khách ghé

Ref: DEC-004, DEC-005, DEC-006, DEC-018, DEC-019
Priority: Must
Screen: SCR-01

Khi có dữ liệu, hệ thống phải hiển thị matrix theo 2 trục: trục ngang là thời gian chưa ghé lại, trục dọc là chu kỳ ghé trung bình trước đó.

Acceptance Criteria

  • Header trục dọc là Chu kỳ khách ghé.
  • Header trục ngang là Số ngày chưa ghé lại hoặc Số tháng chưa ghé lại theo type_cycle.
  • Row cố định theo thứ tự: Tổng, Tỷ lệ KH theo thời gian chưa ghé lại, KH chưa quay lại ghé, Cùng ngày, các bucket động.
  • Row KH chưa quay lại ghéCùng ngày không được gộp (rename T3: trước đây là 0 ngày).
  • Bucket label by_day (giữ): 1 - 30 ngày, 31 - 60 ngày, ... > N ngày.
  • Bucket label by_month (DEC-018, đổi mới): ≤ 3 tháng, 4 - 6 tháng, 7 - 9 tháng, ... > N tháng (KHÔNG dùng pattern quirk 3 tháng / 3 - 6 / 6 - 9 của tab purchase). Boundary vẫn theo duration × 30 ngày (DEC-006).
  • Click bucket (DEC-019): chỉ ô có value > 0 mới attach click handler (cursor pointer); ô value 0 hoặc rỗng → cursor default, không mở popup.
  • User click ô data hoặc cột Tổng của row data có value > 0 để mở popup detail.
  • Sub-text matrix headers (DEC-029):
    • Header trục dọc Chu kỳ khách ghé → sub-text Khoảng cách trung bình giữa các lượt ghé.
    • Header trục ngang Số ngày/tháng chưa ghé lại → sub-text Tính tới ngày kết thúc kỳ.
  • Sub-text matrix rows (DEC-029):
    • Row Cùng ngày → sub-text Nhiều lượt ghé qualified trong 1 ngày.
    • Row KH chưa quay lại ghé → sub-text Chỉ có 1 lượt ghé qualified trong kỳ.
  • Column rename T6 (DEC-029): Cột cuối bảng matrix đổi tên từ Tỷ lệ thành % trên tổng. Logic data binding (tử số / total_customer × 100) KHÔNG đổi. Chỉ tab mới đổi, tab Chu kỳ mua hàng giữ nguyên Tỷ lệ.
  • Tooltip required (DEC-030): Matrix header dọc + ngang + row labels (Tổng, Tỷ lệ KH theo thời gian chưa ghé lại, KH chưa quay lại ghé, Cùng ngày) + cột cuối % trên tổng phải có icon ℹ️ + hover tooltip theo UI B9.

FR-005: Popup chi tiết khách ghé không mua

Ref: DEC-007, DEC-008, DEC-009, DEC-017, DEC-021, DEC-026
Priority: Must
Screen: SCR-02

Khi user click một bucket trong matrix, hệ thống phải mở popup danh sách khách thuộc bucket đó để CSKH tìm khách và mở hồ sơ xử lý tiếp.

Acceptance Criteria

  • Popup title chứa row label và column label, format {Row label} • {Column label} (separator bullet với 2 space — DEC-021). Ví dụ KH chưa quay lại ghé • 31 - 60 ngày. KHÔNG dùng format (Chu kỳ: ...) của tab purchase.
  • Popup có search theo tên hoặc số điện thoại.
  • Popup không có nút export, không có filter dịch vụ và không có filter nhóm dịch vụ trong phase 1.
  • Bảng hiển thị 1 khách/row với các cột: Khách hàng, SĐT, Chi nhánh, Lượt ghé gần nhất, Loại ghé, Số lượt ghé không mua trong kỳ, Đơn mua gần nhất, Số ngày chưa ghé lại.
  • Click dòng khách mở hồ sơ khách ở tab mới như pattern hiện có.
  • Nếu latest qualified visit của khách có nhiều branch trong cùng ngày, cột Chi nhánh hiển thị Nhiều chi nhánh.
  • Cột Loại ghé (DEC-017): lấy visit_source của latest qualified visit; map consultantTư vấn, do_serviceLàm dịch vụ; nếu visit có cả 2 source, hiển thị theo thứ tự cố định Tư vấn + Làm dịch vụ (separator +, không xuống dòng, không chip).
  • Nếu latest qualified visit chỉ có 1 source: hiển thị Tư vấn hoặc Làm dịch vụ đơn lẻ.
  • Detail action branch scope (DEC-026): FE phải truyền branch_ids đúng với filter aggregate. BE re-apply resolveBranchScope rồi resolve last_visit_date, branch_name, count_visit_no_order chỉ trong scope đã intersect. Tránh drift khi khách có visit ở nhiều chi nhánh và user chọn filter chỉ 1 chi nhánh.
  • Popup column tooltip (DEC-029 + DEC-030): Mỗi column header trong popup table phải có icon ℹ️ inline cạnh tên column. Hover ℹ️ hiển thị tooltip giải thích column. 6 columns required: Chi nhánh, Lượt ghé gần nhất, Loại ghé, Số lượt ghé không mua trong kỳ, Đơn mua gần nhất, Số ngày chưa ghé lại. Copy tooltip lift từ UI B9.

FR-006: Scope guard và regression

Ref: DEC-010, DEC-013, DEC-016, DEC-020
Priority: Must
Screen: SCR-01, SCR-02

Khi triển khai feature, team phải giữ tab mua hàng ổn định, không mở rộng export/permission ngoài phạm vi phase 1, đảm bảo BE enforce branch scope độc lập với FE, và đạt performance NFR.

Acceptance Criteria

  • Không sửa output schema của reportPurchaseCycle.
  • Không sửa output schema của reportPurchaseCycleDetail.
  • Không thêm export Excel cho tab mới trong phase 1.
  • Regression tab Chu kỳ mua hàng pass với cùng filter trước và sau feature.
  • Nếu sau này bật export, phải có decision mới và permission/export contract mới.
  • BE 2 actions mới (reportVisitCycleNoPurchase, reportVisitCycleNoPurchaseDetail) phải enforce branch scope theo DEC-016. Detail action: customer_ids cũng phải intersect với khách thuộc branch scope của user — tránh leak khách qua customer_ids đoán được.
  • Performance gate (DEC-020): Trước GA phải có benchmark report trên seed 100k và 500k qualified visits. P95 aggregate action < 3s @ 100k, < 5s @ 500k. Fail → switch sang SQL aggregation pattern (PostgreSQL function với window/CTE) thay vì pull-all-to-Go.

FR-007: Info strip luôn hiển thị

Ref: DEC-028
Priority: Must
Screen: SCR-01

Khi user mở tab Chu kỳ khách ghé không mua, hệ thống phải hiển thị info strip vĩnh viễn trên đầu nội dung tab để định nghĩa scope tab và flag rule loại trừ khách đã chuyển đổi.

Acceptance Criteria

  • Info strip nằm dưới tab bar, trên filter bar, full container width.
  • Copy chính thức: Tab hiển thị khách đã ghé tư vấn / làm dịch vụ nhưng chưa phát sinh doanh số. Đã loại khách đã mua sau lượt ghé. (i18n key customer_cycle.info_strip).
  • Style: background subtle (amber-50 hoặc info-50), icon ℹ️ left, padding 12-16px, font-size body, không bold.
  • KHÔNG có nút dismiss; KHÔNG có icon × đóng; KHÔNG dùng localStorage; KHÔNG có logic first-time vs returning user.
  • Strip persistent qua: filter change, refresh trang, switch tab quay lại, mọi data state (loading/empty/error/normal).
  • Mobile (<768px): full-width, copy auto-wrap, không cắt chữ.
  • A11y: role="status", aria-live="polite", không block focus, screen reader đọc được copy.
  • Strip render độc lập với data state — vẫn hiển thị khi total_customer=0 (empty state).

A6) Giả định

IDGiả địnhCách kiểm chứng
ASM-001all_customer_visits được cập nhật đủ cho nguồn consultant/do_serviceQA seed và đối chiếu CRM Visit
ASM-002is_zero_order=true phản ánh đúng Không phát sinh doanh sốTest same-day order
ASM-003Branch scope FE hiện tại đủ cho report phase 1Test Manager branch
ASM-004Định nghĩa "order hợp lệ" cho điều kiện loại trừ DEC-014 thống nhất với tab Chu kỳ mua hàng (status không hủy + order_kind ∈ service/cosmetic/prepaid)Cross-check report_purchase_cycle.go
ASM-005ctx.Access.UserID truy cập được trong action handler; BE query user.branches qua Hasura đủ thông tin scope; user.branches=[] đại diện no branch restriction theo pattern admin/HQSpike test action mới với 1 user Manager + 1 user admin/HQ có user.branches=[]

A7) Rủi ro

RiskImpactMitigation
Data lớn làm action chậmReport timeoutReuse worker pattern, index visit_date/branch_id/visit_source, giới hạn query theo filter
Người dùng hiểu KH chưa quay lại ghé là churnSai quyết định marketingTooltip và glossary phân biệt rõ
Empty data bị coi là lỗiUX xấuAction trả payload rỗng, FE empty state tiếng Việt
Sửa tab cũ ngoài ý muốnRegression reportAction/query mới riêng, QA regression
Manager bypass branch scopeLeak data cross-branchDEC-016 BE enforce intersect; QA TC-012 explicit attempt
Tab cũ rớt persistence sau refresh do bug to mappingUX confuseDEC-015 fix kèm trong cùng PR, QA TC-001 cover
Visit multi-source hiển thị không nhất quánCSKH đọc sai tín hiệuDEC-017 fixed order + separator
Bucket by_month label gây hiểu nhầmUser đọc sai bucketDEC-018 đổi convention sạch cho tab mới
Popup empty mở vô nghĩa từ cell value=0UX confuseDEC-019 disable click; QA TC-015
Pull-all-to-Go fail @ 500k+ rowsAction timeoutDEC-020 benchmark gate + SQL fallback; QA TC-016
Tab label overflow trên mobileUX brokenDEC-022 responsive label
User chọn date range 5 năm → matrix 60+ bucketUnreadable + slowDEC-023 max 365 ngày + clamp + toast
User nhập bước chu kỳ invalid → matrix rỗngUX confuseDEC-024 FE/BE validate range

A8) Metrics

MetricĐịnh nghĩaTarget
Data correctnessSố khách popup khớp customer_ids từ matrix bucket100%
Copy correctnessKhông có wording purchase trong tab mới100%
RegressionTab Chu kỳ mua hàng không đổi số liệu với same seed100% P0 pass
UsabilityCSKH mở được hồ sơ khách từ popupUAT pass

A9) Glossary

Thuật ngữ VIThuật ngữ EN/codeĐịnh nghĩaPhân biệt với
Khách ghé không muaVisit no purchase customerKhách có qualified visit và CHƯA có order tạo sau last_qualified_visit_date trong kỳKhác khách không có lượt ghé; khác khách đã chuyển đổi
Đã chuyển đổi sau ghéConverted after visitKhách có qualified visit nhưng có order hợp lệ với created_at > last_qualified_visit_date (trong kỳ, đến to_date); KHÔNG tính vào tậpKhác khách có đơn cũ trước ghé (vẫn được tính)
Qualified visitall_customer_visits row hợp lệVisit có source consultant/do_service và không phát sinh doanh sốKhác order
Chu kỳ khách ghéVisit cycleSố ngày trung bình giữa các qualified visit liên tiếp của cùng kháchKhác chu kỳ mua hàng
KH chưa quay lại ghéNon-return visit customerKhách chỉ có 1 qualified visit trong kỳ lọc — chưa đủ dữ liệu tính chu kỳKhác churn chính thức; khác Cùng ngày
Cùng ngàySame-day visitsKhách có nhiều qualified visit trong cùng 1 ngày (khác chi nhánh hoặc multi-source) → chu kỳ tính ra = 0 ngàyKhác KH chưa quay lại ghé; trước đây gọi là 0 ngày (rename T3)
Số ngày chưa ghé lạiDays since last visitSố ngày từ qualified visit gần nhất đến to_dateKhác số ngày chưa mua lại
Đơn mua gần nhấtClosest orderĐơn hợp lệ gần nhất tới to_date, không giới hạn trong kỳ filterTrước đây gọi là Lần mua gần nhất (rename T1)
Số lượt ghé không mua trong kỳVisit count in periodĐếm qualified visits của khách giới hạn trong kỳ filterTrước đây gọi là Số lần ghé không mua (rename T4)

A10) Công thức nghiệp vụ

FORMULA-001: Tổng khách có lượt ghé không mua

  • Mô tả: Số khách duy nhất có ít nhất 1 qualified visit trong điều kiện lọc, VÀ chưa chuyển đổi sau lượt ghé qualified gần nhất trong kỳ.
  • Công thức:
    qualified_customers = { customer_id: ∃ qualified_visit ∈ kỳ }
    converted_customers = { customer_id ∈ qualified_customers:
                            ∃ order hợp lệ với
                            last_qualified_visit_date < order.created_at ≤ to_date }
    total_customer = count(qualified_customers \ converted_customers)
  • Biến số:
    • qualified_visits: rows từ all_customer_visits thỏa filter thời gian/chi nhánh/source/no-revenue.
    • last_qualified_visit_date: max(visit_date) của khách trong tập qualified_visits.
    • order hợp lệ: order_service_status NOT IN ('order_canceled', 'prepaid_canceled') AND order_kind IN ('service', 'cosmetic', 'prepaid') (canonical theo pkg/store/order.go:174,191).
  • Ví dụ:
    • Khách Lan ghé consultant 10/04/2026 (qualified), tạo đơn 12/04/2026 → 12/04 > 10/04 → loại.
    • Khách Anh ghé do_service 10/04 và 20/04 (cả 2 qualified), tạo đơn 15/04 → 15/04 < 20/04 → giữ.
    • Khách Hân ghé consultant 10/04, tạo đơn 05/04 (trước ghé) → 05/04 < 10/04 → giữ.
    • Khách Nam ghé consultant 10/04, chưa từng mua → giữ.
    • Tổng cộng 1.250 khách đáp ứng cả 2 điều kiện -> total_customer = 1.250.
  • Edge cases:
    • Không có qualified visit -> 0, không báo lỗi.
    • Một khách ghé nhiều lần vẫn chỉ tính 1 khách trong tổng.
    • Order tạo cùng ngày last_qualified_visit_date (> chứ không phải >=): KHÔNG loại; vì cùng ngày coi như chưa quan sát được hành vi sau ghé.
    • Order hủy hoặc kind không hợp lệ -> bỏ qua, không loại khách.

FORMULA-002: Cơ cấu khách: đã/chưa quay lại ghé

  • Mô tả: Phân nhóm khách đã hình thành chu kỳ ghé và chưa hình thành chu kỳ ghé. Chỉ tính trong target_customers (đã loại khách đã chuyển đổi theo DEC-014).
  • Công thức: return_rate = return_visit_customer_count / total_customer x 100
  • Biến số:
    • return_visit_customer_count: khách thuộc target_customers có từ 2 qualified visits trở lên.
    • non_return_visit_customer_count: khách thuộc target_customers có đúng 1 qualified visit.
  • Ví dụ: Tổng 1.250 khách target, 300 khách có từ 2 lượt ghé không mua -> return_rate = 24.00%, non_return_rate = 76.00%.
  • Edge cases:
    • total_customer=0 -> hiển thị -, không hiển thị 0% hoặc NaN.

FORMULA-003: Số ngày chưa ghé lại

  • Mô tả: Số ngày từ qualified visit gần nhất của khách đến ngày kết thúc filter.
  • Công thức: days_since_last_visit = to_date - max(visit_date)
  • Ví dụ: Khách ghé lần gần nhất 10/04/2026, to_date=30/04/2026 -> 20 ngày.
  • Edge cases:
    • to_date lớn hơn ngày hiện tại -> backend clamp về thời điểm hiện tại như pattern purchase.

FORMULA-004: Chu kỳ khách ghé

  • Mô tả: Trung bình khoảng cách giữa các qualified visit liên tiếp của cùng khách.
  • Công thức: visit_cycle = average(diff(visit_date[i], visit_date[i-1]))
  • Ví dụ: Khách ghé không mua ngày 01/03, 21/03, 10/04 -> gaps 20 và 20 -> visit_cycle=20 ngày.
  • Edge cases:
    • Chỉ 1 qualified visit -> không tính cycle, vào KH chưa quay lại ghé.
    • Nhiều qualified visits cùng ngày khác branch -> có thể vào bucket Cùng ngày (chu kỳ = 0 ngày).

FORMULA-005: Tỷ lệ khách theo thời gian chưa ghé lại

  • Mô tả: Tỷ trọng khách theo bucket days_since_last_visit.
  • Công thức: bucket_rate = bucket_customer_count / total_customer x 100
  • Ví dụ: 450/1.250 khách chưa ghé lại 31-60 ngày -> 36.00%.
  • Edge cases: total_customer=0 -> hiển thị -.

FORMULA-006: Đơn mua gần nhất trong popup

  • Mô tả: Ngày tạo đơn hợp lệ gần nhất của khách đến to_date, dùng làm ngữ cảnh chăm sóc.
  • Công thức: closest_order_date = max(order.created_at where customer_id = X and created_at <= to_date and order valid)
  • Ví dụ: Khách ghé không mua 10/04/2026, đơn gần nhất 15/03/2026 -> popup hiển thị 15/03/2026.
  • Edge cases: Chưa từng mua -> hiển thị Chưa từng mua (rõ scope all-time, không nhầm "chưa mua trong kỳ").

A11) Traceability

FRDECFormulaUIDevQA
FR-001DEC-001, DEC-011, DEC-015, DEC-022-SCR-01C1/C6TC-001, TC-017
FR-002DEC-002, DEC-003, DEC-006, DEC-014, DEC-016, DEC-023, DEC-024FORMULA-001SCR-01C3/C5/C8TC-002, TC-010, TC-011, TC-012, TC-018
FR-003DEC-004, DEC-005, DEC-012, DEC-029, DEC-030FORMULA-002, FORMULA-005SCR-01C3/C5/C6TC-003, TC-021, TC-024
FR-004DEC-004, DEC-005, DEC-006, DEC-018, DEC-019, DEC-029, DEC-030FORMULA-003, FORMULA-004, FORMULA-005SCR-01C3/C5/C6TC-004, TC-007, TC-015, TC-021, TC-022, TC-024
FR-005DEC-007, DEC-008, DEC-009, DEC-017, DEC-021, DEC-026, DEC-029, DEC-030FORMULA-006, FORMULA-007SCR-02C5/C6TC-005, TC-013, TC-019, TC-023
FR-006DEC-010, DEC-013, DEC-016, DEC-020-SCR-01/SCR-02C1/C8/C9/C11TC-006, TC-008, TC-012, TC-016
FR-007DEC-028-SCR-01C1/C6TC-020