# POS Scan

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

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

## Overview

`pos-scan` is the product-lookup JSON endpoint (`GET /pos/scan?q=`) that powers the cashier terminal's scanner and autocomplete box. Given a scanned barcode or a typed name fragment, it returns the matching product(s) together with the fully-normalized unit payload the client cart engine needs to price and add a line. It is a read-only collaborator of the `pos-terminal` unit — the terminal page injects the URL and calls this endpoint on every scan/keystroke. 🟢 (`routes/web.php:80`, `PosController::scan` `app/Http/Controllers/PosController.php:761-784`)

## Responsibilities

- Resolve a single query string `q` to product data in one of two modes: **exact barcode match** or **name substring search**. 🟢 (`:759-770`)
- Prefer an exact `code` match (barcode path); only if none exists, fall back to a `name LIKE %q%` search capped at 10 rows. 🟢 (`:761,770`)
- Attach a normalized `units` array to every returned product via `buildUnitsPayload`, guaranteeing exactly one `is_default` unit and a synthetic base unit first. 🟢 (`:767,777,786-810`)
- Eager-load the unit conversions (`units.unit`) so the payload needs no further round-trips. 🟢 (`:761,770`)
- Signal which mode produced the result through the `is_barcode` flag so the client can auto-add (barcode) vs. show a picker list (name). 🟢 (`:765,777`)

## Business Rules

- **Barcode-first resolution.** An exact match on `products.code` wins; the name search runs only when the code lookup returns nothing. A barcode that also happens to match a name is never treated as a name search. 🟢 (`:765-774`, `Product::scopeCode` `app/Models/Product.php:79-82`)
- **Exact code, fuzzy name.** `code` uses equality (`where('code',$code)`); `name` uses `LIKE '%q%'` (substring, case-insensitivity governed by the DB collation). 🟢 (`app/Models/Product.php:81`, `PosController.php:774`)
- **Name results are capped at 10.** `->take(10)` bounds the autocomplete list; there is no pagination or "more results" affordance. 🟢 (`:774`)
- **Soft-deleted products are invisible to scan.** `Product` uses `SoftDeletes`, so the default query scope excludes `deleted_at IS NOT NULL` rows; deleted products are additionally renamed `(DELETED) <name>` on delete, so even a restored/leaked row is visually flagged. 🟢 (`app/Models/Product.php:10,35-38`)
- **Every product carries a guaranteed default unit.** `buildUnitsPayload` prepends a synthetic base unit (`unit_id:null`, `label = products.unit` string, `conversion_qty:1`) and, if no `ProductUnit` row is flagged default, promotes the base unit to `is_default = true`. Exactly one unit is default. 🟢 (`:786-809`)
- **The full product record is returned.** The response embeds `product->toArray()` verbatim (including `wholesale_prices`, `sale_price`, `qty`, `is_expired`, etc.) — pricing is not computed server-side here; the client derives per-line price from these fields. 🟢 (`:762,772`; pricing lives in `products-pricing` / `Product::getPriceByCustomerType`)
- **No `q` normalization.** `q` is used as-is; `%` or `_` typed by the user act as SQL `LIKE` wildcards in the name path (parameter-bound, so no injection, but wildcard semantics leak). 🟡 (`:759,770`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | Resolve `q` against `products.code` (exact) and return the product with `is_barcode:true` when matched | Must | Scanning an existing barcode returns `{is_barcode:true, data:{...single product...}}` |
| RF-02 | On no code match, return up to 10 products whose `name` contains `q`, with `is_barcode:false` | Must | Typing a name fragment returns `{is_barcode:false, data:[...≤10 products...]}` |
| RF-03 | Attach a normalized `units` array to each returned product | Must | Every product in `data` has `units[]` with exactly one `is_default:true` and a leading `unit_id:null` base entry |
| RF-04 | Eager-load `units.unit` to avoid N+1 and to expose the human unit `label` | Must | Response unit entries include a `label` string with no extra queries per product |
| RF-05 | Exclude soft-deleted products from both paths | Must | A soft-deleted product's barcode/name yields no match |
| RF-06 | Require an authenticated admin session | Must | Anonymous request is redirected to `auth/login` (no JSON) |
| RF-07 | Cap the name search at 10 rows | Should | A fragment matching 50 products returns at most 10 |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|-----------|
| Performance | Single indexed equality lookup on `code`, then a bounded (`take(10)`) `LIKE` scan; eager-load prevents per-product unit queries | `PosController.php:765,774` | 🟢 |
| Performance | Called on every keystroke/scan from the terminal — must be low-latency (autocomplete UX) | `PosController.php:252-267` (caller) | 🟡 |
| Security | Authentication required via the `['web','admin']` route group; no per-record authorization (any admin sees all products) | `routes/web.php:24-28,80`, `permissions.md` | 🟢 |
| Scalability | `LIKE '%q%'` with a leading wildcard cannot use a B-tree index on `name`; degrades as the catalog grows | `PosController.php:774` | 🟡 |

> Inferred from code. Validate latency-at-scale with operations.

## Acceptance Criteria

```gherkin
Given an existing product with code "8938505970012" and a resolvable unit set
When the cashier scans that barcode (GET /pos/scan?q=8938505970012)
Then the response is 200 with {is_barcode:true, data:{...that product..., units:[...]}}
And exactly one units[] entry has is_default:true

Given several products whose name contains "sua" and none whose code equals "sua"
When the cashier types "sua" (GET /pos/scan?q=sua)
Then the response is 200 with {is_barcode:false, data:[...at most 10 products...]}
And each product carries its normalized units[]

Given a query string that matches no code and no name
When GET /pos/scan?q=zzz-nonexistent
Then the response is 200 with {is_barcode:false, data:[]}

Given an unauthenticated client
When GET /pos/scan?q=anything
Then the request is redirected (302) to auth/login and no product JSON is returned
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| Barcode/name resolution returning product + units (RF-01/02/03) | Must | Core of the POS add-item flow; the terminal is unusable without it |
| `units.unit` eager-load and default-unit guarantee (RF-03/04) | Must | The cart engine relies on exactly-one-default and a `label` per unit |
| Soft-delete exclusion (RF-05) | Must | Selling a deleted product must be impossible |
| Auth requirement (RF-06) | Must | Whole app is behind the admin guard |
| 10-row cap (RF-07) | Should | UX/perf bound; a different cap would still function |
| `q` wildcard/normalization handling | Could | Edge behavior; rarely exercised deliberately |

> Priority inferred from call frequency (every scan) and position in the add-item chain.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/PosController.php:761-784` | `PosController::scan` | 🟢 |
| `app/Http/Controllers/PosController.php:786-810` | `PosController::buildUnitsPayload` | 🟢 |
| `app/Models/Product.php:79-82` | `Product::scopeCode` | 🟢 |
| `app/Models/Product.php:10,35-38` | `Product` SoftDeletes + deleted-rename hook | 🟢 |
| `app/Models/Product.php:73-76` | `Product::units` (hasMany ProductUnit) | 🟢 |
| `routes/web.php:80-81` | Route registration (scan before resource) | 🟢 |
