# Customers Scan — 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-scan` unit — the single route `GET /customers/scan` (`CustomerController::scan`) under the admin group (`['web','admin']`, empty admin prefix). It is a read-only JSON autocomplete used by the POS terminal's `select2` customer search. It requires an authenticated admin session; an unauthenticated request gets `302 → auth/login`. 🟢 (`routes/web.php:53`, `CustomerController.php:283-294`)

> This route is declared **before** `resource('/customers', 'CustomerController')` (`routes/web.php:62`), so `/customers/scan` is not captured by the resource `show` route (`/customers/{customer}`). The resource CRUD itself belongs to the `customers-crud` unit. 🟢 (`routes/web.php:53-62`)

---

## GET `/customers/scan` — customer autocomplete 🟢 (`:283-294`)

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

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `q` | string | ❌ | Search token. Matched as `phone LIKE %q%` **OR** `fullname LIKE %q%` (both substring). Bound as a parameter (no injection), but `%`/`_` in `q` act as `LIKE` wildcards. A missing/empty `q` becomes `LIKE '%%'` and returns the first 10 live rows. 🟡 (`:285-287`) |

- **Response — `200` JSON array** of up to 10 serialized `Customer` records, ordered by the table's default (no explicit `orderBy`). Empty result → `[]`. 🟢 (`:287-291`)

  Each element (fields from `Customer` `$fillable` + appended attributes):

  ```json
  [
    {
      "id": 42,
      "fullname": "Nguyễn Văn A",
      "phone": "0900000000",
      "email": "a@example.com",
      "address": "…",
      "gender": "male",
      "birthday": "01/02/1990",
      "birthday2": "1990-02-01T00:00:00.000000Z",
      "dependant": "…",
      "points": 120,
      "debt_total": 0,
      "type": "khach_le",
      "type_label": "Khách lẻ",
      "created_at": "…",
      "updated_at": "…",
      "deleted_at": null
    }
  ]
  ```

  - `type` is never null — the accessor defaults it to `khach_le`. `type_label` is the appended human label (`Customer::$types`). 🟢 (`Customer.php:15,25,49-55`)
  - `birthday2` and `deleted_at` serialize as dates (`$dates`). 🟢 (`Customer.php:21-23`)

- **The `204` branch is dead.** The source has `if ($result) json($result) else json(null, 204)`, but `$result` is an Eloquent `Collection` (always truthy, even empty), so the endpoint **always** returns the `200` array and never emits `204`. 🟢 Confirmed harmless by the team — left as-is. (`:289-293`, `_reversa_sdd/flowcharts/customers.md:71`)
- **Status codes:** `200` (always, once the action runs) · `302 → auth/login` (unauthenticated). No `4xx` for a bad/empty `q`; no `204`. 🟢

---

## Consumed contracts (owned by other units)

`customers-scan` reads only its own `customers` table via the `Customer` model. It calls no other unit or external service. 🟢 (`:287`)

| Reads | Owner unit | Purpose |
|-------|------------|---------|
| `customers` (`phone`, `fullname`, `type`, …) | `customers-crud` | the master records this endpoint searches and serializes |

---

## Producer/consumer relationships

| This unit is… | Counterparty | Contract |
|---------------|--------------|----------|
| **Producer** | `pos-terminal` (client `select2`) | `pos-terminal` calls `GET /customers/scan` as the cashier types, then remaps each result's `id` to `phone` as the `select2` value and reads `type` to drive per-tier pricing. 🟢 (cross-ref `pos-terminal`; `Customer.php:58-61`) |

---

## Cross-cutting contract notes

- **Method surface:** a single `GET`; no other verbs on this path. 🟢 (`routes/web.php:53`)
- **Content type:** always `application/json`; the payload is an **array** (contrast `pos-scan`, whose payload is a discriminated union keyed on `is_barcode`). Consumers must branch on array length, not on a wrapper flag. 🟢 (`:289-291`)
- **Result cap:** hard `take(10)`; no pagination, no total count. 🟢 (`:287`)
- **Identity key:** `phone` is the customer business identity used downstream (`select2` value, `scopeCode`), not `id`. 🟢 (`Customer.php:58-61`)
- **Soft-delete:** the SoftDeletes global scope excludes deleted rows from **both** branches — verified via Eloquent's automatic scope-nesting (`callScope`), which isolates the scope's `deleted_at IS NULL` from the hand-written `orWhere`. No leak. 🟢 (`:287`, verified against `vendor/laravel/framework`)
- **CSRF:** not applicable — `GET`, no state change. 🟢
- **No observability:** the endpoint emits no telemetry despite per-keystroke traffic. 🔴 (`:283-294`, absence)
