# POS Scan — Design

## Data Model

### Entities involved

**`products`** (read-only from this unit)

| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `code` | string | Barcode; unique/indexed; exact-match target |
| `name` | string | Display name; `LIKE` search target |
| `unit` | string | Base unit label (e.g. `"hộp"`); denormalized string, not a FK |
| `price` | numeric | Base price; returned verbatim |
| `sale_price` | numeric | Sale price; returned verbatim |
| `wholesale_prices` | JSON | Decoded by accessor before serialization |
| `qty` | numeric | Stock quantity |
| `reward_point` | int | |
| `expiry_date` | datetime | |
| `is_expired` | bool | Appended accessor, not a column |
| `deleted_at` | datetime\|null | Soft-delete sentinel; `NULL` = active |

**`product_units`** (`ProductUnit` model — `products` hasMany)

| Column | Type | Notes |
|--------|------|-------|
| `unit_id` | int FK → `units` | The referenced unit |
| `conversion_qty` | float | How many base units equal one of this unit |
| `is_default` | bool | At most one row per product should be `true` |

**`units`** (`Unit` model — `ProductUnit` belongsTo)

| Column | Type | Notes |
|--------|------|-------|
| `id` | int | PK |
| `name` | string | Human label (e.g. `"thùng"`) |

### Relationship diagram

```mermaid
erDiagram
    products {
        int id PK
        string code
        string name
        string unit
        numeric price
        numeric sale_price
        json wholesale_prices
        numeric qty
        datetime expiry_date
        datetime deleted_at
    }
    product_units {
        int unit_id FK
        float conversion_qty
        bool is_default
    }
    units {
        int id PK
        string name
    }

    products ||--o{ product_units : "units (hasMany)"
    product_units }o--|| units : "unit (belongsTo)"
```

## Internal Flows

### Main scan flow

```mermaid
flowchart TD
    A[GET /pos/scan?q=] --> B{Admin auth?}
    B -- No --> C[302 → auth/login]
    B -- Yes --> D[Read q from request]
    D --> E["Product::with('units.unit')\n->code(q)->first()"]
    E -- hit --> F["data = product->toArray()\ndata['units'] = buildUnitsPayload(product)"]
    F --> G["JSON 200\n{is_barcode:true, data:object}"]
    E -- miss --> H["Product::with('units.unit')\n->where('name','like','%q%')\n->take(10)->get()"]
    H --> I["map each: arr = product->toArray()\narr['units'] = buildUnitsPayload(product)"]
    I --> J["JSON 200\n{is_barcode:false, data:array 0..10}"]
```

### `buildUnitsPayload` algorithm

```mermaid
flowchart TD
    A["Input: Product $product"] --> B["Seed units[0] =\n{unit_id:null, label:product.unit ?: '',\n conversion_qty:1, is_default:false}"]
    B --> C{More ProductUnit rows?}
    C -- Yes --> D["Append {unit_id, label:pu.unit?.name ?: '',\n conversion_qty:(float), is_default:(bool)}"]
    D --> E[Track hasDefault flag if is_default=true]
    E --> C
    C -- No --> F{hasDefault?}
    F -- No --> G["units[0].is_default = true"]
    F -- Yes --> H[Leave base unit non-default]
    G --> I[Return units array]
    H --> I
```

**Invariants guaranteed by the algorithm:**
- `units[0]` is always the synthetic base unit (`unit_id: null`, `conversion_qty: 1`).
- Exactly one entry in the returned array has `is_default: true`.
- Every entry carries a non-null `label` string (falls back to `''`).

### Discriminated-union response contract

The two data shapes are structurally different and selected by `is_barcode`:

| `is_barcode` | `data` type | Trigger |
|---|---|---|
| `true` | single object | `Product::scopeCode($q)` returned a row |
| `false` | array (0..10) | No code hit; name `LIKE` path ran |

Clients **must** branch on `is_barcode` before reading `data`.

## Technical Decisions

| Decision | Rationale | Source |
|---|---|---|
| Barcode-first, name-fallback in a single endpoint | Unified entry point for the terminal; avoids two round-trips per scan | `PosController.php:765-774` |
| Route registered before `resource('/pos')` | Prevents the resource `show` route from shadowing `/pos/scan` (Laravel matches routes in declaration order) | `routes/web.php:80-81` |
| Return full `product->toArray()` (no trimmed DTO) | Client cart engine computes per-line price from `price`/`sale_price`/`wholesale_prices`; server does not pre-compute it | `PosController.php:766,776` |
| Units normalized server-side (synthetic base + guaranteed default) | Client never needs to special-case the base unit or handle a missing default; all consumers see the same shape | `PosController.php:786-809` |
| Eager-load `units.unit` | Avoids N+1 across the ≤10 name results; also needed to populate `label` from `Unit.name` | `PosController.php:765,774` |
| No `q` normalization | `q` is bound as a parameter (no SQL injection); `%`/`_` leak as `LIKE` wildcards on the name path — acknowledged risk, not accidental | `PosController.php:763,774` |
| Cap name results at 10 | Bounds autocomplete list size and the `LIKE '%q%'` scan cost; no pagination affordance | `PosController.php:774` |
| Stateless action | No session mutation, no cache writes; every call is an independent read against live catalog state | `PosController.php:761-784` |

## Notes

### Constraints and pitfalls

- **Leading-wildcard `LIKE`**: `where('name','like','%q%')` cannot use a B-tree index on `name`. On a large catalog this degrades autocomplete latency. A reimplementation should evaluate a prefix index, full-text index, or trigram search.
- **Empty/null `q`**: `code(null)` matches nothing; `LIKE '%%'` matches any non-deleted product (up to 10). This is incidental behavior, not a specified contract. The intended response for a missing `q` is undefined.
- **Zero-conversion guard**: `conversion_qty` is returned as-is (cast to float). The server does not filter or reject `0`-conversion `ProductUnit` rows. Clients that divide by `conversion_qty` must guard against division by zero.
- **Soft-delete + rename**: Deleted products are excluded by the default `SoftDeletes` scope. At delete time the application also renames them to `(DELETED) <name>`, so any row that bypasses the scope is still visually flagged — defense in depth, not relied on by this unit.
- **No observability**: the action emits no logs, metrics, or traces. Transport failures surface only as the framework's default error response; the terminal client maps them to a `swal` alert.
