Appearance
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 đổi | Section | Ảnh hưởng |
|---|---|---|
| Thêm 4 DEC: DEC-027 umbrella, DEC-028 info strip, DEC-029 sub-text + rename T6, DEC-030 tooltip 19 required | Z) Nhật ký quyết định | All |
| Thêm FR-007 Info strip luôn hiển thị | A5) FR-007 | FE |
| Update AC FR-003 thêm sub-text 3 cards overview | A5) FR-003 | FE |
| Update AC FR-004 thêm sub-text matrix + column rename T6 | A5) FR-004 | FE |
| Update AC FR-005 thêm popup column tooltip 6 ℹ️ icon | A5) FR-005 | FE |
| A11 traceability — thêm FR-007 + DEC mới + TC mới | A11) Traceability | All |
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
| ID | Nhóm | Quyết định | Lý do | Status |
|---|---|---|---|---|
| DEC-001 | Business | Tách tab mới Chu kỳ khách ghé không mua | Không lẫn visit no purchase với repurchase theo order | Locked |
| DEC-002 | Business | Phase 1 chỉ tính consultant và do_service | Đúng hành vi khách đã ghé/tư vấn/làm dịch vụ | Locked |
| DEC-003 | Data | Khách ghé không mua = qualified visit có is_zero_order=true | Reuse rule Không phát sinh doanh số của CRM Visit | Locked |
| DEC-004 | UX | Reuse shell tab mua hàng nhưng rewrite wording/data binding | Giảm learning cost nhưng giữ đúng nghĩa | Locked |
| DEC-005 | UX | Row 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ức | Locked |
| DEC-006 | Tech | by_month = duration * 30 ngày | Bám thuật toán hiện có | Locked |
| DEC-007 | UX/Data | Popup 1 khách/row; branch theo latest qualified visit, multi-branch cùng ngày hiển thị Nhiều chi nhánh | Deterministic, dùng được cho CSKH | Locked |
| DEC-008 | Business | Đơn mua gần nhất (rename T1) lấy đơn hợp lệ gần nhất đến to_date | Cho CSKH ngữ cảnh lịch sử | Locked |
| DEC-009 | Delivery | Popup 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 popup | Locked |
| DEC-010 | Tech | Thêm action/query mới riêng | Bảo vệ tab Chu kỳ mua hàng | Locked |
| DEC-011 | UX | Tab mới persist sau refresh | Hành vi report group nhất quán | Locked |
| DEC-012 | UX/Tech | Không có data trả payload rỗng hợp lệ | Empty là trạng thái bình thường | Locked |
| DEC-013 | Security/Delivery | Export deferred | Cần permission/export contract riêng | Locked |
| DEC-014 | Business | Loạ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ính | Marketing chỉ target nhóm chưa chuyển đổi; tránh CSKH gọi nhầm khách đã mua | Locked |
| DEC-015 | Tech/UX | Fix kèm bug CustomerCycleReport.tsx:37 (to: CUSTOMER_CYCLE_PURCHASE → CUSTOMER_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án | Locked |
| DEC-016 | Security/Tech | BE 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 FE | Diva RBAC; tránh leak data cross-branch | Locked |
| DEC-017 | UX | Cột Loại ghé popup: thứ tự cố định Tư vấn → Làm dịch vụ, separator + | Determinism, CSKH đọc nhất quán | Locked |
| DEC-018 | UX/Tech | Bucket 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 purchase | Quirk gây hiểu nhầm; tab cũ giữ nguyên (DEC-010) | Locked |
| DEC-019 | UX | Cell matrix value 0/rỗng → cursor default, KHÔNG mở popup | Tránh popup empty vô nghĩa | Locked |
| DEC-020 | Tech/NFR | P95 < 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-021 | UX | Popup title {Row label} • {Column label} (separator •) | Consistency riêng cho tab mới; tab cũ giữ format (Chu kỳ: ...) | Locked |
| DEC-022 | UX | Tab label responsive: ≥768px Chu kỳ khách ghé không mua, <768px Khách ghé không mua | Tránh overflow mobile; vẫn rõ nghĩa | Locked |
| DEC-023 | UX/Tech | Date range filter max 365 ngày; vượt → FE clamp + toast warning | Performance + readability matrix | Locked |
| DEC-024 | UX/Tech | Bước chu kỳ: by_day [1,180], by_month [1,12]; out-of-range → FE inline error + BE reject | Input safety; tránh empty matrix do bucket=0 | Locked |
| DEC-026 | Tech/Data | Detail action nhận thêm branch_ids; BE re-apply resolveBranchScope rồi filter qualified_visits trong popup | Khớp matrix bucket; tránh drift khi khách có visit ở nhiều branch | Locked (review C-03) |
| DEC-027 | UX | Adopt Hybrid clarity approach (v2.8 umbrella) — 3 layer info strip + sub-text + tooltip | PO feedback 11/05/2026 "mờ hồ" | Locked |
| DEC-028 | UX | Info strip permanent trên đầu tab, KHÔNG dismiss, KHÔNG localStorage | Always-visible context | Locked |
| DEC-029 | UX | Inline 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 visible | Locked |
| DEC-030 | UX | Tooltip 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 backlog | DoD discovery cho user mới | Locked |
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ếpMapping FR
| FR | Screen | Dev surface | Decision |
|---|---|---|---|
| FR-001 Tab mới + persistence | SCR-01 | FE tab constants/panel | DEC-001, DEC-011, DEC-015, DEC-022 |
| FR-002 Qualified visit filter | SCR-01 | BE aggregate action | DEC-002, DEC-003, DEC-006, DEC-014, DEC-016, DEC-023, DEC-024 |
| FR-003 Overview visit-based | SCR-01 | FE chart + BE overview output | DEC-004, DEC-005, DEC-012 |
FR-004 Matrix Chu kỳ khách ghé | SCR-01 | FE matrix + BE table output | DEC-004, DEC-005, DEC-006, DEC-018, DEC-019 |
| FR-005 Popup detail | SCR-02 | Detail action + popup columns | DEC-007, DEC-008, DEC-009, DEC-017, DEC-021 |
| FR-006 Scope guard/regression | SCR-01/SCR-02 | Separate actions + tests | DEC-010, DEC-013, DEC-016, DEC-020 |
| FR-007 Info strip permanent | SCR-01 | FE component | DEC-028 |
A1) Bức tranh nghiệp vụ
| Field | Value |
|---|---|
| Feature | Tab Chu kỳ khách ghé không mua |
| Product area | Báo cáo khách hàng / CRM |
| Platform | Web Admin |
| Primary users | Marketing Manager, CSKH/CRM, Quản lý chi nhánh |
| Complexity | M |
| Release style | Additive report tab |
A2) Bối cảnh
Hiện trạng
CustomerCycleReport.tsxcó report group với tabTổng quan,Chu kỳ mua hàng,Chu kỳ khách hàng.Chu kỳ mua hàngtính trênorder.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ó filterKhông phát sinh doanh số; backend lưu tương ứngall_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êu | Chỉ số | Target |
|---|---|---|
| Đọc đúng nhóm khách đã ghé nhưng chưa mua | Qualified visit dùng all_customer_visits + is_zero_order=true | 100% |
| Loại khách đã chuyển đổi | Khách trong popup không có order với created_at > last_qualified_visit_date | 100% data correct |
| Tránh lẫn với repurchase | Tab mới không còn wording mua lại, rời bỏ, bắt đầu mua | 100% copy mới |
| Giữ an toàn tab cũ | reportPurchaseCycle và reportPurchaseCycleDetail không đổi output contract | 0 regression P0 |
| CSKH dùng được popup | Popup 1 khách/row, có branch/latest visit/days since last visit | UAT pass |
A4) Persona
| Persona | Nhu cầu | Tần suất |
|---|---|---|
| Marketing Manager | Chọn nhóm khách đã ghé nhưng chưa mua để remarketing | Hàng tuần |
| CSKH/CRM | Tìm khách cần gọi lại sau tư vấn/làm dịch vụ chưa mua | Hàng ngày |
| Quản lý chi nhánh | Theo dõi chi nhánh có nhiều khách ghé nhưng chưa chuyển đổi | Hà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_groupcó tab labelChu kỳ khách ghé không mua. - Tab mới có key riêng
CUSTOMER_CYCLE_VISIT_NO_PURCHASE, không dùng lạiCUSTOMER_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.currentTabhợ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].tophải bằngCUSTOMER_CYCLE_CUSTOMER(hiện tại sai bằngCUSTOMER_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ạiCUSTOMER_CYCLE_RATEkhỏ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_sourcegiao vớiconsultanthoặcdo_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ỳ, đếnto_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')ANDorder_kind IN ('service', 'cosmetic', 'prepaid')(canonical theopkg/store/order.go:174,191; cùng định nghĩa với tabChu 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_monthvàduration=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.branchescủactx.Access.UserIDtrước khi queryall_customer_visits;user.branches=[]nghĩa là no branch restriction, non-empty thì intersect vớiinput.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ùnginput.BranchIDstrực tiếp; nếu input rỗng, không filter branch. Không xác định bypass bằngActor.IsAdmin(). - Date range max (DEC-023): Nếu
to_date - from_date > 365 ngày, FE clampfrom_date = to_date - 365 ngàyvà hiển thị toastPhạ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ả errorinvalid_date_range. - Bước chu kỳ validation (DEC-024):
type_cycle=by_day→duration ∈ [1, 180];type_cycle=by_month→duration ∈ [1, 12]. Out-of-range → FE inline error trên inputBướ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ả errorinvalid_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ồmKH đã quay lại ghévà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 muatrong tab mới. - Khi
total_customer=0, FE hiển thị empty stateChư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ệnNaN. - 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.
- Dưới label Card 1
- 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ạihoặcSố tháng chưa ghé lạitheotype_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évàCùng ngàykhô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 quirk3 tháng / 3 - 6 / 6 - 9của tab purchase). Boundary vẫn theoduration × 30 ngày(DEC-006). - Click bucket (DEC-019): chỉ ô có value > 0 mới attach click handler (cursor
pointer); ô value0hoặc rỗng → cursordefault, không mở popup. - User click ô data hoặc cột
Tổngcủ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-textKhoả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-textTính tới ngày kết thúc kỳ.
- Header trục dọc
- Sub-text matrix rows (DEC-029):
- Row
Cùng ngày→ sub-textNhiều lượt ghé qualified trong 1 ngày. - Row
KH chưa quay lại ghé→ sub-textChỉ có 1 lượt ghé qualified trong kỳ.
- Row
- 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, tabChu kỳ mua hànggiữ nguyênTỷ 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ổngphả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ánhhiển thịNhiều chi nhánh. - Cột
Loại ghé(DEC-017): lấyvisit_sourcecủa latest qualified visit; mapconsultant→Tư vấn,do_service→Làm dịch vụ; nếu visit có cả 2 source, hiển thị theo thứ tự cố địnhTư 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ấnhoặcLà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-applyresolveBranchScoperồi resolvelast_visit_date,branch_name,count_visit_no_orderchỉ 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àngpass 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_idscũ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 keycustomer_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
| ID | Giả định | Cách kiểm chứng |
|---|---|---|
| ASM-001 | all_customer_visits được cập nhật đủ cho nguồn consultant/do_service | QA seed và đối chiếu CRM Visit |
| ASM-002 | is_zero_order=true phản ánh đúng Không phát sinh doanh số | Test same-day order |
| ASM-003 | Branch scope FE hiện tại đủ cho report phase 1 | Test 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-005 | ctx.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/HQ | Spike test action mới với 1 user Manager + 1 user admin/HQ có user.branches=[] |
A7) Rủi ro
| Risk | Impact | Mitigation |
|---|---|---|
| Data lớn làm action chậm | Report timeout | Reuse 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à churn | Sai quyết định marketing | Tooltip và glossary phân biệt rõ |
| Empty data bị coi là lỗi | UX xấu | Action trả payload rỗng, FE empty state tiếng Việt |
| Sửa tab cũ ngoài ý muốn | Regression report | Action/query mới riêng, QA regression |
| Manager bypass branch scope | Leak data cross-branch | DEC-016 BE enforce intersect; QA TC-012 explicit attempt |
Tab cũ rớt persistence sau refresh do bug to mapping | UX confuse | DEC-015 fix kèm trong cùng PR, QA TC-001 cover |
| Visit multi-source hiển thị không nhất quán | CSKH đọc sai tín hiệu | DEC-017 fixed order + separator |
| Bucket by_month label gây hiểu nhầm | User đọc sai bucket | DEC-018 đổi convention sạch cho tab mới |
| Popup empty mở vô nghĩa từ cell value=0 | UX confuse | DEC-019 disable click; QA TC-015 |
| Pull-all-to-Go fail @ 500k+ rows | Action timeout | DEC-020 benchmark gate + SQL fallback; QA TC-016 |
| Tab label overflow trên mobile | UX broken | DEC-022 responsive label |
| User chọn date range 5 năm → matrix 60+ bucket | Unreadable + slow | DEC-023 max 365 ngày + clamp + toast |
| User nhập bước chu kỳ invalid → matrix rỗng | UX confuse | DEC-024 FE/BE validate range |
A8) Metrics
| Metric | Định nghĩa | Target |
|---|---|---|
| Data correctness | Số khách popup khớp customer_ids từ matrix bucket | 100% |
| Copy correctness | Không có wording purchase trong tab mới | 100% |
| Regression | Tab Chu kỳ mua hàng không đổi số liệu với same seed | 100% P0 pass |
| Usability | CSKH mở được hồ sơ khách từ popup | UAT pass |
A9) Glossary
| Thuật ngữ VI | Thuật ngữ EN/code | Định nghĩa | Phân biệt với |
|---|---|---|---|
| Khách ghé không mua | Visit no purchase customer | Khá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 visit | Khá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ập | Khác khách có đơn cũ trước ghé (vẫn được tính) |
| Qualified visit | all_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 cycle | Số ngày trung bình giữa các qualified visit liên tiếp của cùng khách | Khác chu kỳ mua hàng |
| KH chưa quay lại ghé | Non-return visit customer | Khá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ày | Same-day visits | Khá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ày | Khá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ại | Days since last visit | Số ngày từ qualified visit gần nhất đến to_date | Khác số ngày chưa mua lại |
| Đơn mua gần nhất | Closest order | Đơn hợp lệ gần nhất tới to_date, không giới hạn trong kỳ filter | Trướ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ỳ filter | Trướ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_visitsthỏ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')ANDorder_kind IN ('service', 'cosmetic', 'prepaid')(canonical theopkg/store/order.go:174,191).
- Ví dụ:
- Khách Lan ghé
consultant10/04/2026 (qualified), tạo đơn 12/04/2026 → 12/04 > 10/04 → loại. - Khách Anh ghé
do_service10/04 và 20/04 (cả 2 qualified), tạo đơn 15/04 → 15/04 < 20/04 → giữ. - Khách Hân ghé
consultant10/04, tạo đơn 05/04 (trước ghé) → 05/04 < 10/04 → giữ. - Khách Nam ghé
consultant10/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.
- Khách Lan ghé
- 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.
- Không có qualified visit ->
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ộctarget_customerscó từ 2 qualified visits trở lên.non_return_visit_customer_count: khách thuộctarget_customerscó đú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ặcNaN.
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_datelớ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).
- Chỉ 1 qualified visit -> không tính cycle, vào
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
| FR | DEC | Formula | UI | Dev | QA |
|---|---|---|---|---|---|
| FR-001 | DEC-001, DEC-011, DEC-015, DEC-022 | - | SCR-01 | C1/C6 | TC-001, TC-017 |
| FR-002 | DEC-002, DEC-003, DEC-006, DEC-014, DEC-016, DEC-023, DEC-024 | FORMULA-001 | SCR-01 | C3/C5/C8 | TC-002, TC-010, TC-011, TC-012, TC-018 |
| FR-003 | DEC-004, DEC-005, DEC-012, DEC-029, DEC-030 | FORMULA-002, FORMULA-005 | SCR-01 | C3/C5/C6 | TC-003, TC-021, TC-024 |
| FR-004 | DEC-004, DEC-005, DEC-006, DEC-018, DEC-019, DEC-029, DEC-030 | FORMULA-003, FORMULA-004, FORMULA-005 | SCR-01 | C3/C5/C6 | TC-004, TC-007, TC-015, TC-021, TC-022, TC-024 |
| FR-005 | DEC-007, DEC-008, DEC-009, DEC-017, DEC-021, DEC-026, DEC-029, DEC-030 | FORMULA-006, FORMULA-007 | SCR-02 | C5/C6 | TC-005, TC-013, TC-019, TC-023 |
| FR-006 | DEC-010, DEC-013, DEC-016, DEC-020 | - | SCR-01/SCR-02 | C1/C8/C9/C11 | TC-006, TC-008, TC-012, TC-016 |
| FR-007 | DEC-028 | - | SCR-01 | C1/C6 | TC-020 |