# Customers Statistics — Technical Design

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

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

## Interface

HTTP endpoint:

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/customers/{customer}/statistic` | `customer` (route id), optional `page` query (annotated-orders pagination) | HTML page (`pages.customer-statis`) | 200, 404, 302 (unauth) |

Route name: `customers.statis`. Registered at `routes/web.php:57`, **before** `resource('/customers', 'CustomerController')` (`routes/web.php:62`) so `/customers/{customer}/statistic` is not shadowed by the resource `show` route. Under the admin group (`['web','admin']`, empty admin prefix). 🟢

Controller symbol:

| Symbol | Signature | Returns | Note |
|--------|-----------|---------|------|
| `CustomerController::statis` | `($id)` | `View` (`pages.customer-statis`) | Read-only; no verbs beyond GET |

View data (compact bindings passed to `pages.customer-statis`): 🟢

| Variable | Type | Source |
|----------|------|--------|
| `item` | `Customer` (+ dynamic `summary`, `orders_count`) | `Customer::findOrFail($id)` |
| `amountTotal` | number | `summary.amount_total ?? 0` |
| `debtTotal` | number | `item.debt_total ?? 0` (LIVE) |
| `pointsTotal` | number | `summary.points_total ?? 0` |
| `attrStatistic` | `Collection` | milk (category id 1) `items` |
| `medicineStatis` | `Collection` | medicine (category id 8) `items` |
| `goodsStatis` | `Collection<stdClass{name,qty_total,amount_total}>` | all other categories, aggregated, sorted by name |
| `orders` | `LengthAwarePaginator<Order>` | annotated orders, 10/page |
| `statisticsUpdatedAt` | timestamp\|null | `summary.updated_at ?? null` |

> The template also reads `item->points` (live current reward-points balance) directly off the customer for the "current points" cell. 🟢 (`customer-statis.blade.php:50`)

## Data source — the precomputed read model

`customer_order_summary` (model `CustomerOrderSummary`, table `customer_order_summary`) is a **one-row-per-customer** denormalised summary: 🟢 (`data-dictionary.md:183-193`)

| Field | Type | Meaning |
|-------|------|---------|
| `customer_id` | integer (PK) | one row per customer |
| `orders_count` | integer | Σ orders |
| `amount_total` | decimal(14,2) | Σ order `total` |
| `points_total` | decimal(14,2) | Σ `earned_point` |
| `categories_statistic` | longText, cast `object` | JSON per-category / per-`attr_weight` aggregates |
| `created_at` / `updated_at` | timestamps | last rebuild time |

`categories_statistic` deserialises (via the `object` cast) to an array of category objects shaped: 🟢 (`Order.php:106-167`)

```jsonc
[
  {
    "category_id": 1,
    "category_name": "Sữa",
    "items": [
      { "attr_weight": "900g", "qty_total": 12, "amount_total": 3600000 },
      { "attr_weight": "400g", "qty_total": 5,  "amount_total":  900000 }
    ]
  }
  // …one object per tracked category
]
```

Only categories in the **tracked set** appear: milk (1), medicine (8), and the consumption ids `[34, 41, 42]`. For the consumption ids the SQL forces `attr_weight = 'ALL'`, collapsing per-weight detail into one bucket per category. 🟢 (`Order.php:77-81,141-161`)

The read model is (re)built by `Order::summaryLogging()` — a single `INSERT … SELECT … ON DUPLICATE KEY UPDATE` that recomputes every customer's row from `orders` + `order_product` + `products` + `categories`. It runs nightly via `Kernel::schedule()` `->daily()` and on demand via `php artisan customer-summary:logging`. This screen **never triggers a rebuild** — it only reads. 🟢 (`Order.php:69-181`, `app/Console/Kernel.php:30-32`, `CustomerOrderSummaryLogging.php:34-38`)

## Main Flow

1. Set header `Thống kê nhanh` ("Quick statistics") and the `Khách hàng → Thống kê nhanh` breadcrumb. 🟢 (`CustomerController.php:217-221`)
2. `item = Customer::findOrFail($id)` → 404 if the id is unknown or soft-deleted. 🟢 (`:223`)
3. `item->summary = CustomerOrderSummary::where('customer_id', item->id)->first()` — may be `null`. 🟢 (`:224`)
4. Compute headline scalars with null-coalescing fallbacks: `item->orders_count = summary->orders_count ?? 0`; `amountTotal = summary->amount_total ?? 0`; `debtTotal = item->debt_total ?? 0` (**live**); `pointsTotal = summary->points_total ?? 0`. 🟢 (`:226-229`)
5. `statisticsUpdatedAt = summary->updated_at ?? null`. 🟢 (`:231`)
6. **`try`**: decode categories. 🟢 (`:233-251`)
   1. `categoriesStatistic = summary->categories_statistic ?? []` (already an array of objects via the `object` cast).
   2. Re-key by `category_id` into `categoryAttrStatistic[category_id] = category`.
   3. `attrStatistic = collect(categoryAttrStatistic[milk_id]->items ?? [])`, then `unset` the milk entry from the map.
   4. `medicineStatis = collect(categoryAttrStatistic[medicine_id]->items ?? [])`, then `unset` the medicine entry.
   5. `goodsStatis` = map every remaining category to `{name: category_name, qty_total: Σ items.qty_total, amount_total: Σ items.amount_total}`, then `sortBy('name')`.
7. **`catch (\Exception)`**: set `attrStatistic`, `goodsStatis`, `medicineStatis` all to empty collections — headline totals still render. 🟢 (`:252-256`)
8. `orders = Order::where('customer_id', item->id)->whereNotNull('notes')->orderBy('id','desc')->paginate(10)` — the annotated-orders panel. 🟢 (`:258`)
9. `return view('pages.customer-statis', compact(...))`. 🟢 (`:260`)

## Alternative / Edge Flows

- **No summary row (`summary === null`):** every `summary->X ?? 0` yields `0`; step 6.1 sets `categoriesStatistic = []`; the loop is a no-op; all three category collections are empty; `statisticsUpdatedAt = null` → template shows "chưa cập nhật" ("not yet updated"). Page returns 200. 🟢
- **Malformed `categories_statistic`:** any exception during decode/aggregation drops into the `catch`, blanking the three tables; headline totals are already computed before the `try`, so they survive. 🟢
- **Category present but empty `items`:** `?? []` guards produce an empty collection for that category; goods aggregation sums to `0`. 🟢
- **Customer with orders but none annotated:** `orders` paginator is empty → the "annotated orders" table renders no rows (the view uses `@forelse`). 🟢 (`customer-statis.blade.php:77-84`)
- **Unauthenticated request:** admin middleware redirects `302 → auth/login` before the action runs. 🟡 (`config/admin.php`, ADR-0009)

## Dependencies

- **`Customer` model** — `findOrFail`, live `points` / `debt_total`. 🟢 (`Customer.php:15-18`)
- **`CustomerOrderSummary` model** — the read model; `categories_statistic` cast to `object`. 🟢 (`CustomerOrderSummary.php:11-15`)
- **`Order` model** — supplies `$category_milk_id` (1), `$category_medicine_id` (8) constants used to split the map; owns `summaryLogging()` that builds the source data. 🟢 (`Order.php:23-25,69-181`)
- **Scheduler / artisan command** — `Kernel::schedule()->daily()` and `customer-summary:logging` populate the read model this screen renders. Upstream dependency; documented in the `artisan-migrate` / batch context, consumed here as data only. 🟢
- **View `pages.customer-statis`** — Blade template with tabs to the sibling customer screens (edit, orders, debt), the annotated-orders inline-edit modal (`PUT /orders/{id}/note` → `orders-note` unit) and the redeem-points modal (`POST /customers/{id}/redeem-points` + `GET /customers/{id}/check-gift` → `customers-loyalty` unit). Those endpoints are **consumed contracts**, owned by other units. 🟢 (`customer-statis.blade.php:180-404`)

## Design Decisions Identified

| Decision | Evidence | Confidence |
|----------|----------|-----------|
| Statistics served from a nightly precomputed read model rather than live aggregation | `Order::summaryLogging` + `->daily()` schedule | 🟢 (ADR-0005) |
| Debt & current points read live off the customer, historical totals off the summary | `CustomerController.php:228`, `customer-statis.blade.php:50-51` | 🟢 |
| Milk (1) & medicine (8) special-cased into dedicated tables; all else aggregated | `Order.php:23-25`, `CustomerController.php:239-251` | 🟢 (ADR-0006) |
| Consumption categories [34,41,42] flattened to a single `'ALL'` weight | `Order.php:27,141-161` | 🟢 |
| Category decode wrapped in try/catch to keep headline totals resilient | `CustomerController.php:233-256` | 🟢 |
| Annotated-orders panel filters `whereNotNull('notes')` (not full history) | `CustomerController.php:258` | 🟢 |

## Internal State

None owned by this unit. It is a pure reader: it holds no session/cart state and mutates nothing. The only persisted state it depends on — `customer_order_summary` — is owned and written exclusively by `Order::summaryLogging()`. The `$item->summary` and `$item->orders_count` assignments are transient in-request decorations on the model, not persisted. 🟢

## Observability

None. The action emits no logs, metrics, or traces. A missing summary row and a stale/failed nightly rebuild are both invisible from this screen — the only in-page cue is the `statisticsUpdatedAt` timestamp (or its "chưa cập nhật" placeholder), which a user must notice manually. 🔴 (`CustomerController.php:215-261`)

## Risks and Gaps

- 🟢 **Corrected 2026-09-23 (was misstated as 🔴, contradicting this file's own Alternative Flows §):** a brand-new customer / a customer never processed by the nightly job **is** visually distinguished — `summary === null` renders `"chưa cập nhật"` instead of a date (see Alternative Flows above; `customer-statis.blade.php:107-111`). Residual, narrower gap: a customer with an *existing* summary row whose next nightly refresh silently fails shows a stale-but-present date rather than a prominent warning. `questions.md#question-8` closed.
- 🟢 **Category ids `1` and `8` are hard-coded** via `Order::$category_milk_id` / `Order::$category_medicine_id`. The medicine id's `placeholder` status is a **known, already-decided limitation**, not a new gap: `openspec/changes/archive/2026-08-27-customer-statis-and-order-filter/design.md` explicitly records it as a Non-Goal deferred to the business. No action needed from this review.
- 🟡 **Staleness window up to ~24 h.** Numbers reflect the last `->daily()` run, not the current moment; acceptable for a "statistics" view but must be communicated (the freshness timestamp is the only signal).
- 🟡 **No per-record authorization** — any admin views any customer's statistics (ADR-0009).
- 🟢 **`amount_total` here is Σ order `total` from the summary; the sibling `customers-purchase-history` recomputes its own live `Σ total`.** Both derive from the same order totals, but one is precomputed and one is live, so a filtered/stale mismatch between the two screens is expected, not a bug — document to avoid confusion.
