# POS Terminal Design

> Legacy route: `GET /pos` (`PosController::index`) · view `pages.pos`

## Data Model

### Eloquent models consumed at server boot

```
Order
  ├── customer          (BelongsTo Customer)
  └── products          (BelongsToMany Product via order_product)
        └── units       (HasMany ProductUnit)
              └── unit  (BelongsTo Unit)

Product
  ├── unit              (string — free-text base-unit label)
  └── productUnits      (HasMany ProductUnit)

ProductUnit
  ├── unit_id           (FK → Unit)
  ├── conversion_qty    (float)
  └── is_default        (bool)

Unit
  └── name              (string)
```

Only `Order::find($id)->load('customer','products.units.unit')` is executed at boot, solely for the optional draft preload. No writes occur in `index`.

### Normalised units payload (`buildUnitsPayload`) — shape per product

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

Invariant: exactly one entry has `is_default = true`. The base entry (`unit_id: null`) is always first.

### Client-side in-memory state (browser only)

| Store | Shape | Notes |
|-------|-------|-------|
| `POSitems` | `Array<{code, product_id, name, qty, selected_unit_id, lineKey, price, total, total_text, units[]}>` | Authoritative cart; never persisted by this unit |
| `#totalPOS` bindings model | `{count, subtotal, total, discount, debt}` | Recomputed by `updatePOS` on every change |
| `#customerZone` bindings model | `{id, phone, name, type, debt_total, …}` | Active customer; cleared when no customer selected |
| `#orderDraftZone` bindings model | `{id, status, debt_locked, …}` | Loaded draft identity; `null` for a fresh terminal |

## Internal Flows

### Server-side boot sequence (`PosController::index`)

```mermaid
flowchart TD
    A[GET /pos] --> B{Authenticated?}
    B -- no --> C[302 → auth/login]
    B -- yes --> D[Set header = __Bán hàng__, breadcrumb]
    D --> E[Build 5 admin URLs\npos/scan · customers/scan · orders/scan?status=draft\norders · customers.statis#id#]
    E --> F{?id= present?}
    F -- no --> G[$orderDraft = null]
    F -- yes --> H[Order::find id]
    H -- null --> G
    H -- found --> I[load customer · products.units.unit\nreplace each product.units\nwith buildUnitsPayload]
    I --> J[$orderDraft = Order]
    G --> K[Assemble ~630-line JS heredoc\ninterpolate URLs + orderDraft]
    J --> K
    K --> L[Admin::script sc]
    L --> M[return view pages.pos]
```

### Client cart engine — sub-flows

**A. Scan / autocomplete add**

```mermaid
flowchart TD
    A[Enter on scanner input] --> B[GET /pos/scan?q=term]
    B -- is_barcode:true --> C[addItem data]
    B -- list --> D[Render per-product×unit autocomplete\none entry per product × unit, pre-priced]
    D --> E[Click item] --> C
    B -- no match --> F[Show empty message]
    B -- AJAX error --> G[swal error + console.error]
    C --> H{lineKey exists?}
    H -- yes --> I[updateItem: qty += 1]
    H -- no --> J[Push new line, qty = 1 or pivot qty\nsync is_default on unit list]
    I --> K[Sync price → total = qty × round price 1\nre-render row → updatePOS]
    J --> K
```

**B. Unit change**

```mermaid
flowchart TD
    A[.unit-select change] --> B{newLineKey == oldLineKey?}
    B -- yes --> C[no-op]
    B -- no --> D{newLineKey exists in POSitems?}
    D -- yes collision --> E[Add this qty into collision line\nre-price collision for new unit\nre-render collision\nremove original line + array entry]
    D -- no --> F[Relabel line to newLineKey\nsync is_default\nre-price for new unit, qty preserved\nre-render]
    E --> G[updatePOS]
    F --> G
```

**C. Customer selection + re-price**

```mermaid
flowchart TD
    A[#customerKey select2 change] --> B[fillCustomerInfo\npopulate customer zone\nenable debt input\nshow debt hint if debt_total > 0\nset customer-detail link]
    B --> C[For every POSitems entry:\nGET /products/get-price?phone=customer.phone&product_id=&unit_id=\nupdate price + total\nre-render row]
    C --> D[updatePOS]
    A2[Quick-add modal submit] --> E[POST customers JSON]
    E -- success --> A
    E -- error --> F[Show field errors]
```

**D. Draft rehydration (`fillOrderDraft`)**

