# POS Scan — Technical Design

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

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

## Interface

HTTP endpoint (JSON), under the admin group (`['web','admin']`, empty admin prefix). Controller: `App\Http\Controllers\PosController::scan`.

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/pos/scan?q=<string>` | `q: string` (query) | `{ is_barcode: bool, data: object \| array }` | 200, 302 (unauth) |

**Input**

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `q` | string | ❌ (missing → `null`) | Barcode or name fragment. Read via `request('q')`; `null`/empty is accepted and simply matches nothing on the code path and everything-ish on the `LIKE` path. 🟢 (`:767`) |

**Output** — two shapes discriminated by `is_barcode`:

- Barcode hit (`is_barcode:true`): `data` is a **single product object** (`product->toArray()` + injected `units`). 🟢 (`:762-767`)
- Name search (`is_barcode:false`): `data` is an **array** of product objects (0..10), each `product->toArray()` + injected `units`. 🟢 (`:770-778`)

Internal helper:

| Symbol | Signature | Returns | Note |
|--------|-----------|---------|------|
| `PosController::scan` | `()` | `Illuminate\Http\JsonResponse` | Reads `request('q')`; no explicit params. 🟢 (`:765`) |
| `PosController::buildUnitsPayload` | `(Product $product)` | `array` of unit dicts | Prepends synthetic base unit, appends each `ProductUnit`, guarantees one default. 🟢 (`:786-810`) |
| `Product::scopeCode` | `($query, $code)` | Builder | `where('code', $code)` — exact match. 🟢 (`app/Models/Product.php:79-82`) |

## Main Flow

1. Read `q` from the request query (`$q = request('q')`). 🟢 (`:767`)
2. **Barcode path:** run `Product::with('units.unit')->code($q)->first()`. If a product is found: 🟢 (`:765`)
   1. `$data = $product->toArray()` (full record, including appended `is_expired` and JSON-decoded `wholesale_prices`). 🟢 (`:766`)
   2. `$data['units'] = buildUnitsPayload($product)`. 🟢 (`:767`)
   3. Return `response()->json(['is_barcode'=>true, 'data'=>$data])`. 🟢 (`:764-767`)
3. **Name path (only if no code match):** run `Product::with('units.unit')->where('name','like',"%$q%")->take(10)->get()`. 🟢 (`:774`)
   1. `map` each product to `$arr = $product->toArray(); $arr['units'] = buildUnitsPayload($product);`. 🟢 (`:771-775`)
   2. Return `response()->json(['is_barcode'=>false, 'data'=>$data])` (`data` may be `[]`). 🟢 (`:776-779`)

### `buildUnitsPayload` sub-flow 🟢 (`:786-809`)

1. Seed the array with the synthetic base unit: `['unit_id'=>null, 'label'=>$product->unit ?: '', 'conversion_qty'=>1, 'is_default'=>false]`. 🟢 (`:784-786`)
2. For each `ProductUnit` in `$product->units`: append `['unit_id'=>$pu->unit_id, 'label'=>$pu->unit ? $pu->unit->name : '', 'conversion_qty'=>(float)$pu->conversion_qty, 'is_default'=>(bool)$pu->is_default]`; track whether any was default. 🟢 (`:788-799`)
3. If no `ProductUnit` was default, set the base unit (`units[0]`) `is_default = true`. 🟢 (`:801-803`)
4. Return the array. 🟢 (`:809`)

## Alternative Flows

- **No match at all:** name path returns an empty collection → `{is_barcode:false, data:[]}`; the client renders the "no matching product" autocomplete message. 🟢 (`:774-783`; caller `PosController.php:207`)
- **Empty / missing `q`:** `code(null)` matches nothing; `LIKE '%%'` matches up to 10 arbitrary (non-deleted) products. Behavior is defined but not intentional UX. 🟡 (`:759,761,770`)
- **Product with no `ProductUnit` rows:** payload is a single base unit, forced `is_default:true`. 🟢 (`:784-803`)
- **Unauthenticated:** the `admin` middleware redirects to `auth/login` before the action runs (302, not a JSON error). 🟢 (`routes/web.php:24-28`)

## Dependencies

- `Product` model — query (`scopeCode`, `name LIKE`), soft-delete scope, `toArray()` serialization including `wholesale_prices`/`is_expired` accessors. 🟢 (`app/Models/Product.php`)
- `ProductUnit` model (via `Product::units` hasMany) and its `unit` relation (`Unit`) — supplies `unit_id`, `conversion_qty`, `is_default`, and the display `label`. 🟢 (`app/Models/Product.php:73-76`, `buildUnitsPayload:789-798`)
- Admin route group / `admin` guard — authentication. 🟢 (`routes/web.php:24-28`)
- **Consumers:** `pos-terminal` client engine (scanner Enter + autocomplete) is the sole caller. 🟢 (`PosController.php:252-267`)

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|-----------|
| Barcode-first, name-fallback resolution in a single endpoint | `PosController.php:765-774` | 🟢 |
| Route declared before `resource('/pos')` so `/pos/scan` isn't shadowed by the resource `show` route | `routes/web.php:80-81` | 🟢 |
| Return the whole product record (client computes price) rather than a trimmed DTO | `PosController.php:766,776` | 🟢 |
| Normalize units server-side (synthetic base + guaranteed default) so the client never special-cases the base unit | `PosController.php:786-809` | 🟢 |
| Eager-load `units.unit` to avoid N+1 across the ≤10 results | `PosController.php:765,774` | 🟢 |

## Internal State

Stateless. Each call is an independent read; no session mutation, no caching, no writes. 🟢 (`:761-784`)

## Observability

No logging, metrics, or tracing in the action. Failures surface only as the framework's default error response; the client maps transport failures to a `swal` alert. 🔴 (no instrumentation at `:761-784`)

## Risks and Gaps

- 🟡 `where('name','like',"%$q%")` uses a leading wildcard — cannot use a `name` index; a large catalog will make autocomplete slow. Consider a prefix index, full-text, or a trigram search on reimplementation.
- 🟡 User-typed `%` / `_` in `q` act as SQL `LIKE` wildcards (parameter-bound, so no SQL injection, but surprising matches). Decide whether to escape them.
- 🔴 No explicit response for a `null`/empty `q` — current behavior (`LIKE '%%'` returning arbitrary rows) is incidental; confirm the intended contract for empty input.
- 🟢 Barcode path returns a single object while name path returns an array — the two `data` shapes are intentional and discriminated by `is_barcode`; any reimplementation must preserve both shapes exactly.
- 🟡 `conversion_qty` is cast to `float` in the payload; downstream client division by it must guard against `0` (server does not filter zero-conversion units here).
