# POS Terminal — Technical Design

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-19
> Legacy route: `GET /pos` (`PosController::index`) · view `pages.pos`

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

## Interface

### HTTP surface (this unit)

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/` | — | `301` redirect to `/pos` | 301 |
| GET | `/pos` | optional query `id: int` (draft to preload) | `pages.pos` HTML with injected cart-engine script | 200, 302 (→ login) |

Only the `index` verb of the `/pos` resource is real; `create/store/show/edit/update/destroy` are empty stubs. 🟢 (`PosController.php:694-753`)

### Collaborating endpoints (owned by other units, called by the injected client)

| Method | Path | Owner unit | Role in the terminal |
|--------|------|-----------|----------------------|
| GET | `/pos/scan?q=` | `pos-scan` | Barcode/name product lookup feeding `addProductRow`. 🟢 (`:80,231,252-267`) |
| GET | `/products/get-price?phone=&product_id=&unit_id=` | `products-pricing` | Synchronous per-line unit price. 🟢 (`:57-72`) |
| GET | `/customers/scan` | `customers-scan` | select2 customer search. 🟢 (`:453`) |
| GET | `/orders/scan?status=draft` | `orders-scan` | Draft picker select2 source. 🟢 (`:575`) |
| POST/PUT | `/orders` \| `/orders/{id}` | `orders-crud` | Persist the sale (draft/done). 🟢 (`:332-333,561-562`) |
| POST | `customers` (quick-add) | `customers-crud` | Inline customer creation from the modal. 🟢 (`:619-650`) |
| GET | `customers.statis` (link) | `customers-statistics` | Customer detail link. 🟢 (`:29,423`) |

### Server-side symbols (this unit)

| Symbol | Signature | Returns | Note |
|--------|-----------|---------|------|
| `PosController::index` | `()` | `View pages.pos` | Sets header/breadcrumb, builds URLs, optional draft preload, injects script. 🟢 (`:18`) |
| `PosController::buildUnitsPayload` | `(Product $product)` | `array` | Base unit + each `ProductUnit`, exactly one `is_default`. 🟢 (`:786`) |
| `PosController::scan` | `()` | `JsonResponse` | Belongs to unit `pos-scan`; excluded here. 🟢 (`:754`) |

### View payload (server → `pages.pos`)

`index` renders `view('pages.pos', [])` with **no** explicit data array; the dynamic state is delivered entirely through the injected `Admin::script($sc)` (URLs, optional `$orderDraft`) plus whatever the Blade layout reads from `$this->header` / `$this->breadcrumb`. 🟢 (`:679,680`)

## Fluxo Principal

1. **Route + auth.** `GET /pos` enters the `['web','admin']` group (empty admin prefix); an unauthenticated request is redirected to `auth/login`. Root `/` 301-redirects here. 🟢 (`routes/web.php:22-28,81`)
2. **Header/breadcrumb.** `index` sets `$this->header = __('Bán hàng')` and a one-item breadcrumb. 🟢 (`:20-23`)
3. **URL assembly.** Build `admin_url('pos/scan')`, `admin_url('customers/scan')`, `admin_url('orders/scan?status=draft')`, `admin_url('orders')`, and `route('customers.statis', ['#id#'])` (a placeholder later string-replaced client-side). 🟢 (`:25-29`)
4. **Optional draft preload.** If `request()->id` is set, `Order::find(id)`; if found, `->load('customer','products.units.unit')` and for each product replace its `units` relation with `buildUnitsPayload($product)` so the client receives the normalized unit list rather than raw pivots. A missing/invalid id leaves `$orderDraft` as `null` (✅ fixed 2026-09-19, was unguarded). 🟢 (`:31-41`)
5. **Script injection.** Assemble the ~630-line heredoc `$sc` (interpolating the URLs and `$orderDraft`) and register it with `Admin::script($sc)`. 🟢 (`:40-679`)
6. **Render.** Return `view('pages.pos', [])`; the client engine boots on `document.ready`, calls `fillOrderDraft($orderDraft)` (no-op when null), focuses the scanner, and arms the unsaved-work guard. 🟢 (`:610,652-665,678`)

### Client cart engine — sub-flows (all confirmed against the live UI)

**A. Add by scan / autocomplete** 🟢 (`:237-306,89-121,194-228`)
- Scanner input listens for Enter (keyCode 13); the term is sent to `GET /pos/scan?q=`.
- Response `is_barcode:true` → `addItem(data)` directly; otherwise render an autocomplete list (one entry per product × unit, each pre-priced), and clicking an item calls `addItem`.
- `addItem` resolves the selected unit (pivot unit → preset unit → default unit), computes `lineKey = code + '__' + (unitId||'base')`. If the key already exists → `updateItem` (qty += 1); else push a new line (qty = 1 or pivot qty), sync `is_default` on the unit list, fetch price, set `total = qty * round(price,1)`, render the row, and call `updatePOS()`.

**B. Quantity edit** 🟢 (`:347-352,126-152`)
- Editing a line's `.input-number` sets qty and calls `updateItem`, which re-prices and re-renders that row.

**C. Unit change (relabel or merge)** 🟢 (`:354-407`)
- On `.unit-select` change compute `newLineKey`. If unchanged, no-op.
- If a line with `newLineKey` already exists (collision): add this line's qty into the collision line, re-price the collision for its unit, re-render it, and remove the original line/array entry.
- Otherwise: relabel the line to `newLineKey`, sync `is_default`, re-price for the new unit (qty preserved), re-render.

**D. Customer selection + re-price** 🟢 (`:433-511,439-443,619-650`)
- `#customerKey` select2 queries `customers/scan`, mapping each result so `id = phone` (and `oid = original id`).
- On change, `fillCustomerInfo` populates the customer zone, enables the debt input, shows the debt hint when `debt_total > 0`, and sets the customer-detail link. Then **every** cart line is re-priced for the customer's `type`.
- The quick-add modal posts to `customers` (JSON); on success the new customer is selected and filled in.