```mermaid
flowchart TD
    A[fillOrderDraft result] --> B{result null?}
    B -- yes --> C[return, terminal stays empty]
    B -- no --> D[Set customer, clear + refill product list\nfill notes, totals, status]
    D --> E{result.status == done?}
    E -- yes --> F[Add label-success to status\ndisable customer search]
    E -- no --> G[standard draft]
    D --> H{result.debt_locked?}
    H -- yes --> I[Disable debt input]
    D --> J{result.id present?}
    J -- yes --> K[Show .draft-mode\npush ?id= into URL\nset form _method = PUT\nset form action = /orders/id]
```

**E. Submit**

```mermaid
flowchart TD
    A[Submit button click] --> B[Read debt amount]
    B --> C{debt > 0 AND debt input enabled?}
    C -- yes --> D{Customer selected?}
    D -- no --> E[Block: Phải chọn khách hàng khi có tiền nợ.]
    D -- yes --> F{debt > subtotal − discount?}
    F -- yes --> G[Block: Số tiền nợ không được lớn hơn tổng tiền hàng.]
    F -- no --> H[Set hidden status = button data-status\nsubmit #pos_form]
    C -- no --> H
```

**F. Totals (`updatePOS`)**

`subtotal = Σ round(line.total, 1)` for all `POSitems`  
`total = subtotal − discount`  
Mirrors `count / discount / debt` into the `#totalPOS` bindings model.  
Triggered on every add / update / unit-change / customer-change / discount / debt `model-change` event.

## Technical Decisions

| Decision | Evidence | Rationale / Constraint |
|----------|----------|------------------------|
| Cart engine delivered as an inline PHP heredoc via `Admin::script` rather than a standalone JS asset. | `PosController.php:40-679` | Ties into the Encore Admin framework's script injection mechanism; avoids a separate asset pipeline step. Makes the engine opaque to PHP static analysis and untestable in isolation — a known trade-off. |
| Pricing is fetched synchronously (`async: false`) per line. | `PosController.php:62-72` | Ensures totals are always consistent before the row is rendered. On slow networks or large carts this blocks the UI thread; confirmed risk, not yet addressed. |
| Cart lines are keyed by `code + '__' + (unit_id \|\| 'base')`. | `PosController.php:96,131` | Lets the same physical product appear as multiple lines when sold in different units, while deduplicating same-product-same-unit adds into a single line. |
| Unit-change merge uses **direct quantity addition** (no conversion applied). | `PosController.php:354-407` | Quantity tracks scan count, not converted physical amount; changing units only relabels and re-prices, never converts the number. |
| `select2` remaps customer results so `id = phone` (and `oid = original id`). | `PosController.php:456-461` | The pricing endpoint and order submission identify customers by phone, not by database id. |
| A single `#pos_form` handles both create (`POST /orders`) and update (`PUT /orders/{id}`) via a hidden `_method` field. | `PosController.php:332-333,561-562` | Standard Laravel method-override pattern; avoids a second form element in the view. |
| Client-side debt validation is an intentional duplicate of the server-side check in `OrderController`. | `PosController.php:308-334`, `OrderController.php:125,303` | Client check provides instant inline UX feedback without a round-trip. Server is the single source of truth. Cross-referencing comments were added in both files (2026-09-25) to reduce the risk of future divergence. |
| `buildUnitsPayload` always prepends a synthetic base-unit entry (`unit_id: null`). | `PosController.php:786-810` | Guarantees the client always has a fallback unit regardless of whether `ProductUnit` rows exist, and ensures the `is_default` invariant can always be satisfied. |
| Only the `index` verb of the `/pos` resource route is real; the other five verbs are empty stubs. | `PosController.php:694-753`, `routes/web.php:81` | The Encore Admin router requires a `resource()` registration but the POS exposes only the terminal page — no CRUD surface. |

## Notes

- **Dependency on front-end globals.** The injected engine assumes jQuery, `select2`, `$.templates` (jsrender), `numeral`, `swal`, a `.bindings(...)` model plugin, `inputNumber`, and `hotkeys` are already present in the page. These are provided by the Encore Admin layout; a reimplementation on a different stack must supply equivalents. 🟡
- **Server-side observability is nil for this unit.** `index` emits no logs or metrics; sale observability lives entirely in `OrderController`. Client-side AJAX failures are `console.error`-logged and surfaced via `swal`. 🟡
- **Draft preload null-guard fix (2026-09-19).** Prior to the fix, `Order::find(id)->load(…)` would fatal-error when `id` referred to a missing/deleted order. The guard now checks for `null` before calling `->load()`, falling back to `$orderDraft = null` (same as no `?id=`). 🟢
