# Customers Scan — 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/scan` | `q: string` (query string, optional) | `Customer[]` (JSON array, ≤10) | 200 (always on a reached action); 302 → `auth/login` if unauthenticated |

Controller symbol:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `CustomerController::scan` | `()` — reads `request('q')` directly, no `Request` parameter | `\Illuminate\Http\JsonResponse` | Always returns the `json($result)` array; the `json(null, 204)` branch is unreachable. 🟢 (`:283-294`) |

The route is registered at `routes/web.php:53` **before** `resource('/customers', 'CustomerController')` (`:62`), so the literal path `/customers/scan` is matched by this action and is never captured by the resource `show` route (`/customers/{customer}`). 🟢

## Main Flow

1. Read `q` from the request query string: `$q = request('q')` — no validation, no default; a missing `q` yields `null`. 🟢 (`:285`)
2. Build and execute the search in one expression: `Customer::where('phone','like',"%$q%")->orWhere('fullname','like',"%$q%")->take(10)->get()`. The model's SoftDeletes global scope appends `deleted_at IS NULL`. 🟢 (`:287`, `Customer.php:9-12`)
3. `get()` returns an Eloquent `Collection` (`$result`). 🟢 (`:287`)
4. `if ($result)` — a `Collection` is always truthy, so this is always taken; return `response()->json($result)` → `200` with a JSON array of the (≤10) serialized `Customer` records. 🟢 (`:289-291`)
5. (Unreached) `return response()->json(null, 204)` — dead code; never executes because step 4 always returns. 🟢 (`:293`)

## Alternative Flows

- **No match:** `get()` returns an empty `Collection` → `200` with body `[]`. The client must treat an empty array as "no customer found", not as an error. 🟢 (`:287-291`)
- **Missing / empty `q`:** the pattern becomes `LIKE '%%'`, which matches every live row; the endpoint returns the first 10 customers in default order. Whether this is intended (vs. returning `[]` for an empty query) is unspecified. 🔴 (`:285-287`)
- **`q` contains `%` or `_`:** these are interpreted as `LIKE` wildcards, not literals. Bound as a parameter, so there is no SQL-injection risk, but the match semantics widen unexpectedly. 🟡 (`:287`)
- **Soft-deleted customer matching by phone: verified not possible.** Although the two `LIKE`s are not explicitly grouped, Laravel's `Builder::callScope()` isolates the SoftDeletes global scope's `deleted_at IS NULL` into its own nested group (`WHERE (phone LIKE .. OR fullname LIKE ..) AND (deleted_at IS NULL)`), so a deleted customer cannot leak via either branch. 🟢 (`:287`, verified against `vendor/laravel/framework/.../Eloquent/Builder.php:943-962`)
- **Unauthenticated:** the admin route group middleware redirects to `auth/login` before the action runs. 🟢 (`routes/web.php:23-28`)

## Dependencies

- **`App\Models\Customer`** — the queried model. Supplies SoftDeletes (`:9-12`), `$fillable` (`:15`), the appended `type_label` and the `type` accessor defaulting to `khach_le` (`:25,49-55`), and `scopeCode` (phone identity, `:58-61`). The serialized payload includes these appended attributes. 🟢
- **Laravel query builder + SoftDeletingScope** — provides the fluent `where/orWhere/take/get` and the automatic `deleted_at IS NULL` constraint. 🟢
- **Admin route group** (`config('admin.route.*')`, middleware `['web','admin']`) — enforces the authenticated-admin precondition. 🟢 (`routes/web.php:23-28`)

## Design Decisions Identified

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| Autocomplete search over two columns (`phone`, `fullname`) with a fixed cap of 10, no pagination | `CustomerController.php:287` | 🟢 |
| Return the **full** `Customer` record (not a trimmed DTO) so the POS client gets `type`/`type_label` in one call | `:289-291`, `Customer.php:25` | 🟢 |
| Substring `LIKE %q%` for both fields — no exact-match fast path (contrast `pos-scan` product-code exact match) | `:287` | 🟢 |
| `204` no-content path left in place though unreachable (Collection always truthy) | `:289-293` | 🟢 (team-confirmed harmless) |
| `orWhere` left ungrouped (unlike `customers-crud` index) — safe here because the only other constraint (SoftDeletes) is a global scope, auto-nested by Eloquent; would NOT be safe if a hand-written filter (e.g. a future `month`-style param) were added to this method without grouping. | `:287` vs `CustomerController.php:38-42` | 🟢 (verified) |

## Internal State

None. The action is stateless and read-only; it holds no session state and persists nothing. 🟢 (`:283-294`)

## Observability

None. The action emits no logs, metrics, or traces, even though the POS client calls it on every keystroke of the customer search box. Adding at least a debug-level log of `q` length and result count would help diagnose slow lookups at scale. 🔴 (`:283-294`, absence)

## Risks and Gaps

- 🔴 **Empty-`q` behavior is incidental.** A blank/missing `q` returns the first 10 customers rather than `[]`; confirm whether the client ever sends an empty query and what the intended response is.
- 🟢 **Verified: ungrouped `orWhere` does NOT surface soft-deleted customers.** Eloquent's scope-nesting protects both branches (see Main Flow); no fix needed here. Future maintainers should still group any hand-written filter added to this method, since that protection only applies to framework global scopes, not manual `where()` calls.
- 🟡 **Leading-wildcard `LIKE`** on `phone`/`fullname` cannot use an index; per-keystroke calls do a full scan and degrade as the customer base grows.
- 🟡 **User `%`/`_` widen the match.** Bound-parameter safe (no injection) but semantically surprising; escape them if literal matching is desired.
- 🟡 **Identity key assumption.** The POS client keys the selected customer by `phone`; confirm no two live customers can share a phone (the live-only unique index in `customers-crud` supports this). Cross-ref `pos-terminal`.
