# POS Terminal — Requirements

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

**Confidence scale:** 🟢 CONFIRMED (read directly from code) · 🟡 INFERRED (pattern-based, may be wrong) · 🔴 GAP (needs human validation)

## Overview

The `pos-terminal` unit is the cashier point-of-sale screen of TinyPOS (`tnx-pos`) and the application's **effective landing page** — root `/` issues a `301` redirect to `/pos`. A single `PosController::index` action sets the page header (`Bán hàng` / "Sales"), computes the admin URLs the screen will call, optionally pre-loads an existing order draft, and injects a large (~630-line) inline JavaScript **cart engine** via `Admin::script(...)` before rendering the Blade view `pages.pos`. 🟢 (`app/Http/Controllers/PosController.php:18-687`, `routes/web.php:22,81`)

Almost all of the terminal's behaviour lives **client-side** in that injected script: barcode/name product lookup, an in-memory cart with per-line unit selection, synchronous re-pricing, customer selection with per-customer-type re-pricing, order-draft rehydration, debt-input rules, and form submission. The server side of this unit is thin — it only renders the shell and seeds it with URLs and (optionally) a draft. The three collaborating HTTP endpoints (`GET /pos/scan`, `GET /products/get-price`, `POST|PUT /orders`) are **owned by other units** (`pos-scan`, `products-pricing`, `orders-crud`) and appear here only as dependencies. 🟢 (`PosController.php:25-38,40-679`)

Only the resource `index` verb is used; `create/store/show/edit/update/destroy` exist as empty stubs and are out of scope for reimplementation. 🟢 (`PosController.php:694-753`)

## Responsibilities

- Serve `GET /pos` behind the `['web','admin']` middleware group and render the `pages.pos` view. 🟢 (`routes/web.php:24-28,81`, `PosController.php:680`)
- Act as the post-login landing screen: root `/` 301-redirects to `/pos`. 🟢 (`routes/web.php:22`)
- Set the page header to `Bán hàng` ("Sales") and a single-item breadcrumb. 🟢 (`PosController.php:20-23`)
- Build and hand the client the admin URLs it needs: product scan (`pos/scan`), customer scan (`customers/scan`), draft picker (`orders/scan?status=draft`), order submit target (`orders`), and the customer-statistics detail link (`customers.statis`). 🟢 (`PosController.php:25-29`)
- When a `?id=` query parameter is present, pre-load that `Order` draft with `customer` + `products.units.unit` and replace each product's `units` relation with a normalized units payload so the cart can rehydrate; a missing/invalid id falls back to no draft (✅ fixed 2026-09-19). 🟢 (`PosController.php:31-41`)
- Compute each product's **units payload** — a base unit (`unit_id:null`, label = `product->unit`, `conversion_qty:1`) followed by each configured `ProductUnit`, guaranteeing exactly one `is_default`. 🟢 (`PosController.php:786-810`)
- Inject the client cart engine that: adds items by barcode/autocomplete, dedups cart lines by line key, changes a line's unit (with same-line merge), re-prices lines synchronously, manages the customer selection + quick-add, rehydrates drafts, enforces debt-input rules, and submits the POS form. 🟢 (`PosController.php:40-679`)
- **Out of scope for this unit (do not implement here):** the `/pos/scan` lookup endpoint (unit `pos-scan`), the `/products/get-price` pricing endpoint (unit `products-pricing`), order persistence / totals / points / debt posting (unit `orders-crud`), and the unused resource verbs. 🟢 (`PosController.php:694-753`, `routes/web.php:47,72-81`)

## Business Rules

