# Customers Statistics — Requirements

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

## Overview

`customers-statistics` is the back-office "quick statistics" screen (`GET /customers/{customer}/statistic`, `CustomerController::statis`, route name `customers.statis`) that shows a single customer's lifetime purchasing summary: total orders, total amount spent, points earned, current reward points, current debt, plus per-category consumption breakdowns (milk, general goods, medicine) and a list of that customer's annotated orders. It is a **read-only** HTML page rendered inside the admin layout; it creates, modifies, and deletes nothing. 🟢 (`routes/web.php:57`, `CustomerController.php:215-261`)

Its headline totals and category breakdowns are **not** computed live from the orders on each request — they are read from the denormalised `customer_order_summary` table, which is rebuilt nightly by `Order::summaryLogging()` (a `->daily()` scheduled closure). This screen is therefore a **reader of a precomputed read model**, and its numbers can be up to ~24 h stale. 🟢 (`app/Console/Kernel.php:30-32`, `app/Models/Order.php:69-181`, ADR-0005)

## Responsibilities

- Resolve the target customer by id and 404 if unknown (`Customer::findOrFail`). 🟢
- Load that customer's precomputed summary row (`CustomerOrderSummary::where('customer_id', …)->first()`), tolerating its absence (all headline totals fall back to `0` / empty). 🟢
- Present four headline totals from the summary + live customer record: orders count, total amount spent, total points earned, current reward points balance, current debt balance. 🟢
- Decode the `categories_statistic` JSON and split it into three views: milk (category id `1`), medicine (category id `8`), and all other tracked categories aggregated into a "goods / consumption" table sorted by name. 🟢
- Degrade gracefully to empty category tables if the JSON is missing or malformed (wrapped in `try/catch`). 🟢
- List the customer's **annotated** orders only (`whereNotNull('notes')`), newest first, paginated 10 per page, for inline note review/editing. 🟢
- Surface the "last updated" timestamp of the summary so the user knows how fresh the numbers are. 🟢
- Render `pages.customer-statis` with the header `Thống kê nhanh` ("Quick statistics") and the customer breadcrumb. 🟢

## Business Rules

