# Customers Purchase History — Contracts

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

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

External HTTP contract exposed by the `customers-purchase-history` unit — the single route `GET /customers/{customer}/orders` (`CustomerController::orders`, route name `customers.orders`) under the admin group (`['web','admin']`, empty admin prefix). It is a read-only **HTML** reporting page (not a JSON endpoint) reached from the customers back office; it requires an authenticated admin session; an unauthenticated request gets `302 → auth/login`. 🟢 (`routes/web.php:54`, `CustomerController.php:192-213`)

> This route is declared **before** `resource('/customers', 'CustomerController')` (`routes/web.php:62`), so `/customers/{customer}/orders` is not captured by the resource routes. The resource CRUD itself belongs to the `customers-crud` unit; the sibling `/customers/{customer}/*` screens (`statistic`, `debt`, `check-gift`, …) belong to `customers-statistics`, `customers-debt-actions`, and `customers-loyalty`. 🟢 (`routes/web.php:53-62`)

---

## GET `/customers/{customer}/orders` — per-customer order history 🟢 (`:192-213`)

- **Auth:** required; anonymous → `302 auth/login`. 🟢 (`routes/web.php:23-28`)
- **Route parameter:**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `customer` | integer | ✅ | Customer id. Resolved by `Customer::withCount('orders')->findOrFail` → `404` if unknown or soft-deleted. 🟢 (`:200`) |

- **Request (query string):**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `category` | integer | ❌ | Category id. When truthy, filters to orders containing ≥1 product in that category (`whereHas('products', category_id=?)`, an `EXISTS` subquery). Empty/absent = all categories. Bound as a parameter (no injection). ✅ Fixed (2026-09-21): the dropdown now only offers child categories, so a dead-end parent-category selection is no longer reachable through the UI. 🟢 (`:205-207`) |
  | `page` | integer | ❌ | Standard Laravel paginator page (`paginate(20)`). Out-of-range pages render an empty list. 🟢 (`:208`) |

- **Response — `200 text/html`** rendering `pages.customer-orders` with:

  | View variable | Type | Meaning |
  |---------------|------|---------|
  | `item` | `Customer` (with `orders_count`) | The resolved customer; `orders_count` is the **total** (unfiltered) order count. 🟢 (`:200`) |
  | `orders` | `LengthAwarePaginator<Order>` | The customer's orders, `id` desc, 20/page, optionally category-filtered. Each `Order` carries appended `code` (`#QT78-{id}`), `is_editable`, `debt_locked`. 🟢 (`:204-208`, `Order.php:14`) |
  | `amountTotal` | numeric \| null | `Σ orders.total` over **all** the customer's orders — **not** affected by the `category` filter. Passed to the view but **never rendered** by `pages/customer-orders.blade.php` (its total `<tfoot>` is commented out) — currently dead weight. 🟢 (`:210`, verified against the blade view) |
  | `categories` | `array<string,string>` | `['' => 'Tất cả danh mục'] + Category::whereNotNull('parent_id')->pluck('name','id')` — child categories only. ✅ Fixed (2026-09-21), previously included parents. 🟢 (`:202`) |

- **Status codes:** `200` (page rendered) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). No JSON variant; this route is not content-negotiated (contrast `customers-crud` `store`). 🟢 (`:192-213`)
- **Order set:** includes **both** `draft` and `done` orders (no status filter); order deletes are permanent (`Order` has no SoftDeletes), so removed orders never appear. 🟢 (`:204`; `data-dictionary.md:203`)

---

## Consumed contracts (owned by other units)

`customers-purchase-history` reads the `customers`, `orders`, `order_product`/`products`, and `categories` tables through their models. It calls no other unit's HTTP endpoint and no external service. 🟢 (`:200-210`)

| Reads | Owner unit | Purpose |
|-------|------------|---------|
| `customers` (via `Customer::withCount('orders')->findOrFail`) | `customers-crud` | resolve the target customer + total order count |
| `orders` (`customer_id`, `total`, appended `code`/`is_editable`/`debt_locked`) | `orders-crud` | the history rows and the lifetime total |
| `order_product` + `products.category_id` (via `Order::products()` `whereHas`) | `orders-crud` / `products-catalog` | the `EXISTS` subquery backing the category filter |
| `categories` (`pluck('name','id')`) | `categories` | the filter dropdown options |

---

## Producer/consumer relationships

| This unit is… | Counterparty | Contract |
|---------------|--------------|----------|
| **Consumer** | `customers-crud` | reached from the customers list/detail; depends on `Customer` + `orders()` relation. 🟢 |
| **Consumer** | `orders-crud` | renders `Order` rows and sums `orders.total`; relies on the `Order` schema and appended attributes. 🟢 |
| **Consumer** | `categories` | populates the category filter from the full `Category` table. 🟢 |

---

## Cross-cutting contract notes

- **Method surface:** a single `GET`; no other verbs on this path. 🟢 (`routes/web.php:54`)
- **Content type:** `text/html` (server-rendered Blade), not JSON — unlike the sibling `customers-scan` autocomplete. 🟢 (`:212`)
- **Pagination:** `paginate(20)`; the view is expected to render standard Laravel paginator links (`?page=`). 🟢 (`:208`)
- **Filter semantics:** the `category` filter is order-level ("order has any line in category"), not line-level — it changes *which orders* appear, not which line items within them. 🟢 (`:206`)
- **Total/filter asymmetry is not user-visible:** `amountTotal` is lifetime and ignores `category`, but the view never renders it — no reader ever sees a mismatched total. 🟢 (`:208-210`, verified against the blade view)
- **CSRF:** not applicable — `GET`, no state change. 🟢
- **Authorization:** authentication only; any admin may view any customer's history (no per-record scope). 🟡 (ADR-0009)
- **No observability:** the page emits no telemetry. 🔴 (`:192-213`, absence)