**E. Draft rehydration** 🟢 (`:517-617`)
- `fillOrderDraft(result)` sets the customer, clears and refills the product list (`addProductRow({data:product,is_barcode:true})` per product), fills notes and totals, sets the draft zone, and — for a `done` order — adds success styling and disables the customer search.
- If `result.debt_locked`, the debt input is disabled.
- If `result.id`, switch to draft/edit mode: show `.draft-mode`, push `?id=` into the URL, set the form `_method` to `PUT` and action to `/orders/{id}`.
- The draft picker (`#orderDraftKey`, source `orders/scan?status=draft`) triggers `fillOrderDraft` on selection; the server-preloaded `$orderDraft` is passed to `fillOrderDraft` at boot.

**F. Totals** 🟢 (`:168-189,336-345`)
- `updatePOS` recomputes `subtotal = Σ round(line.total,1)`, `total = subtotal − discount`, and mirrors `count/discount/debt` into the `#totalPOS` bindings model; a `model-change` handler re-runs `updatePOS`.

**G. Submit** 🟢 (`:308-334`)
- On a submit button click, read the debt amount. If debt > 0 **and** the debt input is enabled: require a selected customer, and require debt ≤ (subtotal − discount); otherwise show the matching swal error and abort.
- Otherwise set the form's hidden `status` to the button's `data-status` and submit `#pos_form` (to `/orders`, or `PUT /orders/{id}` when a draft is loaded).

## Fluxos Alternativos

- **No draft (`?id=` absent):** `$orderDraft` is `null`; `fillOrderDraft(null)` returns immediately; the terminal opens empty. 🟢 (`:30,520`)
- **No product match on scan:** autocomplete shows "Không tìm thấy sản phẩm phù hợp." and nothing is added. 🟢 (`:203-205`)
- **Scan AJAX failure:** a swal error "Lỗi, vui lòng tải lại trang" is shown. 🟢 (`:262-264`)
- **No customer selected:** the debt input is cleared and disabled; `customerMore`/`customerDebtGroup` are hidden. 🟢 (`:415-420`)
- **`done` order loaded:** the draft-zone status label gets `label-success` and the customer search is made non-interactive. 🟢 (`:545-548`)
- **`debt_locked` order:** the debt input is disabled so the recorded debt cannot be re-posted. 🟢 (`:549-554`)
- **Product with no configured `ProductUnit`:** `buildUnitsPayload` returns just the base unit, forced `is_default=true`. 🟢 (`:788-807`)

## Dependências

