# Customers Purchase History — 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}/orders` | `customer: int` (route), `category: int` (query, optional), `page: int` (query, optional) | HTML page `pages.customer-orders` | 200; 404 (unknown/soft-deleted customer); 302 → `auth/login` if unauthenticated |

Controller symbol:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `CustomerController::orders` | `orders($id)` — reads `request('category')` directly | `\Illuminate\View\View` (via `$this->view(...)`) | Read-only; renders the Blade view with `item`, `amountTotal`, `orders`, `categories`. 🟢 (`:192-213`) |

The route is registered at `routes/web.php:54` (`->name('customers.orders')`) **before** `resource('/customers', 'CustomerController')` (`:62`), so `/customers/{customer}/orders` is matched by this action and never shadowed by the resource routes. 🟢

## Main Flow

1. Set the page header `__('Đơn hàng đã mua')` ("Orders purchased") and a two-level breadcrumb (`Khách hàng` → header). 🟢 (`:194-198`)
2. Resolve the customer: `$item = Customer::withCount('orders')->findOrFail($id)`. `withCount('orders')` adds an `orders_count` attribute (total, unfiltered); `findOrFail` aborts `404` if the id is unknown or soft-deleted. 🟢 (`:200`)
3. Build the category dropdown: `$categories = ['' => 'Tất cả danh mục'] + Category::whereNotNull('parent_id')->pluck('name','id')->toArray()` — an "all categories" sentinel prepended to every **child** category id→name pair. ✅ Fixed (2026-09-21), was `Category::pluck(...)` with no `parent_id` filter. 🟢 (`:202`)
4. Start the order query: `$ordersQuery = Order::where('customer_id', $item->id)->orderBy('id', 'desc')` — all of the customer's orders, newest first, no status filter. 🟢 (`:204`)
5. If `request('category')` is truthy, narrow it: `$ordersQuery->whereHas('products', fn ($q) => $q->where('category_id', request('category')))` — keep orders having ≥1 product in that category (`EXISTS` subquery, no duplicate rows). 🟢 (`:205-207`)
6. Paginate: `$orders = $ordersQuery->paginate(20)`. 🟢 (`:208`)
7. Compute the lifetime total with a **separate** query: `$amountTotal = Order::where('customer_id', $id)->sum('total')` — no category constraint. 🟢 (`:210`)
8. Render `return $this->view('pages.customer-orders', compact('item', 'amountTotal', 'orders', 'categories'))`. 🟢 (`:212`)

## Alternative Flows

- **Unknown / soft-deleted customer:** `findOrFail` throws `ModelNotFoundException` → framework `404`. 🟢 (`:200`)
- **No `category` param (or empty `""`):** the `if` is skipped; the full order list is returned. 🟢 (`:205`)
- **`category` set to a parent-category id:** ✅ Fixed (2026-09-21) — the dropdown no longer offers parent categories, so this dead-end filter path is no longer reachable through the UI. 🟢 (`:202,206`; cross-ref `products-catalog`)
- **`category` set with a filtered list:** the visible orders are the filtered subset; `amountTotal` still reflects the customer's **lifetime** spend, but this cannot be noticed today because the view never renders `amountTotal` at all. 🟢 (`:208-210`, verified against `resources/views/pages/customer-orders.blade.php`)
- **Customer with zero orders:** `paginate(20)` yields an empty paginator; `amountTotal` is `null`/`0` (`sum` over no rows). 🟢 (`:208-210`)
- **Unauthenticated:** the admin route group middleware redirects to `auth/login` before the action runs. 🟢 (`routes/web.php:23-28`)

## Dependencies

- **`App\Models\Customer`** — resolved by route id via `withCount('orders')->findOrFail`; SoftDeletes makes deleted customers `404`; `orders()` `hasMany` backs the count. 🟢 (`Customer.php:9-12,34-37`)
- **`App\Models\Order`** — the queried model. Supplies `customer_id`, `total`, the `products()` `belongsToMany` used by the category filter, and the appended `code`/`is_editable`/`debt_locked` attributes surfaced when the view renders each row. **No SoftDeletes** — deletes are permanent, so history never shows removed orders. 🟢 (`Order.php:12-14,29-42,49-67`; `data-dictionary.md:203`)
- **`App\Models\Category`** — supplies the dropdown via `whereNotNull('parent_id')->pluck('name','id')` (**child categories only**, not parents) — ⚠ Reviewer 2026-09-22: corrected from a stale "all categories, parents included" claim that contradicted the code and requirements.md:17/30. Because products are only ever assigned child `category_id`s, the child-only dropdown matches the data and the category filter works as intended. 🟢 (`:202`)
- **`order_product` pivot + `products`** — the `whereHas('products')` `EXISTS` subquery joins through the pivot to `products.category_id`. 🟢 (`Order.php:39-41`, `:206`)
- **Admin route group** (`config('admin.route.*')`, middleware `['web','admin']`) — enforces the authenticated-admin precondition and the shared admin layout used by `$this->view`. 🟢 (`routes/web.php:23-28`)

## Design Decisions Identified

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| Per-customer order history is a dedicated named route off the resource, not part of the `customers` resource CRUD | `routes/web.php:54,62` | 🟢 |
| List **all** statuses (no `draft`/`done` filter) — the screen is a full history, not a "completed sales" report | `CustomerController.php:204` | 🟢 |
| Category filter is an "any-line" `whereHas` `EXISTS` (order-level), not a line-level filter — it does not restrict which lines show, only which orders | `:205-207` | 🟢 |
| Lifetime total is a second unfiltered `sum('total')` query rather than deriving from the filtered/paginated set | `:210` | 🟢 |
| Category dropdown is child-only, matching the product forms (fixed 2026-09-21; previously included parents) | `:202` vs `products-catalog` | 🟢 |

## Internal State

None. The action is stateless and read-only: it resolves a customer, runs two read queries (paginated list + total), and renders a view. It persists nothing and holds no session state beyond the ambient authenticated admin. 🟢 (`:192-213`)

## Observability

None. The action emits no logs, metrics, or traces. The category filter and lifetime total run additional queries per request with no timing or count instrumentation. 🔴 (`:192-213`, absence)

## Risks and Gaps

- 🟢 **`amountTotal` is dead weight — never rendered.** Verified: `pages/customer-orders.blade.php` never outputs `$amountTotal`; the one `<tfoot>` total row is commented out. The lifetime-vs-filter question is moot today (nothing to confuse a reader with) — worth a product call on whether to wire it up (e.g. un-comment the footer) when reimplementing, but not a live bug.
- ✅ **Fixed (2026-09-21): parent categories no longer in the dropdown.** `Category::whereNotNull('parent_id')` limits it to child categories, matching the product forms — the dead-end filter is no longer reachable.
- 🟡 **Per-row `debt_locked` N+1.** Each rendered order's appended `debt_locked` runs `debts()->where('type','pos_debt')->exists()` — up to 20 extra queries per page if the view reads it. Eager-loading or a computed flag would remove them. (`Order.php:65-67`)
- 🟡 **No per-record authorization.** Any authenticated admin can view any customer's full purchase history; there is no ownership/scope check (consistent with the system-wide authentication-only model, ADR-0009).
- 🔴 **No observability** on a report that runs a `whereHas` `EXISTS` and an aggregate per request.
- 🟢 **Order deletes are permanent** (no SoftDeletes on `Order`), so the history reflects only surviving rows; there is no "deleted order" state to consider. (`data-dictionary.md:203`)
