# POS Scan — Contracts

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

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

External HTTP contract exposed by the `pos-scan` unit. The route sits under the admin group (`['web','admin']`, empty admin prefix) and returns **JSON** (unlike the `pos-terminal` HTML page that consumes it). Controller: `App\Http\Controllers\PosController::scan`. 🟢 (`routes/web.php:80`, `PosController.php:761-784`)

---

## GET `/pos/scan` — product lookup 🟢

- **Auth:** required (`admin` middleware); anonymous → `302` to `auth/login` (no JSON body). 🟢 (`routes/web.php:24-28`)
- **Route ordering:** declared before `resource('/pos', …)` so it is not captured by the resource `show` route. 🟢 (`routes/web.php:80-81`)
- **Request (query string):**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `q` | string | ❌ | Barcode or name fragment. Missing → `null`; used verbatim (no trim/escape). 🟢 (`:767`) |

- **Behavior:** exact `products.code` match first (barcode path); if none, `name LIKE %q%` limited to 10 (name path). Soft-deleted products excluded. Read-only, no side effects. 🟢 (`:761-779`)
- **Responses:**
  - `200 OK` — JSON, one of the two shapes below.
  - `302 Found` — `Location: auth/login` when unauthenticated.
- Source: `PosController::scan` (`:761-784`).

### Response shape A — barcode hit (`is_barcode: true`) 🟢 (`:762-767`)

`data` is a **single product object** = `product->toArray()` with an injected `units` array.

```json
{
  "is_barcode": true,
  "data": {
    "id": 42,
    "code": "8938505970012",
    "name": "Sữa tươi 180ml",
    "category_id": 1,
    "brand_id": 3,
    "price": 6000,
    "sale_price": 7000,
    "wholesale_prices": { "wholesale": 6500 },
    "qty": 120,
    "unit": "hộp",
    "reward_point": 0,
    "expiry_date": "2026-12-31 00:00:00",
    "is_expired": false,
    "deleted_at": null,
    "units": [
      { "unit_id": null, "label": "hộp", "conversion_qty": 1,  "is_default": false },
      { "unit_id": 5,    "label": "thùng", "conversion_qty": 48, "is_default": true }
    ]
  }
}
```

> Field set mirrors `products` columns + appended accessors (`is_expired`, JSON-decoded `wholesale_prices`); the example values are illustrative. 🟡 (schema per `data-dictionary.md`)

### Response shape B — name search (`is_barcode: false`) 🟢 (`:774-783`)

`data` is an **array** (0..10) of the same product objects.

```json
{
  "is_barcode": false,
  "data": [
    { "id": 42, "code": "8938505970012", "name": "Sữa tươi 180ml", "units": [ /* … */ ] },
    { "id": 43, "code": "8938505970029", "name": "Sữa tươi 1L",   "units": [ /* … */ ] }
  ]
}
```

No match → `{ "is_barcode": false, "data": [] }`. 🟢 (`:774-783`)

### `units[]` payload (per product, via `buildUnitsPayload`) 🟢 (`:786-809`)

```
units: [
  { unit_id: null, label: <products.unit string>, conversion_qty: 1, is_default: <true iff no ProductUnit default> },
  { unit_id: <int>, label: <Unit.name>, conversion_qty: <float>, is_default: <bool> },
  ...
]
```

- The base unit (`unit_id:null`) is **always first**. 🟢 (`:784-786`)
- Exactly **one** entry is `is_default:true` — a flagged `ProductUnit`, else the base unit. 🟢 (`:788-803`)
- `conversion_qty` is emitted as a float; client divides base price by it for non-base units (see `products-pricing`). 🟢 (`:796`)

---

## Consumed contracts (owned by other units)

`pos-scan` calls nothing external — it is a leaf read endpoint. It is **consumed by**:

| Method | Path | Consumer unit | Call site |
|--------|------|---------------|-----------|
| GET | `/pos/scan?q=` | `pos-terminal` | scanner Enter / autocomplete (`PosController.php:252-267`) |

---

## Cross-cutting contract notes

- **Method surface:** only `GET /pos/scan` exists; there is no write verb on this path. 🟢 (`routes/web.php:80`)
- **Content type:** always `application/json` on success (`response()->json`), or a `3xx` redirect when unauthenticated. 🟢 (`:764,776`)
- **Discriminated union:** clients MUST branch on `is_barcode` — `data` is an object when `true`, an array when `false`. 🟢 (`:765,777`)
- **CSRF:** none needed; `GET` is safe and state-free. 🟢
- **Caching:** results reflect live catalog + soft-delete state; must not be cached in a way that serves deleted/renamed products. 🟢 (`app/Models/Product.php:35-38`)
- **Error signalling:** no structured error body; transport/5xx failures are surfaced by the caller as a `swal` alert ("Lỗi, vui lòng tải lại trang"), and a no-match name search shows an inline autocomplete message. 🟢 (`PosController.php:203-207,262-264`)
- **Wildcard leak:** user-typed `%`/`_` behave as SQL `LIKE` wildcards on the name path (parameter-bound → no injection). 🟡 (`:774`)
