# Pos Scan Specification

## Purpose

`pos-scan` is the read-only JSON endpoint (`GET /pos/scan?q=`) that resolves a scanned barcode or typed name fragment into product data for the cashier terminal. It returns the matching product(s) together with a fully-normalized unit conversion payload that the client cart engine uses to price and add a line item.

## Requirements

### Requirement: Barcode-First Resolution

The system SHALL resolve the query parameter `q` against `products.code` using exact equality first. If a product with that code exists, the system SHALL return `{ "is_barcode": true, "data": <single product object> }` and SHALL NOT run a name search. (Implemented in `app/Http/Controllers/PosController.php:765-774`)

#### Scenario: Existing barcode returns single product

- **GIVEN** a non-deleted product exists with `code` equal to `"8938505970012"`
- **WHEN** `GET /pos/scan?q=8938505970012` is requested by an authenticated admin
- **THEN** the response is `200 OK` with `Content-Type: application/json`, `is_barcode` is `true`, and `data` is a single product object (not an array)

#### Scenario: Barcode matching a name substring is not treated as a name search

- **GIVEN** no product has `code` equal to `"abc"` but products exist whose `name` contains `"abc"`
- **WHEN** `GET /pos/scan?q=abc` is requested
- **THEN** the response has `is_barcode` equal to `false` and `data` is an array

---

### Requirement: Name Substring Fallback

The system SHALL perform a name search only when no product matches `q` on `products.code`. The name search SHALL use `LIKE '%q%'` (substring match) and SHALL return `{ "is_barcode": false, "data": <array of 0..10 product objects> }`. (Implemented in `app/Http/Controllers/PosController.php:774-783`)

#### Scenario: Name fragment returns matched products

- **GIVEN** multiple non-deleted products whose `name` contains `"sua"` and no product whose `code` equals `"sua"`
- **WHEN** `GET /pos/scan?q=sua` is requested
- **THEN** the response is `200 OK`, `is_barcode` is `false`, and `data` is a non-empty array of product objects each matching the fragment

#### Scenario: No match returns empty array

- **GIVEN** no non-deleted product has `code` or `name` matching `"zzz-nonexistent"`
- **WHEN** `GET /pos/scan?q=zzz-nonexistent` is requested
- **THEN** the response is `200 OK` with `{ "is_barcode": false, "data": [] }`

---

### Requirement: Name Results Cap

The system SHALL return at most 10 products on the name path. (Implemented in `app/Http/Controllers/PosController.php:774`)

#### Scenario: Fragment matching more than 10 products is truncated

- **GIVEN** 50 non-deleted products whose `name` contains `"milk"` and no product whose `code` equals `"milk"`
- **WHEN** `GET /pos/scan?q=milk` is requested
- **THEN** `data` contains exactly 10 product objects

---

### Requirement: Normalized Units Payload

The system SHALL attach a `units` array to every product object in the response. The array SHALL be produced by the `buildUnitsPayload` logic: the first entry SHALL be a synthetic base unit with `unit_id: null`, `label` equal to the `products.unit` string, and `conversion_qty: 1`; subsequent entries SHALL correspond to `ProductUnit` rows with their `unit_id`, `label` (from the related `Unit.name`), and `conversion_qty` cast to float. Exactly one entry across the full array SHALL have `is_default: true`; if no `ProductUnit` row is flagged as default, the base unit SHALL be promoted. (Implemented in `app/Http/Controllers/PosController.php:786-810`)

#### Scenario: Product with flagged default unit

- **GIVEN** a product with a `ProductUnit` row where `is_default` is `true`
- **WHEN** that product appears in a scan response
- **THEN** `units[0]` has `unit_id: null` and `is_default: false`, the flagged `ProductUnit` entry has `is_default: true`, and no other entry has `is_default: true`

#### Scenario: Product with no ProductUnit rows

- **GIVEN** a product that has no `ProductUnit` rows
- **WHEN** that product appears in a scan response
- **THEN** `units` contains exactly one entry with `unit_id: null` and `is_default: true`

#### Scenario: Base unit always first

- **GIVEN** a product with multiple `ProductUnit` rows
- **WHEN** that product appears in a scan response
- **THEN** `units[0]` has `unit_id: null`

---

### Requirement: Discriminated Union Response Shape

The system SHALL discriminate the two response shapes via `is_barcode`: when `true`, `data` MUST be a single object; when `false`, `data` MUST be an array. Clients SHALL branch on `is_barcode` before reading `data`. (Implemented in `app/Http/Controllers/PosController.php:764,776`)

#### Scenario: Barcode hit produces object data

- **GIVEN** a barcode match is found
- **WHEN** the response is inspected
- **THEN** `is_barcode` is `true` and `data` is a JSON object, not a JSON array

#### Scenario: Name search produces array data

- **GIVEN** no barcode match and one or more name matches exist
- **WHEN** the response is inspected
- **THEN** `is_barcode` is `false` and `data` is a JSON array

---

### Requirement: Soft-Deleted Product Exclusion

The system SHALL exclude soft-deleted products (those with a non-null `deleted_at`) from both the barcode and name search paths. (Implemented in `app/Models/Product.php:10,35-38`)

#### Scenario: Soft-deleted barcode returns no match

- **GIVEN** a product has been soft-deleted and its `code` is `"DEL001"`
- **WHEN** `GET /pos/scan?q=DEL001` is requested
- **THEN** the response is `{ "is_barcode": false, "data": [] }`

#### Scenario: Soft-deleted product absent from name results

- **GIVEN** a product has been soft-deleted and its `name` contains `"milk"`
- **WHEN** `GET /pos/scan?q=milk` is requested
- **THEN** the soft-deleted product does not appear in `data`

---

### Requirement: Authenticated Admin Access

The system SHALL require an authenticated admin session for `GET /pos/scan`. An unauthenticated request SHALL receive a `302` redirect to `auth/login` with no JSON body. (Implemented in `routes/web.php:24-28,80`)

#### Scenario: Unauthenticated request is redirected

- **GIVEN** the client has no authenticated session
- **WHEN** `GET /pos/scan?q=anything` is requested
- **THEN** the response is `302 Found` with `Location` pointing to `auth/login` and no `application/json` body

#### Scenario: Authenticated admin receives product data

- **GIVEN** the client has an active authenticated admin session
- **WHEN** `GET /pos/scan?q=<valid barcode>` is requested
- **THEN** the response is `200 OK` with a JSON body

---

### Requirement: Route Priority Over Resource Route

The system SHALL register `GET /pos/scan` before the `/pos` resource route so that requests to `/pos/scan` are not captured by the resource `show` action. (Implemented in `routes/web.php:80-81`)

#### Scenario: Scan path is not shadowed by resource show

- **GIVEN** both `GET /pos/scan` and the `/pos` resource route are registered
- **WHEN** an authenticated admin requests `GET /pos/scan?q=x`
- **THEN** the `PosController::scan` action handles the request, not `PosController::show`

---

### Requirement: Read-Only Stateless Operation

The system SHALL perform no writes, session mutations, or side effects during a scan request. Each invocation SHALL be independently repeatable with the same observable outcome given the same catalog state. (Implemented in `app/Http/Controllers/PosController.php:761-784`)

#### Scenario: Repeated identical requests are idempotent

- **GIVEN** the product catalog has not changed between two requests
- **WHEN** `GET /pos/scan?q=<same value>` is issued twice in succession
- **THEN** both responses are identical and the catalog state is unchanged