- **BR-01 — POS is the landing page.** The effective home screen after login is `/pos`; root `/` 301-redirects there and the framework `HomeController` builder is dead code. Any reimplementation must land the operator on the terminal, not the dashboard. 🟢 (`routes/web.php:22`, cross-ref `_reversa_sdd/dashboard/requirements.md` BR-06)
- **BR-02 — The cart is client-side; the server only renders and (optionally) seeds.** `index` performs no cart math and no writes. The authoritative cart state lives in the browser (`POSitems` array) until the form is posted to `/orders`. 🟢 (`PosController.php:40-679,678`)
- **BR-03 — A product's sellable units are a base unit plus its conversions, always with one default.** `buildUnitsPayload` always prepends the base unit (`unit_id:null`, label = the product's free-text `unit` string, `conversion_qty:1`), then appends each `ProductUnit` (its `unit_id`, the related `Unit` name, `conversion_qty`, `is_default`). If **no** `ProductUnit` is flagged default, the base unit is made default. 🟢 (`PosController.php:786-810`)
- **BR-04 — A cart line is keyed by product code + unit.** `lineKey = order.code + '__' + (selectedUnitId || 'base')`. Scanning/selecting the same product **in the same unit** increments that line's quantity by 1; the same product in a **different** unit is a distinct line. 🟢 (`PosController.php:96-101,131`)
- **BR-05 — Quantity is a scan/add count, not a converted physical amount.** Changing a line's unit only relabels it and re-prices it; the numeric quantity is preserved as-is (no unit conversion is applied to the quantity). If the target unit already has a line for the same product, the two lines **merge by direct quantity addition** (no conversion, because after the change both are the same unit). 🟢 (`PosController.php:354-407`)
- **BR-06 — Price is fetched synchronously per line from the pricing endpoint.** Each add / update / unit-change / customer-change re-prices the line via a **synchronous** `GET /products/get-price?phone=<customerId>&product_id=<id>[&unit_id=<id>]`; the line total is `qty * round(unitPrice, 1)`. Pricing logic itself (wholesale-by-customer-type, divide by `conversion_qty`) belongs to `products-pricing`. 🟢 (`PosController.php:57-72,111-112,140-141,215,381-382,398-399,501-502`)
- **BR-07 — Selecting a customer re-prices the whole cart for that customer's type.** On customer `change`, every existing line is re-priced using the selected customer's `type`, so wholesale tiers apply retroactively to items already in the cart. 🟢 (`PosController.php:489-511`)
- **BR-08 — Debt entry requires a customer and cannot exceed the order total.** On submit, if the debt amount `> 0` **and** the debt input is not disabled: a customer must be selected (else block with "Phải chọn khách hàng khi có tiền nợ."), and the debt must not exceed subtotal − discount (else block with "Số tiền nợ không được lớn hơn tổng tiền hàng."). These are the same rules `OrderController` enforces server-side. 🟢 (`PosController.php:308-334`, cross-ref `_reversa_sdd/orders-crud`)
- **BR-09 — The debt input is locked when it must not change.** When no customer is selected the debt input is cleared and disabled. When a loaded draft is already `debt_locked` (a `pos_debt` ledger entry exists — even if the order was reverted to draft), the debt input is disabled so the amount cannot be re-posted; adjustments must go through the debts screen ("Ghi nợ tay" / "Thu nợ"). 🟢 (`PosController.php:415-422,549-554`)
- **BR-10 — Loading a draft switches the form to an update.** Selecting/loading an existing order (via `?id=` preload or the draft picker) rehydrates the cart, customer, notes and totals, appends `?id=` to the URL, and repoints the form to `PUT /orders/{id}` (method override). A `done` draft also locks the customer search. 🟢 (`PosController.php:519-564,610-617`)
- **BR-11 — Submission sets the target status and posts the POS form.** The action buttons carry a `data-status` (`draft` or `done`); on click the script writes it into the form's hidden `status` input and submits `#pos_form` to `/orders` (or `PUT /orders/{id}` for a loaded draft). Persistence, totals, earned points and debt posting all happen in `OrderController`. 🟢 (`PosController.php:308-334,561-562`)
- **BR-12 — An unsaved-work guard is armed.** `window.onbeforeunload` returns a warning string so the cashier is prompted before navigating away with an unsubmitted cart. The `F2` hotkey refocuses the scanner input. 🟢 (`PosController.php:652-665`)
- **BR-13 — Only `index` is a real action.** The remaining resource verbs are empty stubs and must not be exposed. 🟢 (`PosController.php:694-753`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance Criterion |
|----|-------------|----------|----------------------|
| RF-01 | Serve `GET /pos` behind the `admin` auth middleware, rendering `pages.pos`. | Must | Authenticated request returns the POS view; anonymous request redirects to `auth/login`. 🟢 |
| RF-02 | Make `/pos` the landing page via a root `301` redirect from `/`. | Must | `GET /` returns `301` with `Location: /pos`. 🟢 |
| RF-03 | Provide the client with the scan, customer-scan, draft-scan, order-submit and customer-detail URLs. | Must | The rendered page exposes the five URLs to the cart engine. 🟢 |
| RF-04 | When `?id=<orderId>` is present, pre-load that order (customer + products with a normalized units payload) so the cart rehydrates on load. | Should | Loading `/pos?id=N` renders the terminal pre-filled with order N's customer, lines, notes and totals. 🟢 |
| RF-05 | Compute a units payload per product: base unit first, then each `ProductUnit`, always with exactly one `is_default`. | Must | Payload begins with `{unit_id:null,label:<product.unit>,conversion_qty:1}`; exactly one entry has `is_default=true`. 🟢 |
| RF-06 | Add a scanned/selected product to the cart, keyed by `code + '__' + (unit_id||'base')`; a repeat of the same product+unit increments its quantity. | Must | Scanning product X twice in the same unit yields one line with qty 2; scanning X in two units yields two lines. 🟢 |
| RF-07 | Re-price each line synchronously from `GET /products/get-price` and compute `total = qty * round(price,1)`. | Must | After add/update the line total equals qty × rounded unit price for the active customer/unit. 🟢 |
| RF-08 | Change a line's unit: relabel + re-price, preserving quantity; if the target unit line already exists, merge by direct quantity addition. | Should | Switching a line's unit keeps its qty and updates its price; switching into an existing unit-line sums the quantities into one line. 🟢 |
| RF-09 | Support customer lookup (select2 against `customers/scan`), a quick-add-customer modal, and re-pricing of all lines on customer change. | Should | Selecting a customer re-prices every cart line for that customer's type; quick-add creates a customer and selects it. 🟢 |
| RF-10 | Maintain running totals: `count`, `subtotal` (Σ line totals), `total` (subtotal − discount), `discount`, `debt`. | Must | Totals update on every cart/line/discount/debt change. 🟢 |
| RF-11 | Enforce debt rules on submit: debt > 0 requires a selected customer and must not exceed the order total (only when the debt input is enabled). | Must | Submitting with debt > 0 and no customer, or debt > total, is blocked with the corresponding message; debt = 0 or a disabled input submits. 🟢 |
| RF-12 | Lock/clear the debt input when no customer is selected, and lock it for a `debt_locked` draft. | Must | With no customer the debt input is disabled and empty; a `debt_locked` order's debt input is disabled. 🟢 |
| RF-13 | Rehydrate a selected draft (cart, customer, notes, totals, status) and repoint the form to `PUT /orders/{id}`. | Should | Loading a draft fills the terminal and submitting issues a `PUT` to that order. 🟢 |
| RF-14 | On submit, set the form's `status` to the button's `data-status` (`draft`/`done`) and post `#pos_form` to the order endpoint. | Must | The "save draft" button posts status `draft`; the "finalize" button posts status `done`. 🟢 |
| RF-15 | Warn the operator before leaving with unsaved work (`onbeforeunload`); bind `F2` to refocus the scanner. | Could | Navigating away with a non-empty cart prompts a confirmation; pressing F2 focuses the scanner input. 🟢 |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | The terminal requires an authenticated admin session (`['web','admin']`); there is no per-role restriction (any admin operates the till). | `routes/web.php:24-28,81`, `config/admin.php`, `_reversa_sdd/permissions.md` | 🟢 |
| Performance | Pricing lookups are **synchronous** (`async:false`) AJAX calls, one per line per re-price event — a customer change re-prices every line serially, blocking the UI thread. | `PosController.php:62-72,489-511` | 🟢 |
| Usability | Barcode-scanner workflow: the scanner input listens for Enter (keyCode 13) to trigger a lookup; `F2` refocuses it; an unsaved-work guard prevents accidental loss. | `PosController.php:237-273,652-665` | 🟢 |
| Maintainability | The entire cart engine is a ~630-line inline JS heredoc injected via `Admin::script`, opaque to PHP static analysis and untestable in isolation. | `PosController.php:40-679` | 🟢 |
| Localization | All operator-facing strings are Vietnamese literals embedded in the script/view (e.g. "Bán hàng", swal messages, select2 placeholders). | `PosController.php:20,264,315,327,446,471` | 🟢 |

> Inferred from the code. Validate the synchronous-pricing performance expectation (blocking re-price per line) with the operations team; confirm acceptable behaviour on slow networks / large carts.

## Acceptance Criteria

```gherkin
Feature: POS terminal

  Scenario: Terminal is the landing page
    Given I am an authenticated administrator
    When I request GET /
    Then I am redirected (301) to /pos
    And GET /pos returns the pages.pos view

  Scenario: Scanning the same product twice increments the line
    Given the POS terminal is open
    When I scan product "X" (base unit)
    And I scan product "X" (base unit) again
    Then the cart has one line for "X" with quantity 2
    And the line total equals 2 × the rounded unit price

  Scenario: Same product in two units are two lines
    Given product "X" has a base unit and a "box" conversion unit
    When I add "X" in the base unit
    And I add "X" in the "box" unit
    Then the cart has two distinct lines for "X"

  Scenario: Changing a line's unit into an existing line merges them
    Given the cart has "X/base" qty 2 and "X/box" qty 1
    When I change the "X/base" line to the "box" unit
    Then the cart has a single "X/box" line with quantity 3
    And the line is re-priced for the box unit

  Scenario: Selecting a customer re-prices the whole cart
    Given the cart has lines priced at retail
    When I select a wholesale customer
    Then every line is re-priced for that customer's type

  Scenario: Debt requires a customer
    Given the cart has a positive total and the debt input is enabled
    And no customer is selected
    When I enter a debt amount greater than 0 and submit
    Then submission is blocked with "Phải chọn khách hàng khi có tiền nợ."

  Scenario: Debt cannot exceed the order total
    Given a customer is selected and the debt input is enabled
    When I enter a debt amount greater than (subtotal − discount) and submit
    Then submission is blocked with "Số tiền nợ không được lớn hơn tổng tiền hàng."

  Scenario: Loading a draft switches to update mode
    Given order N exists as a draft
    When I open GET /pos?id=N
    Then the terminal is pre-filled with order N's cart, customer, notes and totals
    And submitting issues a PUT to /orders/N

  Scenario: Locked debt on a done/locked order
    Given I load an order whose debt is already recorded (debt_locked)
    Then the debt input is disabled

  Scenario: Anonymous access is rejected
    Given I am not authenticated
    When I request GET /pos
    Then I am redirected to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Rationale |
|-------------|--------|-----------|
| Render the terminal behind auth as the landing page (RF-01, RF-02) | Must | The primary daily-use screen and app entry point. |
| Scan/add + line-key dedup + synchronous re-pricing + running totals (RF-06, RF-07, RF-10) | Must | The core selling loop; a POS is unusable without it. |
| Units payload with a guaranteed default (RF-05) | Must | Every priced line depends on a resolvable unit. |
| Debt rules and debt-input locking on submit (RF-11, RF-12) | Must | Financial guardrails mirrored server-side; must not regress. |
| Status-driven form submit to orders (RF-14) | Must | The only way a sale is persisted. |
| URL seeding for collaborators (RF-03) | Must | Without the URLs the engine cannot call scan/price/submit. |
| Draft preload + rehydration + PUT repoint (RF-04, RF-13) | Should | Important for resuming/editing orders, but a fresh sale works without it. |
| Customer lookup, quick-add and cart re-price on change (RF-09) | Should | Enables loyalty/wholesale pricing; retail sales work without a customer. |
| Unit-change merge semantics (RF-08) | Should | Convenience/correctness for multi-unit carts. |
| Unsaved-work guard + F2 hotkey (RF-15) | Could | Ergonomics; not on the transactional critical path. |
| Resource verbs create/store/show/edit/update/destroy | Won't | Empty stubs; do not implement. |

> Priority inferred from call frequency and position in the dependency chain (POS is the highest-traffic transactional screen; draft/customer features are important add-ons; ergonomics are peripheral).

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `routes/web.php:81` | `$router->resource('/pos', 'PosController')` (only `index` used) | 🟢 |
| `routes/web.php:22` | `Route::redirect('/', '/pos', 301)` — POS as landing | 🟢 |
| `app/Http/Controllers/PosController.php:18-687` | `PosController::index` (header, URLs, draft preload, script injection, view render) | 🟢 |
| `app/Http/Controllers/PosController.php:25-29` | Admin URL construction (scan / customer / draft / order / customer-detail) | 🟢 |
| `app/Http/Controllers/PosController.php:31-41` | Draft preload via `?id=` + units-payload replacement (null-guarded) | 🟢 |
| `app/Http/Controllers/PosController.php:40-679` | Injected client cart engine (add/update/unit-change/customer/draft/submit) | 🟢 |
| `app/Http/Controllers/PosController.php:786-810` | `buildUnitsPayload` (base unit + ProductUnits + default guarantee) | 🟢 |
| `app/Http/Controllers/PosController.php:694-753` | Empty resource stubs (out of scope) | 🟢 |
| `app/Models/Product.php:90` (via `products-pricing`) | `getPriceByCustomerType` (pricing source, called synchronously) | 🟢 |
| `_reversa_sdd/flowcharts/pos.md` | Verified control-flow of `index`, `scan` and the client cart | 🟢 |