- **`products-pricing`** (`GET /products/get-price`) — synchronous per-line price source; the terminal is unusable if it fails. 🟢
- **`pos-scan`** (`GET /pos/scan`) — product lookup for the scanner/autocomplete. 🟢
- **`customers-scan` / `customers-crud`** — customer search (select2) and inline quick-add. 🟢
- **`orders-crud` / `orders-scan`** — draft picker source and the persistence target (`POST|PUT /orders`); owns totals, points and debt posting. 🟢
- **`Product`, `ProductUnit`, `Unit`, `Order`** Eloquent models — draft preload and `buildUnitsPayload`. 🟢 (`:5-6,32,786-810`)
- **`Encore\Admin\Admin::script`** — the mechanism that injects the client engine into the page. 🟢 (`:7,673`)
- **Front-end libraries** the injected script assumes are present: jQuery, `select2`, `jsrender` (`$.templates`), `numeral`, `swal`, a `.bindings(...)` model plugin, `inputNumber`, and `hotkeys`. 🟡 (`:117,187,264,336,445,653`)

## Decisões de Design Identificadas

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| The cart is a client-side in-memory engine; the controller only renders and seeds. | `PosController.php:40-679,678` | 🟢 |
| The cart engine is delivered as an inline heredoc via `Admin::script` rather than an asset file. | `:40-679` | 🟢 |
| Cart lines are identified by `code + '__' + (unit_id||'base')`; qty is a scan count, not a converted amount. | `:96,131,354-407` | 🟢 |
| Pricing is fetched synchronously (`async:false`) per line so totals are always consistent before render. | `:62-72` | 🟢 |
| select2 remaps customer results so `id = phone` (phone is the customer key used by pricing/orders). | `:456-461` | 🟢 |
| A single form (`#pos_form`) is reused for both create (`POST /orders`) and update (`PUT /orders/{id}`) via method override. | `:332-333,561-562` | 🟢 |
| Debt guardrails are enforced both client-side (here) and server-side (`OrderController`) — the client mirror is a UX pre-check, not the source of truth. | `:308-334` | 🟢 |

## Estado Interno

The terminal holds transient client-side state only; nothing is persisted by this unit. 🟢
- `POSitems` — the in-memory cart line array (each line: product fields + `qty`, `selected_unit_id`, `lineKey`, `total`, `total_text`, `units[]`). 🟢 (`:230,115,146`)
- `#totalPOS` bindings model — `{count, subtotal, total, discount, debt}`. 🟢 (`:336-342`)
- `#customerZone` / `#orderDraftZone` bindings models — current customer and loaded-draft identity. 🟢 (`:433-437,517`)
- Server-side, `index` is stateless apart from the optional `$orderDraft` read (no writes). 🟢

## Observabilidade

- No server-side logging in `index`. 🟢 (`:18-687`)
- Client-side: scan AJAX failures are `console.error`-logged and surfaced via `swal`. 🟢 (`:262-264`)
- There are no metrics/traces; observability of the sale itself lives downstream in `OrderController`. 🟡

## Riscos e Lacunas

- 🟢 The entire cart engine is a ~630-line inline JS string — correct per the team's manual UI walkthrough, but opaque to PHP static analysis and not unit-testable as-is. A reimplementation should extract it into a testable module.
- 🟢 Synchronous (`async:false`) pricing calls block the UI thread; a customer change re-prices every line serially. On slow networks or large carts this is a UX risk (RF-07/RF-09).
- 🟡 The injected script depends on several global front-end libraries (jQuery, select2, jsrender, numeral, swal, `.bindings`, `inputNumber`, hotkeys) provided by the Encore\Admin layout; these must be present for the terminal to function.
- 🟢 Client-side debt validation is duplicated with the server; confirmed 2026-09-25 (`questions.md#question-17`) that the server (`OrderController`) is the single source of truth, the client is UX-only. Kept the duplication (removing it would lose the instant inline error; a shared-validation endpoint was judged too large a change for this pass) but added explicit cross-referencing comments in both `PosController.php` (`:314-317`) and `OrderController.php` (`:125,303`) so future edits to one side are more likely to prompt syncing the other.
- ✅ **Fixed (2026-09-19).** `Order::find(request()->id)` on preload used to be unguarded (`->load()` on `null` would fatal-error for a missing/deleted order id). Now checks for `null` before `->load()`; a bad `?id=` falls back to no draft (same as `?id=` absent), matching the client's existing `fillOrderDraft(null)` no-op. (`:31-41`)
