# POS Terminal — 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-terminal` unit. The route sits under the admin group (`['web','admin']`, empty admin prefix) and returns a **server-rendered HTML page** (`pages.pos`) with an injected client cart engine — this is a session-cookie web app, not a JSON API. The controller is `App\Http\Controllers\PosController`. The collaborating JSON/persistence endpoints the page calls (`/pos/scan`, `/products/get-price`, `/customers/scan`, `/orders/scan`, `POST|PUT /orders`) are documented in their own units and are listed here only as consumed contracts. 🟢 (`routes/web.php:22,81`, `PosController.php:680`)

---

## GET `/` — Landing redirect 🟢

- **Auth:** none required for the redirect itself.
- **Behavior:** permanent redirect to the POS terminal.
- **Responses:**
  - `301 Moved Permanently` — `Location: /pos`.
- Source: `routes/web.php:22`.

---

## GET `/pos` — POS terminal 🟢

- **Auth:** required (`admin` middleware); anonymous → `302` to `auth/login`. 🟢 (`routes/web.php:24-28`)
- **Request (query string):**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `id` | int (order id) | ❌ | When present, the terminal pre-loads that order as a draft to resume/edit. ✅ Fixed (2026-09-19): a missing/invalid id now falls back to no draft instead of a fatal error. 🟢 (`:31-41`) |

- **Behavior:** sets the page header, builds the collaborator URLs, optionally pre-loads a draft, injects the client cart engine, and renders `pages.pos`. The action itself performs **no writes** and is idempotent/side-effect-free; all mutation happens later via the collaborating `/orders` endpoints. 🟢 (`:18-687`)
- **Responses:**
  - `200 OK` — HTML terminal page (`pages.pos`) with the injected `Admin::script` engine.
  - `302 Found` — `Location: auth/login` when unauthenticated.
- Source: `PosController::index` (`:18-687`).

### View / boot payload (server → page) 🟢

`view('pages.pos', [])` is rendered with no explicit data array; dynamic state reaches the client through the injected script and page context:

| Item | Source | Meaning |
|------|--------|---------|
| `header` / `breadcrumb` | `$this->header = __('Bán hàng')` (`:20-23`) | Page title "Sales". |
| `url` | `admin_url('pos/scan')` (`:25,231`) | Product scan endpoint. |
| `getCustomerURL` | `admin_url('customers/scan')` (`:26,232`) | Customer select2 source. |
| `getOrderDraftURL` | `admin_url('orders/scan?status=draft')` (`:27,233`) | Draft picker source. |
| `getOrderURL` | `admin_url('orders')` (`:28,234`) | Order submit base URL. |
| `customerDetailURL` | `route('customers.statis',['#id#'])` (`:29,235`) | Customer-detail link template (`#id#` replaced client-side). |
| `$orderDraft` | `Order` + `customer` + normalized `products[].units` (`:31-41,610`) | `null` for a fresh terminal, a missing/invalid `?id=`, or the pre-loaded draft passed to `fillOrderDraft`. |

### Units payload shape (per product, via `buildUnitsPayload`) 🟢 (`:786-810`)

```
units: [
  { unit_id: null, label: <product.unit string>, conversion_qty: 1, is_default: <true if no ProductUnit default> },
  { unit_id: <int>, label: <Unit.name>, conversion_qty: <float>, is_default: <bool> },
  ...
]
```
Exactly one entry is `is_default`. The base unit (`unit_id:null`) is always first.

---

## Consumed contracts (owned by other units)

These are called by the injected client engine; their request/response schemas are specified in the referenced units:

| Method | Path | Owner unit | Call site |
|--------|------|-----------|-----------|
| GET | `/pos/scan?q=` | `pos-scan` | scanner Enter / autocomplete (`:252-267`) |
| GET | `/products/get-price?phone=&product_id=&unit_id=` | `products-pricing` | synchronous per-line pricing (`:62-72`) |
| GET | `/customers/scan` | `customers-scan` | customer select2 (`:453`) |
| GET | `/orders/scan?status=draft` | `orders-scan` | draft picker select2 (`:575`) |
| POST | `/orders` | `orders-crud` | new-sale submission (`:332-333`) |
| PUT | `/orders/{id}` | `orders-crud` | loaded-draft submission (`:561-562`) |
| POST | `customers` | `customers-crud` | quick-add modal (`:619-650`) |

The pricing call uses the customer's **phone** as the `phone` parameter (select2 remaps `id = phone`). 🟢 (`:60,456-461`)

---

## Cross-cutting contract notes

- **Method surface:** although registered via `resource('/pos', …)`, only `GET /pos` (`index`) is meaningful; `POST/PUT/PATCH/DELETE` and `show/create/edit` are empty framework stubs and are **out of scope** — do not expose them. 🟢 (`routes/web.php:81`, `PosController.php:694-753`)
- **Transport / session:** HTTPS expected in production; cookie-based session via the `web` group. 🟢
- **CSRF:** `GET /pos` changes no state; the collaborating `POST|PUT /orders` and quick-add `POST customers` calls require the framework CSRF token (handled by the `web` group / the POS form). 🟡
- **Content type:** `text/html` (or a `3xx` redirect); there is **no JSON contract** for this endpoint — the JSON contracts are the collaborators'. 🟢
- **Idempotency / caching:** `GET /pos` is read-only and safe; it must **not** be cached in a way that stales the injected URLs or a `?id=` draft. 🟢 (`:18-687`)
- **Landing page:** `/pos` is the app landing page — root `/` 301-redirects here. 🟢 (`routes/web.php:22`)
- **Client-side error signalling:** scan failures surface via `swal` ("Lỗi, vui lòng tải lại trang"); no-match shows an inline autocomplete message. 🟢 (`:203-205,262-264`)
