Appearance
Đặc tả kỹ thuật — Tab Chu kỳ khách ghé không mua
Version: 2.8
Date: 11/05/2026
Canonical Inputs: SOURCE_OF_TRUTH.md, EVIDENCE_PACK.md, prd.md.
v2.8 — 11/05/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
| Thêm 3 component FE (InfoStrip, LabelWithSubText, 9 i18n keys) vào C1 | C1) Phạm vi | FE |
| Thêm 3 capability vào C2 (info strip, sub-text pattern, popup tooltip) | C2) Impact và reuse | FE |
| Thêm 5 FE implementation notes (info strip, label sub-text, popup tooltip, column rename T6) | C6) Frontend notes | FE |
| Thêm 10 task: FE-005..010 + QA-006..010 | C11) Task breakdown | FE, QA |
| Update C12 traceability + thêm FR-007 row | C12) Traceability | All |
Dev Spec này mô tả implementation delta. Business formulas canonical nằm ở PRD A10; section C3 chỉ ghi mapping kỹ thuật.
C1) Phạm vi
| Layer | File/Area | Change |
|---|---|---|
| FE | diva-admin/src/modules/report/types.ts | Thêm CUSTOMER_CYCLE_VISIT_NO_PURCHASE |
| FE | CustomerCycleReport.tsx | (a) Thêm tab/panel mới; (b) Fix DEC-015: tabs[CUSTOMER_CYCLE_CUSTOMER].to = CUSTOMER_CYCLE_CUSTOMER; (c) update getSavedTab() whitelist gồm 4 key đang render, loại CUSTOMER_CYCLE_RATE (không có panel) |
| FE | components/customer-cycle/* | Thêm report/chart/table/popup visit-no-purchase từ shell hiện có |
| FE | graphql/report_customer_cycle.graphql | Thêm queries cho 2 action mới |
| BE | services/ecommerce-api/action | Thêm aggregate/detail action handlers; helper resolveBranchScope (DEC-016) |
| BE | action/action.go | Register action handlers |
| Metadata | actions.graphql, actions.yaml | Thêm action input/output |
| DB | migrations nếu cần metadata/report seed | Không tạo bảng dữ liệu mới |
| FE | diva-admin/src/modules/report/components/customer-cycle/VisitNoPurchaseInfoStrip.tsx | NEW v2.8 — static info strip (FR-007/DEC-028, no props/state) |
| FE | diva-admin/src/modules/common/LabelWithSubText.tsx | NEW v2.8 — reusable component cho overview cards + matrix headers/rows (DEC-029) |
| FE | i18n locale files | NEW v2.8 — thêm 9 keys mới (xem UI B6) |
Không thuộc scope: sửa reportPurchaseCycle, sửa reportPurchaseCycleDetail, export, filter source visible, churn scoring.
C2) Impact và reuse
| Capability | Existing | Delta | Risk |
|---|---|---|---|
| Tab shell | CustomerCycleReport.tsx | Add tab/panel/persistence | Low |
| Filter | CustomerCyclePurchaseFilterReport.tsx | Reuse visible controls | Low |
| Aggregate algorithm | report_purchase_cycle.go | Reuse bucket/month pattern, source visit, thêm exclude converted (DEC-014) | Med |
| Detail popup | CustomerCycleDetailPopup.tsx | Reuse shell only, replace columns/output | High |
| No revenue rule | all_customer_visits.is_zero_order | Reuse | Low |
| Order valid rule | report_purchase_cycle.go:241-243 | Reuse cùng định nghĩa cho exclude converted | Low |
| Permission | Hasura actions role user | Add actions; enforce branch scope | Med |
| Info strip | (none — new pattern in report module) | Build mới static component, no props/state (DEC-028) | Low |
| Label sub-text pattern | (none — new pattern) | Build mới reusable component (DEC-029) | Low |
| Tooltip icon on popup columns | Existing q-tooltip pattern trên matrix labels | Extend sang popup column headers (DEC-029, DEC-030) | Low |
C3) Quy tắc và công thức triển khai
FORMULA-001: Tổng khách có lượt ghé không mua
- Ref: PRD A10 FORMULA-001
- Source:
all_customer_visits,order - Filter SQL skeleton:
sql
WITH qualified_visits AS (
SELECT customer_id, visit_date, branch_id, visit_source
FROM all_customer_visits
WHERE visit_date BETWEEN :from_date::date AND :to_date::date
AND (:branch_ids IS NULL OR branch_id::text = ANY(:branch_ids))
AND is_zero_order = true
AND visit_source && ARRAY['consultant', 'do_service']
),
last_qualified_visit AS (
SELECT customer_id, MAX(visit_date) AS last_visit_date
FROM qualified_visits
GROUP BY customer_id
),
converted_customers AS (
-- DEC-014: khách có order hợp lệ tạo SAU lượt ghé qualified gần nhất → loại
SELECT DISTINCT lqv.customer_id
FROM last_qualified_visit lqv
JOIN "order" o
ON o.customer_id = lqv.customer_id
AND o.created_at > (lqv.last_visit_date + interval '1 day')::timestamptz -- > end-of-last-visit-day
AND o.created_at <= :to_date
AND o.order_service_status NOT IN ('order_canceled', 'prepaid_canceled') -- canonical theo pkg/store/order.go:174,191
AND o.order_kind IN ('service', 'cosmetic', 'prepaid')
),
target_customers AS (
SELECT customer_id FROM last_qualified_visit
EXCEPT
SELECT customer_id FROM converted_customers
)
SELECT COUNT(*) FROM target_customers;Implementation notes:
last_visit_datelưu kiểu DATE → so sánh vớiorder.created_at(timestamptz) phải ép kiểu để tránh boundary error: dùng(last_visit_date + interval '1 day')::timestamptz(theo timezone VN) làm "đầu ngày kế tiếp", order tạo trong cùng ngàylast_visit_datekhông loại khách (vì cùng ngày coi như chưa quan sát).target_customerslà tập khách dùng cho mọi formula sau (cycle, frequency, bucket). Aggregate trong Go phải filter set này trước khi bucket.- Detail action (
reportVisitCycleNoPurchaseDetail) phải nhậncustomer_idsđã được aggregate filter — KHÔNG tự re-compute exclude logic ở detail layer (tránh inconsistency matrix vs popup).
FORMULA-002: Cơ cấu khách: đã/chưa quay lại ghé
- Ref: PRD A10 FORMULA-002
- Implementation: group
qualified_visits WHERE customer_id IN target_customersbycustomer_id;count >= 2làreturn_visit_customer,count = 1lànon_return_visit_customer. - Critical (M-01 fix): PHẢI filter theo
target_customers(đã loại converted theo DEC-014). Nếu group toàn bộqualified_visitssẽ tính cả khách đã chuyển đổi vào donut, sai DEC-014. - Dữ liệu rỗng: trả count
0, rate"-"hoặc"0.00%"theo FE convention nhưng khôngNaN.
FORMULA-003: Số ngày chưa ghé lại
- Ref: PRD A10 FORMULA-003
- Implementation:
daysSinceLastVisit = normalizeEndDate(to_date) - max(visit_date)vớicustomer_id IN target_customers(M-01 — chỉ tính khách trong target). - Clamp: nếu
to_date> now, clamp như purchase action hiện tại.
FORMULA-004: Chu kỳ khách ghé
- Ref: PRD A10 FORMULA-004
- Implementation: từ
qualified_visits WHERE customer_id IN target_customers, sort visits by date, average consecutive gaps (M-01). - Map:
count=1-> rowKH chưa quay lại ghé; average0-> rowCùng ngày(rename T3 — chu kỳ = 0 ngày).
FORMULA-005: Bucket rate + Bucket label convention (DEC-018)
- Ref: PRD A10 FORMULA-005, DEC-018
- Implementation: Boundary giữ nguyên (
durationDay = durationcho by_day,duration * 30cho by_month) — KHÔNG reuse bucket builder củareport_purchase_cycle.govì phát sinh quirk label by_month. Implement riêng cho tab mới:
go
// buildBuckets returns (durationDays, labels)
func buildBuckets(typeCycle TypeCycle, duration, fromDate, toDate time.Time) (int, []string) {
durationDay := duration
unit := " ngày"
if typeCycle == TypeCycleMonth {
durationDay = duration * 30
unit = " tháng"
}
spanDays := int(toDate.Sub(fromDate).Hours() / 24)
countDurations := spanDays / durationDay
remainder := spanDays % durationDay
var labels []string
if typeCycle == TypeCycleMonth {
// DEC-018: label sạch cho tab mới
// i=0: "≤ 3 tháng"; i=1: "4 - 6"; i=2: "7 - 9"; ...; remainder: "> N"
labels = append(labels, "≤ "+strconv.Itoa(duration)+unit)
for i := 1; i < countDurations; i++ {
t1 := duration*i + 1 // i=1: 4
t2 := duration * (i + 1) // i=1: 6
labels = append(labels, strconv.Itoa(t1)+" - "+strconv.Itoa(t2)+unit)
}
if remainder > 0 && countDurations >= 1 {
labels = append(labels, "> "+strconv.Itoa(countDurations*duration)+unit)
}
} else {
for i := 0; i < countDurations; i++ {
t1 := duration*i + 1
t2 := duration * (i + 1)
labels = append(labels, strconv.Itoa(t1)+" - "+strconv.Itoa(t2)+unit)
}
if remainder > 0 {
labels = append(labels, "> "+strconv.Itoa(countDurations*duration+1)+unit)
}
}
return durationDay, labels
}- Ví dụ:
type_cycle=by_month, duration=3, kỳ 9 tháng → labels["≤ 3 tháng", "4 - 6 tháng", "7 - 9 tháng"]. Boundary semantics:≤ 3=0 < days ≤ 90;4 - 6=90 < days ≤ 180;7 - 9=180 < days ≤ 270. - Khác purchase tab: purchase tab labels
["3 tháng", "3 - 6 tháng", "6 - 9 tháng"](overlap số) — KHÔNG đụng vào (DEC-010).
FORMULA-006: Đơn mua gần nhất
- Ref: PRD A10 FORMULA-006
- Query:
orderbycustomer_id,created_at <= to_date, valid status, valid kind. - Output: null maps to
Chưa từng mua(rename T2 — rõ scope all-time).
FORMULA-007: branch_name resolution cho popup (DEC-007 + DEC-026)
- Ref: PRD FR-005 + DEC-007 + DEC-026
- Mục tiêu: Popup hiển thị 1 chi nhánh hoặc
Nhiều chi nhánhcho latest qualified visit. - Critical (C-03 fix):
qualified_visitsở detail action phải filter theoeffective_branchestừresolveBranchScope(ctx, input.branch_ids)để khớp scope matrix bucket. Nếu không filter, khách có visit nhiều branch sẽ resolve latest visit ngoài scope user chọn. - Query SQL pseudocode:
sql
WITH last_visit AS (
SELECT customer_id, MAX(visit_date) AS last_visit_date
FROM qualified_visits -- đã filter scope (effective_branches)/source/no-revenue
WHERE customer_id IN (:input_customer_ids) -- từ aggregate
GROUP BY customer_id
),
last_visit_branches AS (
SELECT
lv.customer_id,
lv.last_visit_date,
array_agg(DISTINCT qv.branch_id ORDER BY qv.branch_id) AS branch_ids,
array_agg(DISTINCT b.name ORDER BY b.name) AS branch_names
FROM last_visit lv
JOIN qualified_visits qv
ON qv.customer_id = lv.customer_id
AND qv.visit_date = lv.last_visit_date
JOIN branch b ON b.id = qv.branch_id
GROUP BY lv.customer_id, lv.last_visit_date
)
SELECT
customer_id,
last_visit_date,
CASE
WHEN array_length(branch_ids, 1) > 1 THEN 'Nhiều chi nhánh'
ELSE branch_names[1]
END AS branch_name
FROM last_visit_branches;- Edge case:
- 1 visit cùng ngày 2 branch →
branch_ids = [Q1, Q3]→Nhiều chi nhánh. - 1 visit cùng ngày cùng branch nhưng 2 source (consultant + do_service) →
branch_ids = [Q1](DISTINCT) → tên Q1. - Nhiều ngày khác nhau →
last_visit_datechỉ MAX, các ngày trước không tính branch.
- 1 visit cùng ngày 2 branch →
- Implementation note: Nếu Go aggregate, làm sau khi đã có
target_customers: queryqualified_visitsfiltered theotarget_customers, group by customer_id, lấy max date, rồi resolve branch.
C4) Data model
| Object | Type | Fields used |
|---|---|---|
all_customer_visits | Existing table | customer_id, visit_date, branch_id, visit_source, is_zero_order, customer_status |
ecommerce_user | Existing table | id, display_name, phone_number, avatar_url |
branch | Existing table | id, name |
order | Existing table | customer_id, created_at, order_kind, order_service_status, total, paid_amount |
No new persistent table in phase 1.
C5) Integration contracts
Action: reportVisitCycleNoPurchase
Input
graphql
input ReportVisitCycleNoPurchaseInput {
type_cycle: String!
duration: Int!
from_date: timestamptz!
to_date: timestamptz!
branch_ids: [String]
}Output
graphql
type ReportVisitCycleNoPurchaseOutput {
visit_cycle_overview: VisitCycleNoPurchaseOverview
visit_cycle_table: VisitCycleNoPurchaseTable
}
type VisitCycleNoPurchaseOverview {
total_customer: Int
visit_frequency: VisitFrequency
return_rate_by_time: [ReturnRateByTime]
}
type VisitFrequency {
return_visit_customer_count: Int
return_visit_customer_rate: String
non_return_visit_customer_count: Int
non_return_visit_customer_rate: String
}
type VisitCycleNoPurchaseTable {
durations: [VisitsByDuration]
visit_cycle_rows: [VisitCycleRows]
}
type VisitsByDuration {
duration: String
visits_by_duration_column: [VisitsByDurationColumn]
}
type VisitsByDurationColumn {
column: String
customer_ids: [String]
}
type VisitCycleRows {
row: [String]
}No-data contract
Return payload with total_customer=0, empty durations/rows initialized enough for FE empty state. Do not return business error like order_not_found_with_this_filter.
Input validation contract (DEC-023, DEC-024)
BE phải validate input trước khi thực thi aggregate. Defense in depth — không tin FE đã validate.
go
func validateInput(input *ReportVisitCycleNoPurchaseInput) error {
// (1) Date range
if input.Data.ToDate.Before(input.Data.FromDate) {
return util.NewError("invalid_date_range", "to_date phải sau from_date")
}
rangeDays := int(input.Data.ToDate.Sub(input.Data.FromDate).Hours() / 24)
if rangeDays > 365 {
return util.NewError("invalid_date_range", "Phạm vi báo cáo tối đa 365 ngày")
}
// (2) Type cycle
if input.Data.TypeCycle != TypeCycleDay && input.Data.TypeCycle != TypeCycleMonth {
return util.NewError("invalid_type_cycle", "type_cycle không hợp lệ")
}
// (3) Duration validation (DEC-024)
switch input.Data.TypeCycle {
case TypeCycleDay:
if input.Data.Duration < 1 || input.Data.Duration > 180 {
return util.NewError("invalid_duration", "Bước chu kỳ phải từ 1 đến 180 ngày")
}
case TypeCycleMonth:
if input.Data.Duration < 1 || input.Data.Duration > 12 {
return util.NewError("invalid_duration", "Bước chu kỳ phải từ 1 đến 12 tháng")
}
}
// (4) Clamp to_date về now nếu vượt
if input.Data.ToDate.After(time.Now()) {
input.Data.ToDate = time.Now()
}
return nil
}FE preflight validate giống logic trên — nếu fail thì không gửi request. Toast/inline error theo UI B4.
Action: reportVisitCycleNoPurchaseDetail
Input
graphql
input ReportVisitCycleNoPurchaseDetailInput {
customer_ids: [String!]!
from_date: timestamptz!
to_date: timestamptz!
branch_ids: [String] # DEC-026: phải nhận để khớp matrix bucket
search: String
limit: Int
offset: Int
}Branch scope contract (DEC-026):
- FE phải truyền
branch_idsđúng với filter của aggregate vừa gọi (cùng giá trị FE đang giữ trong filter state). - BE detail handler:
- Call
resolveBranchScope(ctx, input.BranchIDs)→effective_branches(DEC-016 revision) - Filter
qualified_visitstheocustomer_id IN customer_idsANDbranch_id IN effective_branchesAND time range - Resolve
last_visit_date,branch_name,count_visit_no_orderchỉ trong scopeeffective_branches
- Call
- KHÔNG re-compute exclude-converted ở detail (trust
customer_idstừ aggregate). - Verification rule: với same filter,
countcủa detail phải bằng số khách trong bucket vừa click ở matrix.
Output
graphql
type ReportVisitCycleNoPurchaseDetailOutput {
count: Int
cycle_details: [VisitNoPurchaseCycleDetail]
}
type VisitNoPurchaseCycleDetail {
customer_id: String
display_name: String
phone_number: String
avatar_url: String
branch_name: String
last_visit_date: timestamptz
visit_source: [String] # Thứ tự cố định DEC-017: ['consultant', 'do_service'] — FE map "Tư vấn + Làm dịch vụ"
count_visit_no_order: Int
closest_order_date: timestamptz
days_since_last_visit: Int
}Visit source ordering (DEC-017):
BE phải normalize visit_source array của latest qualified visit theo thứ tự cố định trước khi return:
go
sourceOrder := []string{"consultant", "do_service"}
normalized := []string{}
for _, s := range sourceOrder {
if slices.Contains(visit.Source, s) {
normalized = append(normalized, s)
}
}
detail.VisitSource = normalizedFE join bằng + với map consultant → "Tư vấn", do_service → "Làm dịch vụ". Nếu BE trả mảng theo thứ tự khác, FE vẫn phải đảm bảo thứ tự cuối cùng đúng convention (defense in depth).
C6) Frontend implementation notes
| Item | Note |
|---|---|
| Tab key | Add CUSTOMER_CYCLE_VISIT_NO_PURCHASE = "customer_cycle_visit_no_purchase" |
| Persistence (DEC-015) | validTabs whitelist phải gồm 4 key đang render thật: OVERVIEW, PURCHASE, CUSTOMER, VISIT_NO_PURCHASE. Loại RATE (không có panel render). Trong tabs config, sửa [CUSTOMER_CYCLE_CUSTOMER].to = CUSTOMER_CYCLE_CUSTOMER (bug hiện tại trỏ về CUSTOMER_CYCLE_PURCHASE) |
| Components | Prefer new components named CustomerCycleVisitNoPurchaseReport, ...Table, ...ViewChart, ...DetailPopup |
| Filter | Reuse CustomerCyclePurchaseFilterReportType/getInitValue if type names do not confuse generated code; otherwise extract shared filter type |
| GraphQL | Add queries in report_customer_cycle.graphql; run codegen if repo workflow requires |
| Copy | Use UI B6 dictionary; no purchase wording in new tab |
| Popup | Bỏ nút export và bỏ filter dịch vụ/nhóm dịch vụ trong popup mới |
| Visit source label (DEC-017) | Map consultant → "Tư vấn", do_service → "Làm dịch vụ". Join theo thứ tự ["consultant","do_service"] cố định, separator +. Component utility formatVisitSource(sources: string[]): string để FE reuse |
| Cell click guard (DEC-019) | Cell value "0", "", hoặc "0.00%" → render <td> không có @click, không có class cursor-pointer. Cell value > 0 → bind click + cursor pointer. Áp dụng cho cả ô data và cột Tổng của row data. Giữ behavior cho row header (rowIndex < 3 vẫn không bind click theo pattern hiện tại) |
| Tab label responsive (DEC-022) | Component XTabs đã có outsideArrows + scroll. Bổ sung 2 i18n key: customer_cycle.tab.full và customer_cycle.tab.short. Logic: useBreakpoint() (Quasar $q.screen.lt.md ~768px) chọn key. Tab key + URL không đổi giữa 2 viewport. |
| Popup title format (DEC-021) | Build từ ${rowLabel} ${separator} ${columnLabel} với separator i18n key customer_cycle.popup.title_separator •. Component popup nhận rowLabel và columnLabel riêng (KHÔNG nhận title đã build sẵn) để dễ test/i18n |
| Date range max (DEC-023) | FE DateRangePicker hoặc XInputDateRangePicker validate onChange: nếu to - from > 365 ngày → clamp from = to - 365 ngày + emit toast ($q.notify). BE validate độc lập (xem C5 input validation contract) |
| Duration validation (DEC-024) | FE input có @blur hoặc @input validate range theo type_cycle. Sai range → display inline error message từ i18n key, KHÔNG gửi request. BE cũng validate (defense in depth) |
| Info strip (FR-007/DEC-028) | New component VisitNoPurchaseInfoStrip.tsx (static, no state, no props). Render với i18n key customer_cycle.info_strip. Style: q-banner của Quasar với background bg-amber-50, icon ℹ️ (q-icon name="info"), border-radius nhẹ, padding 12-16px. Place trong tab panel render tree, ngay sau tab bar + trước filter bar. KHÔNG dùng localStorage. A11y: role="status", aria-live="polite" |
| Label sub-text component (DEC-029) | Build reusable <LabelWithSubText> ở diva-admin/src/modules/common/. Props: label: string, subtext?: string, tooltip?: string, level?: 'card' | 'header' | 'row'. Style sub-text: font-size 12px (text-caption), color text-grey-7 hoặc text-secondary, margin-top 4px, line-height tight. Mobile: wrap, không cắt. Tooltip icon ℹ️ nếu prop tooltip truyền, dùng q-icon name="info" + q-tooltip |
| Sub-text integration (DEC-029) | Apply <LabelWithSubText> cho: 3 cards overview (Card 1 + 2 Donut), matrix header dọc + ngang, matrix rows Cùng ngày + KH chưa quay lại ghé. Donut sub-text dùng template Trên tổng {total} KH với {total} từ overview.total_customer |
| Popup column tooltip (DEC-029, DEC-030) | Mỗi <th> của 6 popup columns thêm <q-icon name="info"><q-tooltip>{copy}</q-tooltip></q-icon> inline cạnh tên column. Copy từ i18n key. Mobile: tap toggle tooltip |
| Column rename T6 (DEC-029) | Trong file matrix table component: đổi i18n key column cuối từ Tỷ lệ (cũ) → customer_cycle.matrix.col_percent = % trên tổng. Logic data binding KHÔNG đổi — vẫn render value / total_customer × 100%. Quan trọng: chỉ đổi trong component tab mới, KHÔNG đụng vào tab Chu kỳ mua hàng |
C7) Thay đổi metadata và migration
| Step | Required |
|---|---|
| Add action definitions | Yes |
| Add action types | Yes |
| Add handler registration | Yes |
| DB table migration | No |
| Index migration | Optional if performance test shows scan on all_customer_visits slow; existing branch/source indexes already present |
| Report seed | No for phase 1. Tab inherits existing customer_cycle_report_group access and CustomerCycleReport currently renders tabs statically. Do not add report_role seed unless a later decision introduces per-tab permission; that later change must update FE tab filtering by globalStore.reportRoles in the same PR |
C8) Bảo mật và quyền
Branch scope enforcement (DEC-016)
BE 2 actions mới (reportVisitCycleNoPurchase, reportVisitCycleNoPurchaseDetail) bắt buộc thực hiện theo pattern sau, trước khi chạy SQL aggregate:
go
// resolveBranchScope intersects FE-provided branch_ids with user's allowed branches.
// IMPORTANT (C-01 fix): KHÔNG dùng `ctx.Access.IsAdmin()` — hàm này trả true cho cả `RoleUser`
// (xem pkg/access/role.go:62-64), trong khi Hasura action chạy role `user`. Dùng nó sẽ bypass
// branch intersect cho mọi Manager/Staff.
//
// Pattern đúng: query DB user.branches; nếu rỗng → user không bị giới hạn (admin/HQ pattern)
// → trust input. Nếu non-empty → intersect.
func resolveBranchScope(ctx actionContext, requested []string) ([]string, error) {
var query struct {
User struct {
Branches []struct {
BranchID string `graphql:"branch_id"`
} `graphql:"branches"`
} `graphql:"ecommerce_user_by_pk(id: $user_id)"`
}
if err := ctx.AdminClient.Query(ctx.Context.Ctx(), &query, map[string]interface{}{
"user_id": ctx.Access.UserID,
}); err != nil {
return nil, err
}
allowed := lo.Map(query.User.Branches, func(b struct{ BranchID string }, _ int) string {
return b.BranchID
})
// Empty allowed = user không có branch restriction (admin/HQ-level pattern)
// → trust input directly. Nếu input empty cũng pass through (no branch filter).
if len(allowed) == 0 {
return requested, nil
}
// User has explicit branch list (Manager/Staff) → intersect
if len(requested) == 0 {
return allowed, nil // user không chọn → dùng toàn bộ allowed
}
return lo.Intersect(requested, allowed), nil
}Verification matrix:
| User type | user.branches DB | requested (FE) | Result |
|---|---|---|---|
| Admin/HQ không có branch | [] | [] | [] (no filter, trả tất cả) |
| Admin/HQ không có branch | [] | [Q1, Q3] | [Q1, Q3] (trust admin input) |
| Manager Q1 | [Q1] | [] | [Q1] |
| Manager Q1 | [Q1] | [Q3] | [] (intersection rỗng → empty payload DEC-012) |
| Manager Q1+Q3 | [Q1, Q3] | [Q1] | [Q1] |
| Manager Q1+Q3 | [Q1, Q3] | [Q1, Q4] | [Q1] (Q4 bị filter) |
Rules:
- Nếu intersection rỗng → action trả empty payload theo DEC-012 (
total_customer=0, empty rows). KHÔNG trả error nghiệp vụ. - Detail action: ngoài intersect branch, phải verify
customer_idsđầu vào có thực thuộc kết quả aggregate gần nhất của user — không tự ý expose customer ngoài bucket. Cách đơn giản: action detail tự re-query qualified visits với scope đã intersect rồi filtercustomer_ids ∩ scoped_customers. - New actions use Hasura role
userlike existing report actions; KHÔNG dùng roleadminđể bypass. - Do not expose financial fields in popup phase 1.
- Export is deferred; do not add export endpoint or file download in phase 1.
Threat model rút gọn
| Threat | Mitigation |
|---|---|
Manager Q1 gửi branch_ids=[Q3] qua DevTools | resolveBranchScope intersect → empty |
| FE bug gửi sai branch | Cùng cơ chế: BE filter ra branch không thuộc scope |
Detail action nhận customer_ids đoán (UUID enumeration) | Re-query qualified visits với scope, intersect với customer_ids đầu vào |
| Admin/HQ chính đáng (no branch restriction) | user.branches rỗng → trust input. KHÔNG dùng Actor.IsAdmin() vì returns true cho cả RoleUser (footgun). |
| Bypass via Hasura role swap | Action permission cố định role user; resolveBranchScope dựa vào ctx.Access.UserID (UUID) chứ không trust role. |
C9) Yêu cầu phi chức năng
| NFR | Target |
|---|---|
| Performance @ 100k qualified visits (DEC-020) | P95 aggregate < 3s, P95 detail < 1s |
| Performance @ 500k qualified visits (DEC-020) | P95 aggregate < 5s, P95 detail < 2s |
| Performance gate trước GA | Benchmark report bắt buộc với seed 100k và 500k; fail target → switch sang SQL aggregation pattern (xem fallback bên dưới) |
| Empty state | Không báo lỗi nghiệp vụ khi dữ liệu rỗng |
| Regression | Purchase tab unchanged |
| Observability | Log action name, duration, branch count, qualified customer count, converted_excluded_count |
Performance fallback plan (DEC-020)
Nếu benchmark fail target, KHÔNG thử optimize Go pattern thêm — switch ngay sang SQL aggregation:
sql
-- PostgreSQL function/CTE thực hiện aggregate trong DB, trả pre-grouped
CREATE OR REPLACE FUNCTION report_visit_cycle_no_purchase(
_from_date timestamptz,
_to_date timestamptz,
_branch_ids text[], -- đã intersect ở BE
_duration_day int
)
RETURNS TABLE (
customer_id text,
last_visit_date date,
visit_count int,
avg_cycle_days int,
days_since_last int,
branch_name text
) AS $$
WITH qualified_visits AS (...),
last_qualified_visit AS (...),
converted_customers AS (...),
target_customers AS (
SELECT customer_id FROM last_qualified_visit
EXCEPT
SELECT customer_id FROM converted_customers
),
customer_stats AS (
SELECT
qv.customer_id,
MAX(qv.visit_date) AS last_visit_date,
COUNT(*) AS visit_count,
-- avg gap dùng window
COALESCE(AVG(diff_days)::int, 0) AS avg_cycle_days
FROM (
SELECT
customer_id, visit_date,
EXTRACT(epoch FROM (visit_date - LAG(visit_date) OVER (
PARTITION BY customer_id ORDER BY visit_date
)))/86400 AS diff_days
FROM qualified_visits
WHERE customer_id IN (SELECT customer_id FROM target_customers)
) t
WHERE diff_days IS NOT NULL OR true -- giữ cả khách 1 visit
GROUP BY qv.customer_id
)
SELECT cs.*, lvb.branch_name
FROM customer_stats cs
LEFT JOIN last_visit_branches lvb USING (customer_id);
$$ LANGUAGE sql STABLE;Backend Go sẽ chỉ:
- Call
resolveBranchScope(DEC-016) - Call function trên với scope đã resolve
- Build matrix/bucket từ result đã pre-aggregated (trên ~10-50k row thay vì 500k+)
Memory footprint ↓ ~10-100x; P95 dự kiến < 1s @ 500k rows.
C10) Giám sát vận hành
| Log/Metric | When | Fields |
|---|---|---|
reportVisitCycleNoPurchase.duration_ms | Aggregate action | type_cycle, duration, branch_count, total_customer |
reportVisitCycleNoPurchaseDetail.duration_ms | Detail action | customer_ids_count, result_count |
| Log lỗi | Khi phát sinh exception | action name + sanitized input |
C11) Task breakdown
| ID | Task | Owner | Depends on |
|---|---|---|---|
| BE-000 | Implement resolveBranchScope helper (DEC-016) + unit test | BE | - |
| BE-001 | Add action input/output metadata | BE | - |
| BE-002 | Implement aggregate handler (call resolveBranchScope first) | BE | BE-000, BE-001 |
| BE-003 | Implement detail handler (resolveBranchScope + customer_ids intersect) | BE | BE-000, BE-001 |
| BE-004 | Add action registration and tests | BE | BE-002, BE-003 |
| BE-005 | Normalize visit_source order theo DEC-017 trong detail handler | BE | BE-003 |
| FE-001 | Add tab key + panel + fix tab persistence DEC-015 (whitelist + to mapping) | FE | - |
| FE-002 | Add GraphQL queries + codegen | FE | BE-001 |
| FE-003 | Build report/chart/table component | FE | FE-001, FE-002 |
| FE-004 | Build visit detail popup + utility formatVisitSource (DEC-017) | FE | FE-002 |
| QA-001 | Seed visit/order data (CUST-A → CUST-J) | QA | BE complete |
| QA-002 | Regression purchase tab + persistence whitelist regression | QA | FE complete |
| QA-003 | Test branch scope enforcement: Manager bypass attempt (TC-012), admin/HQ no-branch-restriction case (TC-008 expand) | QA | BE complete |
| QA-004 | Performance benchmark: seed 100k + 500k qualified visits, đo P95 aggregate + detail (TC-016); báo cáo decision pass/fail | QA + BE | BE complete |
| QA-005 | UI verify click guard: cell value=0 không pointer + không click (TC-015) | QA | FE complete |
| FE-005 | Build component VisitNoPurchaseInfoStrip.tsx (FR-007, DEC-028) | FE | FE-001 |
| FE-006 | Build component reusable LabelWithSubText.tsx (DEC-029) | FE | - |
| FE-007 | Integrate <LabelWithSubText> vào 3 cards overview + matrix headers + 2 matrix rows (DEC-029) | FE | FE-003, FE-006 |
| FE-008 | Add q-icon info + tooltip cho 6 popup column headers (DEC-029, DEC-030) | FE | FE-004 |
| FE-009 | Rename matrix column cuối từ Tỷ lệ → % trên tổng (DEC-029 rename T6); regression check tab purchase | FE | FE-003 |
| FE-010 | Add 9 i18n keys mới vào i18n locale files (info strip + 8 sub-text/column copy) | FE | - |
| QA-006 | Verify info strip render + responsive + a11y (TC-020) | QA | FE-005 |
| QA-007 | Verify sub-text rendering tất cả 8 widget (TC-021) | QA | FE-007 |
| QA-008 | Verify column rename + regression tab purchase (TC-022) | QA | FE-009 |
| QA-009 | Verify popup column tooltip coverage (TC-023) | QA | FE-008 |
| QA-010 | Verify 19 tooltip required coverage (TC-024) | QA | FE-007, FE-008 |
C12) Traceability
| FR | Dev sections |
|---|---|
| FR-001 | C1, C6 (DEC-015 fix) |
| FR-002 | C3, C5, C8 (DEC-016 enforcement) |
| FR-003 | C3, C5, C6 (sub-text integration FE-007, DEC-029) |
| FR-004 | C3 (FORMULA-005 bucket builder DEC-018, FORMULA-007 branch resolution), C5, C6 (DEC-019 click guard, sub-text FE-007, column rename T6 FE-009) |
| FR-005 | C5, C6 (popup column tooltip FE-008), C8 (DEC-017 visit_source ordering) |
| FR-006 | C1, C7, C8, C9 (DEC-020 performance gate + SQL fallback), C11 |
| FR-007 | C1 (file mapping), C6 (InfoStrip component FE-005, DEC-028) |