# Customers Purchase History — Requirements

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

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

## Overview

`customers-purchase-history` is the back-office reporting screen (`GET /customers/{customer}/orders`, `CustomerController::orders`) that lists every order a given customer has placed, newest first, with an optional filter by product category and a lifetime spend total. It is a read-only HTML page rendered inside the admin layout; it creates, modifies, and deletes nothing. 🟢 (`routes/web.php:54`, `CustomerController.php:192-213`)

## Responsibilities

- Resolve the target customer by route id via `findOrFail`, loading the customer's total order count (`withCount('orders')`) for header display. 🟢 (`:200`)
- List that customer's orders (`orders.customer_id = {customer}`) ordered by `id` descending, paginated 20 per page. 🟢 (`:204,208`)
- Offer an optional category filter: when a `category` query param is present, restrict the list to orders that contain at least one product in that category (`whereHas('products', category_id = ?)`). 🟢 (`:205-207`)
- Build the category dropdown as `"" => "Tất cả danh mục"` plus every **child** `Category` id→name pair. ✅ Fixed (2026-09-21): now filtered to `whereNotNull('parent_id')`, matching `products-catalog`'s dropdowns. 🟢 (`:202`)
- Compute and pass the customer's **lifetime** order total, `Σ orders.total` across all their orders (not affected by the category filter). 🟢 (`:210`)
- Render `pages.customer-orders` with the customer, the paginated orders, the lifetime total, and the category dropdown. 🟢 (`:212`)

## Business Rules

- **Customer scoped by route id, soft-delete aware.** `Customer::withCount('orders')->findOrFail($id)` returns the customer or aborts `404`; because `Customer` uses SoftDeletes, a soft-deleted customer is treated as not found. 🟢 (`:200`, `Customer.php:9-12`)
- **All statuses are listed.** The order query filters only on `customer_id` — there is **no** `status` filter, so both `draft` and `done` orders appear in the history. 🟢 (`:204`; `orders.status` ∈ {`draft`,`done`}, `data-dictionary.md:217`)
- **Newest first.** Orders are ordered by `id` descending (a proxy for creation order). 🟢 (`:204`)
- **Category filter is an "any line" match.** With `?category=<id>`, `whereHas('products', category_id=<id>)` keeps an order if **at least one** of its line-item products belongs to that category. `whereHas` compiles to an `EXISTS` subquery, so each qualifying order still appears exactly once (no row duplication). 🟢 (`:205-207`)
- **The filter is applied only when `category` is truthy.** `if (request('category'))` — the empty `""` option (and any falsy value) means "all categories", so the unfiltered list is returned. 🟢 (`:205`)
- **Lifetime total is computed but never rendered.** `$amountTotal = Order::where('customer_id',$id)->sum('total')` is a separate, unfiltered query, but `pages/customer-orders.blade.php` never outputs `$amountTotal` — the one `<tfoot>` block that would have shown a total is commented out (`:93-102` in the blade file). So the filter/total "asymmetry" is not user-visible today; the variable is dead weight passed to the view. 🟢 (verified against `resources/views/pages/customer-orders.blade.php`)
- **`total` is the net order amount.** `orders.total = subtotal − discount_amount`; summing it yields net lifetime spend, not gross. 🟢 (`data-dictionary.md:210`)
- **Category dropdown now lists only child categories.** ✅ Fixed (2026-09-21): `Category::whereNotNull('parent_id')->pluck('name','id')` matches `products-catalog`'s child-only dropdowns, since products can only be assigned child categories. Previously listed parent (group) categories too, which always matched no product. 🟢 (`:202`; cross-ref `products-catalog`, `categories`, ADR-0006)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | Resolve the customer by route id or return `404` | Must | `GET /customers/999999/orders` for a nonexistent/soft-deleted id → HTTP `404`. 🟢 |
| RF-02 | List the customer's orders, `id` desc, 20 per page | Must | A customer with 25 orders shows 20 on page 1 and 5 on page 2, newest first. 🟢 |
| RF-03 | Include both `draft` and `done` orders | Must | A customer with one `draft` and one `done` order sees both listed. 🟢 |
| RF-04 | Filter by `?category=<id>` to orders containing ≥1 product in that category | Should | `?category=<child-id>` shows only orders with a line in that category; each such order appears once. 🟢 |
| RF-05 | Provide a category dropdown with an "all categories" option | Should | The dropdown's first option is `"" => "Tất cả danh mục"` followed by every category. 🟢 |
| RF-06 | Show the customer's lifetime `Σ total` | Should | `amountTotal` equals the sum of `total` over all the customer's orders, unaffected by the category filter. 🟢 |
| RF-07 | Require an authenticated admin session | Must | Anonymous request → `302` to `auth/login`. 🟢 (`routes/web.php:23-28`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | Admin authentication required (admin route group middleware `['web','admin']`) | `routes/web.php:23-28,54` | 🟢 |
| Performance | Order list bounded to 20 rows per page via `paginate(20)` | `CustomerController.php:208` | 🟢 |
| Performance | Category filter uses an `EXISTS` subquery over `order_product`/`products`; benefits from indexes on `order_product.product_id` and `products.category_id` | `CustomerController.php:206` | 🟡 |
| Performance | Rendering each order's appended `debt_locked` runs a `debts()->exists()` query per row → up to 20 extra queries per page if the view reads it | `Order.php:14,65-67` | 🟡 |
| Observability | None — the action emits no log, metric, or trace | `CustomerController.php:192-213` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Given an authenticated administrator and an existing customer with orders
When he accesses GET /customers/{id}/orders
Then he receives HTTP 200 with the customer-orders page listing the customer's orders (draft and done) in descending id order, 20 per page, and the lifetime total Σ total

Given an existing customer with orders across several categories
When he accesses GET /customers/{id}/orders?category=<child-category-id>
Then the list shows only orders containing at least one product from that category, each order exactly once, while amountTotal remains the unfiltered lifetime total

Given a non-existent or soft-deleted customer id
When GET /customers/{id}/orders is called
Then it receives HTTP 404

Given a request with no authenticated admin session
When GET /customers/{id}/orders is called
Then it receives HTTP 302 redirecting to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| List a customer's orders paginated, newest first (RF-02) | Must | The core purpose of the screen |
| Resolve/guard the customer (RF-01) | Must | No history without a valid customer; `findOrFail` is the only guard |
| Include all statuses (RF-03) | Must | History is incomplete if drafts are hidden; no status filter exists |
| Category filter (RF-04, RF-05) | Should | Convenience narrowing; the page works fully without a category |
| Lifetime spend total (RF-06) | Should | Informational KPI; not required to browse orders |
| Admin authentication (RF-07) | Must | Enforced by the route group for the whole back office |

> Priority inferred from the action's role as a read-only reporting view and its position off the customers list.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/CustomerController.php:192-213` | `CustomerController::orders` | 🟢 |
| `routes/web.php:54` | `GET /customers/{customer}/orders` route (`customers.orders`, declared before `resource('/customers')`) | 🟢 |
| `app/Models/Customer.php:9-12,34-37` | `Customer` (SoftDeletes, `orders()` hasMany) | 🟢 |
| `app/Models/Order.php:12-14,29-42,49-67` | `Order` (`customer_id`, `total`, `products()` belongsToMany, appended `code`/`is_editable`/`debt_locked`) | 🟢 |
| `app/Models/Category.php` | `Category` (dropdown source via `pluck('name','id')`) | 🟢 |