- **The summary is a precomputed nightly read model, not a live aggregate.** `orders_count`, `amount_total`, `points_total`, and every category breakdown come from `customer_order_summary`, rebuilt by `Order::summaryLogging()` on a `->daily()` schedule (and on demand via the `customer-summary:logging` artisan command). A customer with no summary row (never included in a run) shows all zeros / empty tables. 🟢 (`CustomerController.php:224-234`, `app/Console/Kernel.php:30-32`, `app/Console/Commands/CustomerOrderSummaryLogging.php:34-38`)
- **Debt and current reward points are read LIVE from the customer, not from the summary.** `debtTotal` = `customer.debt_total` and the "current points" cell = `customer.points`; only the *historical* totals (`amount_total`, `points_total`, `orders_count`) come from the summary. So debt/points are always current even when the rest of the page is stale. 🟢 (`CustomerController.php:228`, `customer-statis.blade.php:50-51`)
- **Milk (category id `1`) and medicine (category id `8`) are special-cased.** They are pulled out of the category map into their own tables (`attrStatistic` = milk, `medicineStatis` = medicine); everything left is aggregated into `goodsStatis`. The ids are hard-coded via `Order::$category_milk_id` / `Order::$category_medicine_id`. **The medicine id (`8`) is a known, already-decided placeholder** — documented in `openspec/changes/archive/2026-08-27-customer-statis-and-order-filter/design.md` as an explicit Non-Goal ("Determining the correct production category ID(s) for 'Thuốc' — `8` is used as-is... the business will correct later"), deferred to the business and not a new open question. 🟢 (`Order.php:23-25`, `CustomerController.php:239-243`, ADR-0006)
- **"Consumption" categories `[34, 41, 42]` collapse their `attr_weight` into a single `'ALL'` bucket** during summary generation, so their per-weight detail is intentionally flattened. 🟢 (`Order.php:27`, `Order.php:141-144,158-161`)
- **The goods table aggregates per category:** for each remaining category it sums `qty_total` and `amount_total` across that category's `items`, exposing `name`, `qty_total`, `amount_total`, sorted by category name. 🟢 (`CustomerController.php:245-251`)
- **`points_total` (earned over lifetime) is distinct from `points` (current spendable balance).** The page shows both side by side; redemptions reduce `points` but not `points_total`. 🟢 (`customer-statis.blade.php:49-50`)
- **The orders panel lists only orders that have a note** (`whereNotNull('notes')`), newest-first, 10/page — it is an "annotated orders" review list, not the full order history (that is the `customers-purchase-history` unit). Notes are editable inline via `PUT /orders/{order}/note` (the `orders-note` unit). 🟢 (`CustomerController.php:258`, `customer-statis.blade.php:77-102,180-211`)
- **Any malformed / missing `categories_statistic` degrades silently to empty category tables** — the headline totals still render; only the three breakdown tables go blank. 🟢 (`CustomerController.php:252-256`)
- **No per-record authorization.** Any authenticated admin can view any customer's statistics; access control is authentication-only. 🟡 (ADR-0009, `permissions.md`)
- 🟢 **Corrected 2026-09-23 (was misstated as a 🔴 gap, contradicting this file's own RF-07):** the page **does** signal a missing summary — when `summary === null` (brand-new customer, or one never processed by the nightly job), `statisticsUpdatedAt` is `null` and the template renders `"chưa cập nhật"` ("not yet updated") instead of a date (`customer-statis.blade.php:107-111`). The residual, narrower gap: if a customer *already has* a summary row but a subsequent night's job silently fails to refresh it, the page shows a stale-but-present "last updated" date rather than a prominent staleness warning — distinguishable only if an operator notices how old the date is. `questions.md#question-8` closed.

## Functional Requirements

| ID | Requirement | Priority | Acceptance Criterion |
|----|-------------|----------|----------------------|
| RF-01 | Resolve customer by id; 404 on unknown/soft-deleted | Must | `GET /customers/999999/statistic` for a non-existent id returns HTTP 404 |
| RF-02 | Render headline totals (orders count, amount spent, points earned, current points, current debt) | Must | With a summary row present, the five totals match the summary (historical) + live customer (points/debt) |
| RF-03 | Fall back to zeros/empty when no summary row exists | Must | A customer with no `customer_order_summary` row renders all totals as `0` and empty category tables, HTTP 200 |
| RF-04 | Decode `categories_statistic` and split milk / medicine / other-goods | Must | Milk table shows category-1 items, medicine table shows category-8 items, goods table shows all others aggregated & name-sorted |
| RF-05 | Degrade to empty category tables on malformed JSON | Should | A corrupt `categories_statistic` still returns HTTP 200 with headline totals and empty breakdown tables |
| RF-06 | List annotated orders (`notes` not null), newest first, 10/page | Should | Only orders with a non-null `notes` appear; page size is 10; ordering is `id` desc |
| RF-07 | Display the summary "last updated" timestamp (or "not yet updated") | Should | When `summary.updated_at` is set, it renders formatted `d/m/Y H:i`; otherwise a "chưa cập nhật" placeholder |
| RF-08 | Require an authenticated admin session | Must | An unauthenticated request is redirected `302 → auth/login` |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|-----------|
| Performance | Headline totals & category breakdowns are read from a single precomputed row (O(1) reads), avoiding a live multi-join aggregate on every page load | `CustomerController.php:224`, `Order.php:85-180` | 🟢 |
| Performance | Freshness is bounded by the nightly rebuild cadence (`->daily()`) — numbers can lag reality by up to ~24 h | `app/Console/Kernel.php:30-32` | 🟢 |
| Performance | Annotated-orders list is paginated (10/page), bounding rows per request | `CustomerController.php:258` | 🟢 |
| Security | Route sits under the `['web','admin']` group; authentication required, no per-record authorization | `routes/web.php:57`, `config/admin.php`, ADR-0009 | 🟡 |
| Reliability | Category decoding is wrapped in `try/catch` so a malformed read model cannot 500 the page | `CustomerController.php:233-256` | 🟢 |
| Observability | No logging/metrics on this read path; a failed/absent nightly rebuild is invisible here | `CustomerController.php:215-261` (no logger calls) | 🔴 |

## Acceptance Criteria

```gherkin
Given an existing customer with a row in customer_order_summary
When an authenticated admin accesses GET /customers/{id}/statistic
Then the page returns 200 and shows orders_count, amount_total, and points_total from the summary,
  and the debt_total and points read live from the customer

Given an existing customer with no row in customer_order_summary (never processed by the nightly job)
When an admin accesses GET /customers/{id}/statistic
Then the page returns 200 with all totals at 0 and empty category tables

Given a customer whose categories_statistic is corrupted/unreadable
When the page is rendered
Then the header totals still appear and the three category tables are empty (no 500 error)

Given a customer whose summary contains categories 1 (milk) and 8 (medicine) plus others
When the page is rendered
Then milk appears in the "SỮA" table, medicine in the "THUỐC" table, and the remaining categories aggregated in the "TIÊU DÙNG" table sorted by name

Given a non-existent customer id
When an admin accesses GET /customers/{id}/statistic
Then the response is 404

Given a request with no authenticated session
When GET /customers/{id}/statistic is called
Then it redirects 302 to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| Read summary + render headline totals (RF-01, RF-02) | Must | Core purpose of the screen |
| Graceful fallback when summary absent (RF-03) | Must | Every new customer starts without a summary row; page must not break |
| Category split milk/medicine/goods (RF-04) | Must | Central business breakdown, milk/medicine special-casing is a domain rule (ADR-0006) |
| Authenticated admin access (RF-08) | Must | Route is behind the admin guard |
| Malformed-JSON degradation (RF-05) | Should | Defensive; matters only if the read model is corrupt |
| Annotated-orders panel (RF-06) | Should | Secondary review aid, not the primary metric |
| Freshness timestamp (RF-07) | Should | UX/trust signal; page functions without it |
| Visual "not yet computed" vs "zero" distinction | Should | Already present via the "chưa cập nhật" placeholder (RF-07); confirmed 2026-09-23. |

> Priority inferred from call frequency and dependency position: the summary read + totals are the reason the screen exists; the panels and freshness cues are supporting detail.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `routes/web.php:57` | `customers.statis` route registration (before `resource('/customers')`) | 🟢 |
| `app/Http/Controllers/CustomerController.php:215-261` | `CustomerController::statis` | 🟢 |
| `app/Models/CustomerOrderSummary.php` | read model (`categories_statistic` cast `object`) | 🟢 |
| `app/Models/Order.php:69-181` | `Order::summaryLogging` (builds the read model) | 🟢 |
| `app/Models/Order.php:23-27` | `$category_milk_id` / `$category_medicine_id` / `$category_consumption_stats_ids` | 🟢 |
| `app/Console/Kernel.php:30-32` | nightly `->daily()` schedule of the rebuild | 🟢 |
| `app/Console/Commands/CustomerOrderSummaryLogging.php` | on-demand `customer-summary:logging` command | 🟢 |
| `app/Models/Customer.php:15-18,34-37` | live `points` / `debt_total`, `orders()` relation | 🟢 |
| `resources/views/pages/customer-statis.blade.php` | rendered view | 🟢 |
