# Customers Scan Design

## Data Model

The endpoint queries a single model with no joins.

**`Customer`** (`app/Models/Customer.php`)

| Aspect | Detail | Source |
|--------|--------|--------|
| Soft deletes | `SoftDeletes` trait; global scope appends `deleted_at IS NULL` to every query | `:9-12` |
| Fillable columns searched | `phone`, `fullname` | `:15`, `:287` |
| Appended attributes | `type_label` (human label from `Customer::$types`) | `:25` |
| `type` accessor | Returns stored value or defaults to `"khach_le"` when null | `:49-55` |
| Date columns | `birthday2`, `deleted_at` declared in `$dates` → serialized as ISO timestamps | `:21-23` |
| Phone identity scope | `scopeCode` matches on `phone`; downstream consumers (POS `select2`) key the selected customer by `phone`, not `id` | `:58-61` |

No other table or model is read. No write path exists.

## Internal Flows

### Main flow

```
Request: GET /customers/scan?q=<token>
         │
         ▼
Admin middleware ──(unauthenticated)──► 302 → auth/login
         │ (authenticated)
         ▼
$q = request('q')          // null if param absent; no default, no validation
         │
         ▼
Customer::where('phone','like',"%$q%")
        ->orWhere('fullname','like',"%$q%")
        ->take(10)
        ->get()
         │
         │  Eloquent compiles to:
         │  WHERE (phone LIKE %q% OR fullname LIKE %q%)
         │    AND (deleted_at IS NULL)          ← auto-nested by callScope
         ▼
$result = Collection (always truthy, even if empty)
         │
if ($result) ──── always true ────► return response()->json($result)  HTTP 200
         │
         └─ (dead branch) return response()->json(null, 204)  // never reached
```

### Alternative flows

| Condition | Behavior |
|-----------|----------|
| `q` absent or empty | Pattern becomes `LIKE '%%'`; returns first 10 live rows in default table order — incidental behavior, no product decision recorded |
| No rows match | `get()` returns empty `Collection`; response is `HTTP 200` with body `[]` — never `204` |
| `q` contains `%` or `_` | Bound as a parameter (no SQL injection), but these chars act as `LIKE` wildcards, widening the match unexpectedly |
| Soft-deleted customer matches | Excluded from both the `phone` and `fullname` branches; Eloquent's `Builder::callScope` (`vendor/laravel/framework/.../Eloquent/Builder.php:943-962`) nests the global scope's condition separately from hand-written `where`/`orWhere` calls |

## Technical Decisions

| Decision | Rationale in code | Location |
|----------|-------------------|----------|
| Route declared before `resource('/customers')` | Prevents `/customers/scan` from being captured by the resource `show` pattern `/customers/{customer}` | `routes/web.php:53,62` |
| Substring `LIKE %q%` on both columns; no exact-match fast path | Broadest recall for cashier autocomplete; no tiered strategy (unlike `pos-scan`) | `CustomerController.php:287` |
| Hard cap `take(10)`; no pagination, no total count | Sufficient for a `select2` dropdown; keeps payload small | `:287` |
| Return the full `Customer` record, not a trimmed DTO | Gives the POS client `type`/`type_label` for pricing-tier binding in one call, avoiding a second round-trip | `:289-291`, `Customer.php:25` |
| `orWhere` left ungrouped | Safe here because the only additional constraint (SoftDeletes) is a framework global scope, auto-isolated by `callScope`; this protection does **not** extend to any future hand-written filter added to the same method | `:287` vs `CustomerController.php:38-42` |
| Dead `204` branch left in place | Team confirmed harmless; `Collection` is always truthy so the branch never fires; kept as-is rather than removed | `:289-293` |

## Notes

- **Index gap.** Leading-wildcard `LIKE %q%` on `phone` and `fullname` cannot use a B-tree index; every keystroke triggers a full scan that degrades linearly as the `customers` table grows.
- **Future `orWhere` safety.** The ungrouped `orWhere` is only safe because SoftDeletes is a global scope. Any future hand-written filter (e.g. a `type` param) added to this method without a grouping closure would break the SQL grouping and risk leaking soft-deleted rows — unlike the framework-scope path, that scenario is **not** protected by `callScope`.
- **No observability.** The action emits no logs, metrics, or traces despite being invoked on every keystroke from the POS `select2`. At minimum, a debug-level log of `q` length and result count would aid diagnosis without exposing full customer PII.
- **Phone as identity key.** The POS `select2` remaps each result's `id` to `phone` as the form value. Any schema change that allows two live customers to share a phone number would silently break the downstream selection flow.
