Skip to content

Đặ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 đổiSectionẢnh hưởng
Thêm 3 component FE (InfoStrip, LabelWithSubText, 9 i18n keys) vào C1C1) Phạm viFE
Thêm 3 capability vào C2 (info strip, sub-text pattern, popup tooltip)C2) Impact và reuseFE
Thêm 5 FE implementation notes (info strip, label sub-text, popup tooltip, column rename T6)C6) Frontend notesFE
Thêm 10 task: FE-005..010 + QA-006..010C11) Task breakdownFE, QA
Update C12 traceability + thêm FR-007 rowC12) TraceabilityAll

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

LayerFile/AreaChange
FEdiva-admin/src/modules/report/types.tsThêm CUSTOMER_CYCLE_VISIT_NO_PURCHASE
FECustomerCycleReport.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)
FEcomponents/customer-cycle/*Thêm report/chart/table/popup visit-no-purchase từ shell hiện có
FEgraphql/report_customer_cycle.graphqlThêm queries cho 2 action mới
BEservices/ecommerce-api/actionThêm aggregate/detail action handlers; helper resolveBranchScope (DEC-016)
BEaction/action.goRegister action handlers
Metadataactions.graphql, actions.yamlThêm action input/output
DBmigrations nếu cần metadata/report seedKhông tạo bảng dữ liệu mới
FEdiva-admin/src/modules/report/components/customer-cycle/VisitNoPurchaseInfoStrip.tsxNEW v2.8 — static info strip (FR-007/DEC-028, no props/state)
FEdiva-admin/src/modules/common/LabelWithSubText.tsxNEW v2.8 — reusable component cho overview cards + matrix headers/rows (DEC-029)
FEi18n locale filesNEW 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

CapabilityExistingDeltaRisk
Tab shellCustomerCycleReport.tsxAdd tab/panel/persistenceLow
FilterCustomerCyclePurchaseFilterReport.tsxReuse visible controlsLow
Aggregate algorithmreport_purchase_cycle.goReuse bucket/month pattern, source visit, thêm exclude converted (DEC-014)Med
Detail popupCustomerCycleDetailPopup.tsxReuse shell only, replace columns/outputHigh
No revenue ruleall_customer_visits.is_zero_orderReuseLow
Order valid rulereport_purchase_cycle.go:241-243Reuse cùng định nghĩa cho exclude convertedLow
PermissionHasura actions role userAdd actions; enforce branch scopeMed
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 columnsExisting q-tooltip pattern trên matrix labelsExtend 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_date lưu kiểu DATE → so sánh với order.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ày last_visit_date không loại khách (vì cùng ngày coi như chưa quan sát).
  • target_customers là 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ận customer_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_customers by customer_id; count >= 2return_visit_customer, count = 1non_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_visits sẽ 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ông NaN.

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

  • Ref: PRD A10 FORMULA-003
  • Implementation: daysSinceLastVisit = normalizeEndDate(to_date) - max(visit_date) với customer_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 -> row KH chưa quay lại ghé; average 0 -> row Cù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 = duration cho by_day, duration * 30 cho by_month) — KHÔNG reuse bucket builder của report_purchase_cycle.go vì 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: order by customer_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ánh cho latest qualified visit.
  • Critical (C-03 fix): qualified_visits ở detail action phải filter theo effective_branches từ 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_date chỉ MAX, các ngày trước không tính branch.
  • Implementation note: Nếu Go aggregate, làm sau khi đã có target_customers: query qualified_visits filtered theo target_customers, group by customer_id, lấy max date, rồi resolve branch.

C4) Data model

ObjectTypeFields used
all_customer_visitsExisting tablecustomer_id, visit_date, branch_id, visit_source, is_zero_order, customer_status
ecommerce_userExisting tableid, display_name, phone_number, avatar_url
branchExisting tableid, name
orderExisting tablecustomer_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:
    1. Call resolveBranchScope(ctx, input.BranchIDs)effective_branches (DEC-016 revision)
    2. Filter qualified_visits theo customer_id IN customer_ids AND branch_id IN effective_branches AND time range
    3. Resolve last_visit_date, branch_name, count_visit_no_order chỉ trong scope effective_branches
  • KHÔNG re-compute exclude-converted ở detail (trust customer_ids từ aggregate).
  • Verification rule: với same filter, count củ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 = normalized

FE 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

ItemNote
Tab keyAdd 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)
ComponentsPrefer new components named CustomerCycleVisitNoPurchaseReport, ...Table, ...ViewChart, ...DetailPopup
FilterReuse CustomerCyclePurchaseFilterReportType/getInitValue if type names do not confuse generated code; otherwise extract shared filter type
GraphQLAdd queries in report_customer_cycle.graphql; run codegen if repo workflow requires
CopyUse UI B6 dictionary; no purchase wording in new tab
PopupBỏ 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.fullcustomer_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 rowLabelcolumnLabel 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

StepRequired
Add action definitionsYes
Add action typesYes
Add handler registrationYes
DB table migrationNo
Index migrationOptional if performance test shows scan on all_customer_visits slow; existing branch/source indexes already present
Report seedNo 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 typeuser.branches DBrequested (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 filter customer_ids ∩ scoped_customers.
  • New actions use Hasura role user like existing report actions; KHÔNG dùng role admin để 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

ThreatMitigation
Manager Q1 gửi branch_ids=[Q3] qua DevToolsresolveBranchScope intersect → empty
FE bug gửi sai branchCù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 swapAction 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

NFRTarget
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 GABenchmark 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 stateKhông báo lỗi nghiệp vụ khi dữ liệu rỗng
RegressionPurchase tab unchanged
ObservabilityLog 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ỉ:

  1. Call resolveBranchScope (DEC-016)
  2. Call function trên với scope đã resolve
  3. 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/MetricWhenFields
reportVisitCycleNoPurchase.duration_msAggregate actiontype_cycle, duration, branch_count, total_customer
reportVisitCycleNoPurchaseDetail.duration_msDetail actioncustomer_ids_count, result_count
Log lỗiKhi phát sinh exceptionaction name + sanitized input

C11) Task breakdown

IDTaskOwnerDepends on
BE-000Implement resolveBranchScope helper (DEC-016) + unit testBE-
BE-001Add action input/output metadataBE-
BE-002Implement aggregate handler (call resolveBranchScope first)BEBE-000, BE-001
BE-003Implement detail handler (resolveBranchScope + customer_ids intersect)BEBE-000, BE-001
BE-004Add action registration and testsBEBE-002, BE-003
BE-005Normalize visit_source order theo DEC-017 trong detail handlerBEBE-003
FE-001Add tab key + panel + fix tab persistence DEC-015 (whitelist + to mapping)FE-
FE-002Add GraphQL queries + codegenFEBE-001
FE-003Build report/chart/table componentFEFE-001, FE-002
FE-004Build visit detail popup + utility formatVisitSource (DEC-017)FEFE-002
QA-001Seed visit/order data (CUST-A → CUST-J)QABE complete
QA-002Regression purchase tab + persistence whitelist regressionQAFE complete
QA-003Test branch scope enforcement: Manager bypass attempt (TC-012), admin/HQ no-branch-restriction case (TC-008 expand)QABE complete
QA-004Performance benchmark: seed 100k + 500k qualified visits, đo P95 aggregate + detail (TC-016); báo cáo decision pass/failQA + BEBE complete
QA-005UI verify click guard: cell value=0 không pointer + không click (TC-015)QAFE complete
FE-005Build component VisitNoPurchaseInfoStrip.tsx (FR-007, DEC-028)FEFE-001
FE-006Build component reusable LabelWithSubText.tsx (DEC-029)FE-
FE-007Integrate <LabelWithSubText> vào 3 cards overview + matrix headers + 2 matrix rows (DEC-029)FEFE-003, FE-006
FE-008Add q-icon info + tooltip cho 6 popup column headers (DEC-029, DEC-030)FEFE-004
FE-009Rename matrix column cuối từ Tỷ lệ% trên tổng (DEC-029 rename T6); regression check tab purchaseFEFE-003
FE-010Add 9 i18n keys mới vào i18n locale files (info strip + 8 sub-text/column copy)FE-
QA-006Verify info strip render + responsive + a11y (TC-020)QAFE-005
QA-007Verify sub-text rendering tất cả 8 widget (TC-021)QAFE-007
QA-008Verify column rename + regression tab purchase (TC-022)QAFE-009
QA-009Verify popup column tooltip coverage (TC-023)QAFE-008
QA-010Verify 19 tooltip required coverage (TC-024)QAFE-007, FE-008

C12) Traceability

FRDev sections
FR-001C1, C6 (DEC-015 fix)
FR-002C3, C5, C8 (DEC-016 enforcement)
FR-003C3, C5, C6 (sub-text integration FE-007, DEC-029)
FR-004C3 (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-005C5, C6 (popup column tooltip FE-008), C8 (DEC-017 visit_source ordering)
FR-006C1, C7, C8, C9 (DEC-020 performance gate + SQL fallback), C11
FR-007C1 (file mapping), C6 (InfoStrip component FE-005, DEC-028)