# User Stories

## README

# User Stories — TinyPOS (`tnx-pos`)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

This directory is the **behavioural view** of the system: what each actor is trying to achieve, told as user stories with `Given / When / Then` acceptance criteria. It is a global artifact — it re-tells the same facts already captured per-endpoint in the 23 unit folders (`requirements.md` / `design.md` / `tasks.md` / `contracts.md`) and in `openapi/tnx-pos.yaml`, but organised by **journey** instead of by route. Every story traces back to the unit(s) that own the mechanics, and confidence markers carry through from those units.

These stories describe the **legacy system as it behaves today** — they are reverse-engineered, not aspirational. Where the current behaviour is a defect or an open question, it is recorded as a 🔴/🟡 note on the story rather than silently corrected.

---

## Actors

| Actor | Kind | Role in the stories | Confidence |
|-------|------|---------------------|------------|
| **Store Administrator** (cashier / owner) | Person | The **only** system user. Operates the POS terminal and the entire back-office. There is a single Encore\Admin `Administrator` role and authorization is authenticate-only — every authenticated admin can do everything. | 🟢 (`permissions.md`, ADR-0009) |
| **Shopper / Customer** | Person (indirect) | Physically shops and is served at the terminal by the Administrator. Has **no login**; exists only as a `customers` CRM record used for pricing tier, loyalty points and debt. Never an authenticated actor in any story. | 🟢 (`c4-context.md`) |
| **Scheduler / Cron** | System | Runs `Order::summaryLogging` nightly to rebuild the `customer_order_summary` read model that the statistics stories read. | 🟢 (`app/Console/Kernel.php`, ADR-0005) |
| **Operator** (deploy-time) | Person | The same human as the Administrator, acting to run database migrations over HTTP (`GET /artisan`). Called out separately because the intent is maintenance, not shop operation. | 🟡 (`maintenance.md`) |

> Because access control collapses to "authenticated admin, or not" (🟢, ADR-0009), the stories deliberately do **not** invent role-based variants. Every story below is performed by the one Administrator persona unless it explicitly names the Scheduler.

---

## Story map (journeys → unit coverage)

| Journey file | What it covers | Owning units |
|--------------|----------------|--------------|
| [authentication.md](authentication.md) | Sign in, sign out, change own password | `auth` |
| [point-of-sale.md](point-of-sale.md) | The daily checkout: scan, build cart, pick customer, price by tier, discount, partial debt, park as draft or finalise, print receipt, resume a draft | `pos-terminal`, `pos-scan`, `products-pricing`, `customers-scan`, `orders-crud`, `orders-scan`, `orders-print` |
| [order-management.md](order-management.md) | Back-office order list, detail, re-print, note editing, deletion with reversals | `orders-crud`, `orders-print`, `orders-note`, `orders-scan` |
| [product-catalog.md](product-catalog.md) | Product master data: create/edit/list/soft-delete, multi-unit conversions, wholesale tiers, expiry filter | `products-catalog`, `products-pricing` |
| [customer-management.md](customer-management.md) | CRM: create/edit/list/soft-delete customers, POS quick-add, purchase history, per-customer statistics | `customers-crud`, `customers-scan`, `customers-purchase-history`, `customers-statistics` |
| [accounts-receivable.md](accounts-receivable.md) | Debt: debtor overview, per-customer ledger, record manual debt, record repayment | `debts`, `customers-debt-actions` |
| [loyalty-and-gifts.md](loyalty-and-gifts.md) | Points-for-gifts: gift catalogue, availability pre-flight, redemption, redemption history | `gifts-crud`, `gifts-scan`, `customers-loyalty` |
| [reference-data.md](reference-data.md) | Brands, categories, measurement units admin | `brands`, `categories`, `units` |
| [reporting.md](reporting.md) | Home dashboard KPIs and sales chart | `dashboard` |
| [maintenance.md](maintenance.md) | Run database migrations over HTTP | `artisan-migrate` |

All 23 units are represented. `customers-statistics` appears in both customer-management (the screen) and is fed by the Scheduler journey noted above.

---

## How to read a story

Each story uses:

```
### US-<area>-<n> — <title>

**As a** <actor>, **I want** <capability>, **so that** <business value>.

- **Given** <precondition>
- **When** <action>
- **Then** <observable outcome>   🟢/🟡/🔴

Notes / gaps: ...
Traces to: <unit>/ (<file/route>)
```

The `Traces to` line is the contract: for the authoritative field-level rules, validation, and error strings, open the referenced unit folder. Vietnamese UI strings are reproduced verbatim where they are part of the observable contract (toastr messages, validation errors); all surrounding prose is in English.


## accounts-receivable

# User Stories — Accounts Receivable (Debt)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator.
**Owning units:** `debts` (`GET /debts`), `customers-debt-actions` (per-customer ledger + writes).

Debt is a customer accounts-receivable ledger: an append-only `customer_debts` table plus a running `debt_total` on the customer, kept in lockstep. Four entry types share the ledger — `pos_debt` (posted at checkout, see [point-of-sale.md](point-of-sale.md)), `manual_debt`, `repayment`, and `debt_void` (written when a debt-bearing order is deleted, see [order-management.md](order-management.md)). This journey covers the two staff-initiated writes and the read screens. 🟢

---

### US-DEBT-1 — See who owes money

**As a** Store Administrator, **I want** a list of customers with an outstanding balance, **so that** I can chase repayments.

- **Given** the debts overview
- **When** I open `GET /debts`
- **Then** it lists customers with `debt_total > 0`, ordered by `debt_total` desc, paginated 30/page, balances read **live** off `customers.debt_total` 🟢
- **When** I search `q`
- **Then** a **grouped** closure matches phone OR fullname while staying ANDed with `debt_total > 0` (the correct pattern) 🟢

Notes / gaps:
- Read-only screen — all mutations delegate to `customers-debt-actions`. 🟢
- The modal customer picker sources any live customer via `/customers/scan` (not scoped to debtors), so a repayment can be started for a non-debtor and is only rejected downstream. 🟡

Traces to: `debts/` (`DebtController::index`)

---

### US-DEBT-2 — Inspect one customer's ledger

**As a** Store Administrator, **I want** to open a customer's debt history, **so that** I can see every charge and payment.

- **Given** a customer
- **When** I open `GET /customers/{customer}/debt`
- **Then** the **live** `debt_total` shows, plus `CustomerDebt::with('order')` ordered id desc, 20/page — each entry snapshots `balance_after`, and `pos_debt` rows show the order `code` (falling back to the note / "đã xóa" when the order was hard-deleted) 🟢

Traces to: `customers-debt-actions/` (`debt`)

---

### US-DEBT-3 — Record a manual debt

**As a** Store Administrator, **I want** to add a debt outside a sale, **so that** I can record credit extended off-terminal.

- **Given** a customer and an amount
- **When** I submit `POST /customers/{customer}/debts` (`amount` required|numeric|min:0.01, `note` nullable)
- **Then** `CustomerDebt::record` opens a transaction, locks the customer row, adds `amount` to `debt_total`, writes a `manual_debt` ledger row with `balance_after`, stamps `created_by`, and redirects back with a toastr 🟢

Notes / gaps:
- `record()` is called **bare** here (manual_debt never takes the throwing over-balance branch) — intentional asymmetry with repayment. 🟢
- No upper bound / no confirmation step — a mistyped large debt is only reversible via a repayment or `debt_void`. 🟡
- `amount` min:0.01 admits sub-0.1 values that round to 0.0 under `decimal(15,1)` storage. 🟡

Traces to: `customers-debt-actions/` (`storeDebt`, `CustomerDebt::record`)

---

### US-DEBT-4 — Record a repayment

**As a** Store Administrator, **I want** to record a customer paying down their balance, **so that** the ledger reflects the payment.

- **Given** a customer with a balance
- **When** I submit `POST /customers/{customer}/repayments` (`amount` required|numeric|min:0.01)
- **Then** `record` subtracts from `debt_total`, writes a `repayment` row with `balance_after`, and redirects back 🟢
- **Given** the amount exceeds `debt_total`
- **When** `record` throws
- **Then** `storeRepayment` catches it and returns back-with-errors "Số tiền thu nợ vượt quá số dư nợ hiện tại (<balance> ₫)" 🟢

Notes / gaps:
- Concurrency is correctly handled (`lockForUpdate` + transaction, no overdraw race). 🟢 (not a gap)
- `created_by` is an unconstrained `unsignedInteger` with no FK to `admin_users`. 🟡
- No observability on this money-mutating path beyond the ledger row itself. 🔴

Traces to: `customers-debt-actions/` (`storeRepayment`, `CustomerDebt::record`)


## authentication

# User Stories — Authentication

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator (the only system user).
**Owning unit:** `auth` (`Admin::registerAuthRoutes()` — login / logout / password-setting).

Authentication is the single gate into TinyPOS. Every other journey assumes an authenticated admin session; there is no customer-facing login. Access control across the whole application is authenticate-only — RBAC tables are seeded but never enforced at the route layer (🟢, ADR-0009, `permissions.md`).

---

### US-AUTH-1 — Sign in to the back-office

**As a** Store Administrator, **I want** to sign in with my username and password, **so that** I can operate the POS terminal and back-office.

- **Given** I am unauthenticated and open any protected URL
- **When** the `['web','admin']` middleware finds no `admin` guard session
- **Then** I am redirected to `GET auth/login` 🟢
- **Given** I am on the login page
- **When** I submit a valid **username** (not email) and password to `POST auth/login`
- **Then** a session is created, the session id is regenerated, and I land on the app — effectively `/pos`, because root `/` 301-redirects to the terminal 🟢
- **Given** I submit wrong credentials
- **When** authentication fails
- **Then** I am returned to the login form with a **generic** failure message that does not reveal whether the username exists (no account enumeration) 🟢

Notes / gaps:
- Identity is **username**, not email. 🟢
- Passwords are bcrypt-hashed and only re-hashed on change. 🟢
- There is **no brute-force protection, rate limiting, or failed-login logging**. 🔴 (flagged in `auth`)
- Whether login errors should be an HTML redirect vs a JSON 422 is an unresolved architecture decision. 🔴

Traces to: `auth/` (`routes/web.php:20`, Encore\Admin `AuthController::postLogin`)

---

### US-AUTH-2 — Sign out

**As a** Store Administrator, **I want** to sign out, **so that** the terminal cannot be used by someone else after I leave.

- **Given** I have an active session
- **When** I request `GET auth/logout`
- **Then** my session is invalidated and I am returned to the login page 🟢

Traces to: `auth/` (Encore\Admin `AuthController::getLogout`)

---

### US-AUTH-3 — Change my own password / profile

**As a** Store Administrator, **I want** to edit my own profile and password, **so that** I can keep my credentials current.

- **Given** I am authenticated
- **When** I open `GET auth/setting` and submit changes via `PUT auth/setting`
- **Then** my profile is updated; the password is re-hashed **only when it is actually changed** 🟢

Notes / gaps:
- The framework's full user-management auth stack (`App\User`) is intentionally **dead** — `App\User` is missing and the app authenticates via `admin_users`. Do **not** reimplement the framework user model. 🟢
- The default seeded credential is `admin` / `admin` — flagged for change before production. 🔴

Traces to: `auth/` (Encore\Admin `AuthController::getSetting` / `putSetting`)


## customer-management

# User Stories — Customer Management (CRM)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator.
**Owning units:** `customers-crud`, `customers-scan`, `customers-purchase-history`, `customers-statistics`.

Customers are CRM records — never accounts (no login). A customer carries a pricing tier (`type`), a live loyalty `points` balance, and a live `debt_total`. **Phone** (not id) is the business identity used for search, POS selection, and lookups. 🟢

---

### US-CUST-1 — Browse and search customers

**As a** Store Administrator, **I want** to list and search customers, **so that** I can find a shopper's record.

- **Given** the customer list
- **When** I open `GET /customers`
- **Then** it orders by id desc, paginates 30/page; `q` matches phone (exact) OR fullname (`LIKE %q%`); an optional `month` filter selects by birthday month 🟢

Notes / gaps:
- The `q` search is a grouped closure, so `month` stays ANDed with the phone/fullname match. ✅ Fixed 2026-09-21 (was ungrouped, letting a phone match escape the `month` filter). 🟢

Traces to: `customers-crud/` (`index`)

---

### US-CUST-2 — Register a new customer

**As a** Store Administrator, **I want** to create a customer, **so that** I can attach them to sales and track points/debt.

- **Given** the create form (or the POS quick-add)
- **When** I submit `POST /customers` (validating fullname / phone numeric+live-unique / email / address / gender / birthday `d/m/yyyy` / dependant)
- **Then** the customer is created; birthday is parsed into `birthday2` (nulled silently on a parse failure); if the request `expectsJson()` (POS quick-add) the created customer returns as JSON, otherwise a web redirect + toastr 🟢

Notes / gaps:
- `type` is **not** accepted at create — every new/quick-add customer defaults to `khach_le` until edited. 🔴 (confirm intended)
- An unparseable birthday is silently nulled with no user feedback. 🟡
- The create breadcrumb is mislabeled "Sản phẩm"/Product. 🟡

Traces to: `customers-crud/` (`store`, content-negotiated)

---

### US-CUST-3 — Edit a customer and set their price tier

**As a** Store Administrator, **I want** to edit a customer and assign a wholesale tier, **so that** they get the right prices at checkout.

- **Given** an existing customer
- **When** I submit `PUT /customers/{id}` (phone as string, `type` nullable|in `khach_le`/`si_1`/`si_2`)
- **Then** the record updates; **this is the only action that sets the tier** 🟢

Notes / gaps:
- `update` does **not** re-derive `birthday2`, so an edited birthday goes stale. 🟡
- Phone rule differs create (numeric) vs update (string); update gender lacks `nullable`. 🟡

Traces to: `customers-crud/` (`edit` / `update`)

---

### US-CUST-4 — Soft-delete a customer

**As a** Store Administrator, **I want** to remove a customer, **so that** obsolete records stop appearing while history survives.

- **Given** a customer
- **When** I request `DELETE /customers/{id}`
- **Then** it is soft-deleted (no rename side effect, unlike products) with a `{ status, message }` JSON response 🟢

Traces to: `customers-crud/` (`destroy`)

---

### US-CUST-5 — Autocomplete a customer at the terminal (system-facing)

**As the** POS terminal, **I want** to search customers as the cashier types, **so that** an existing shopper can be attached quickly.

- **Given** a typed query
- **When** the client calls `GET /customers/scan?q=<text>`
- **Then** it returns up to 10 customers matching phone OR fullname (`LIKE %q%`) as a JSON **array** (empty ⇒ `[]`), each with the appended `type` + `type_label` so the client binds identity and tier in one round-trip 🟢

Notes / gaps:
- 🟢 **Verified not a bug:** although the two `LIKE`s are ungrouped, Laravel's `Builder::callScope()` isolates the `SoftDeletes` global scope's `deleted_at IS NULL` into its own nested `AND` group, so a soft-deleted customer cannot leak via either branch (confirmed against the framework source). Only hand-written filters need explicit grouping.
- Empty/missing `q` becomes `LIKE '%%'`, returning the first 10 live rows rather than `[]` — contract unconfirmed. 🔴
- The `else json(null, 204)` branch is dead code (a Collection is always truthy). 🟡

Traces to: `customers-scan/` (`CustomerController::scan`)

---

### US-CUST-6 — Review a customer's purchase history

**As a** Store Administrator, **I want** to see everything a customer has bought, **so that** I can answer questions and understand their spend.

- **Given** a customer
- **When** I open `GET /customers/{customer}/orders`
- **Then** it lists BOTH draft and done orders newest-first (20/page), with an optional category filter (`whereHas('products')` EXISTS), and a **lifetime spend total** computed as a separate unfiltered `SUM(total)` 🟢

Notes / gaps:
- The header total ignores the category filter, so a filtered page's total can exceed the visible rows. 🟡
- The dropdown lists only **child** categories (`whereNotNull('parent_id')`), matching the product forms. ✅ Fixed 2026-09-21 (was every category including parents, so selecting a parent yielded an empty dead-end list). 🟢
- Per-row `debt_locked` can trigger up to 20 queries/page (N+1) if the view reads it. 🟡

Traces to: `customers-purchase-history/` (`CustomerController::orders`)

---

### US-CUST-7 — Review a customer's statistics summary

**As a** Store Administrator, **I want** a quick per-customer purchasing summary, **so that** I can gauge their value and category mix.

- **Given** a customer
- **When** I open `GET /customers/{customer}/statistic`
- **Then** headline totals (orders_count / amount_total / points_total) come from the **nightly** `customer_order_summary` read model, while `debt_total` and current `points` are read **live** off the customer 🟢
- **And** category breakdowns split milk (id 1) and medicine (id 8) into their own buckets, aggregating the rest as "other" 🟢
- **And** the annotated-orders panel lists only orders with a note (`whereNotNull('notes')`, 10/page), each editable inline via `PUT /orders/{id}/note` 🟢

Notes / gaps:
- Numbers are stale up to ~24h; the only freshness cue is `summary.updated_at`. 🟡
- A null summary row (job not yet run) renders a `"chưa cập nhật"` placeholder instead of a date, distinguishing it from a genuine all-zero customer with a real summary. ✅ Corrected 2026-09-23 (was flagged as indistinguishable — contradicted the page's own RF-07). Residual: a customer with an existing-but-stale summary (a silently-failed nightly refresh) shows an old date, not a prominent warning. 🟢
- Medicine category id 8 is a code-marked placeholder — confirm the production id. 🟡

Traces to: `customers-statistics/` (`CustomerController::statis`), fed by `Order::summaryLogging` (Scheduler, ADR-0005)


## loyalty-and-gifts

# User Stories — Loyalty & Gifts

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator (acting for the shopper).
**Owning units:** `gifts-crud` (`resource settings/gifts`), `gifts-scan` (`GET settings/gifts/scan`), `customers-loyalty` (check-gift / redeem-points / gift-received).

Points are **earned** on `done` orders ([point-of-sale.md](point-of-sale.md)) and **spent** only here, by redeeming them for gifts. There is **no points ledger** (contrast the debt ledger) — the only redemption trail is the `customer_gift` pivot plus the `used`/`points` deltas. Redemption is atomic and gated on stock, per-customer limit, and points balance. 🟢

---

### US-GIFT-1 — Maintain the gift catalogue

**As a** Store Administrator, **I want** to manage the gifts customers can redeem, **so that** the loyalty programme stays current.

- **Given** the gifts admin (`settings/gifts`)
- **When** I create/edit a gift (name required; image; `points` cost; per-customer `limit`, 0 = unlimited; stock `quantity`; `active` switch)
- **Then** it saves via the Encore\Admin ModelForm; a `saving` hook zero-defaults points/limit/quantity and forces `used=0` for a new gift; a `saved` hook resizes the uploaded image to 300×300 🟢
- **And** on edit, `quantity` cannot drop below `used` ("Số lượng không được bé hơn tổng đã dùng") 🟢
- **And** deleting a gift **soft-deletes** it (redemption history in `customer_gift` survives) 🟢

Notes / gaps:
- Gifts have **no `code` column** (unlike products). 🟢
- Out-of-stock uses a strict `=== 0` test, so a negative `quantity_available` slips through. 🟡

Traces to: `gifts-crud/` (`GiftController` form/hooks)

---

### US-GIFT-2 — Pick a gift by name (system-facing)

**As the** redemption picker, **I want** to search active gifts by name, **so that** the Administrator can choose one to redeem.

- **Given** a typed query
- **When** the client calls `GET settings/gifts/scan?q=<text>`
- **Then** it returns `{ data: [ ≤10 gifts ] }` (a `{data:[]}` envelope) matching name `LIKE %q%`, each with the appended `quantity_available` + image URL 🟢

Notes / gaps:
- Empty/missing `q` becomes `LIKE '%%'`, returning the first 10 live gifts rather than `[]` — contract unconfirmed. 🔴
- No availability filtering by default at the endpoint level, but mitigated in practice: the redeem-points picker always passes `?active=1` and greys out/disables (`pointer-events: none`) any out-of-stock option client-side (`customer-statis.blade.php:328,338`, verified 2026-09-23). The authoritative gate is still `checkGiftAvailable`. 🟢
- Envelope shape differs from the other scan endpoints (`customers-scan` bare array, `pos-scan` discriminated union). 🟡

Traces to: `gifts-scan/` (`GiftController::scan`)

---

### US-GIFT-3 — Check gift availability before redeeming

**As a** Store Administrator, **I want** an advisory availability check, **so that** I don't attempt a redemption that will fail.

- **Given** a customer and a gift
- **When** I call `GET /customers/{customer}/check-gift`
- **Then** it returns `{ status: true }` when redeemable, or `{ status:false, message }` when the gate blocks (missing/out-of-stock gift; prior redemptions ≥ `limit` when `limit ≠ 0`; `gift.points > customer.points`) 🟢

Notes / gaps:
- Stateless pre-flight — no writes, no stock reservation; authority lives in the redeem re-check, so its verdict can drift. 🟡
- An unresolved/inactive `gift_id` returns a clean `{status:false, message:'Quà tặng không khả dụng'}` instead of crashing. ✅ Fixed 2026-09-21 (a null-guard was added after `Gift::active()->find()`; previously fell through into `checkGiftAvailable(Gift $gift)` and threw a TypeError → 500). 🟢 (`customers-loyalty`)

Traces to: `customers-loyalty/` (`checkGift`, `Customer::checkGiftAvailable`)

---

### US-GIFT-4 — Redeem points for a gift

**As a** Store Administrator, **I want** to redeem a customer's points for a gift, **so that** the shopper receives their reward and the balance is debited.

- **Given** a customer with enough points and an available active gift
- **When** I submit `POST /customers/{id}/redeem-points`
- **Then** in a transaction with `lockForUpdate` on **both** customer and gift plus an under-lock re-check, the gift pivot is attached (snapshotting `points` cost + note), `gift.used` is incremented, and `customer.points` is decremented — all rolled back together on any throw 🟢

Notes / gaps:
- This is the **only** place `customer.points` is spent. 🟢
- Concurrency is correctly handled (atomic since 2026-09-17, ADR-0007) — no oversell/overspend. 🟢 (not a gap)
- The pivot snapshots the cost paid, so historical redemptions survive a gift reprice. 🟢
- No observability on the redemption path. 🔴

Traces to: `customers-loyalty/` (`redeemRewardPoints`)

---

### US-GIFT-5 — Review a customer's redeemed gifts

**As a** Store Administrator, **I want** to see the gifts a customer has redeemed, **so that** I can confirm past rewards.

- **Given** a customer
- **When** I open `GET /customers/{customer}/gift-received`
- **Then** the redeemed-gifts history page renders from the `customer_gift` pivot 🟢

Notes / gaps:
- The `?q=` search matches gift `name` via a grouped closure, scoped to the current customer. ✅ Fixed 2026-09-21 (was `where('code', ...)` — gifts have no `code` column, so any search raised an unknown-column SQL error — combined with an ungrouped `orWhere('name', ...)` that could surface other customers' gifts). 🟢 (`customers-loyalty`)
- The pivot has no `withTimestamps`, so the redemption date isn't reliably captured (the view shows the gift's `created_at`). 🟡

Traces to: `customers-loyalty/` (`getListGiftReceived`)


## maintenance

# User Stories — Maintenance

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Operator (the Administrator, acting at deploy time).
**Owning unit:** `artisan-migrate` (`GET /artisan`).

This journey covers a single privileged, side-effecting maintenance endpoint that runs the app's database migrations over HTTP. It was **not** documented by Scout/Archaeologist — it was found during plan reconstruction (2026-09-19) and added as a unit precisely because it is a privileged, schema-mutating endpoint. It is 🟡 that this is intended as an operator workaround for an environment with no shell access. 🟢/🟡

---

### US-MAINT-1 — Apply pending database migrations over HTTP

**As an** Operator with no shell access, **I want** to trigger the app's migrations from a URL, **so that** I can apply schema changes after a deploy.

- **Given** I am an authenticated admin
- **When** I open `GET /artisan`
- **Then** the closure runs `Artisan::call('migrate', ['--force' => true])`, applies every pending migration and records it in the migrations ledger (idempotent by migration — nothing pending applies nothing), and always returns the static HTML "Migrating completed<br>" (200) 🟢

Notes / gaps:
- **Side-effecting GET behind authentication only:** a browser prefetch, crawler, link-preview, or shared URL with a live admin session can run production migrations — no CSRF, no confirmation. Reviewed with the project owner 2026-09-21 — accepted as a known risk, kept as legacy behaviour (no change scheduled). 🟢
- **Outcome not reported:** the captured `$exitCode` is discarded (`dd($exitCode)` is commented out) and there is no `try/catch`, so a non-zero exit is indistinguishable from success and a thrown migration surfaces as a raw 500 — no audit of who/when/result. Reviewed and accepted as-is, 2026-09-21. 🟢
- No transaction wrapper → partial-migration risk on failure. 🟡
- Authenticate-only, no permission/role (ADR-0009); `--force` removes the production safety prompt (required for a no-TTY web trigger). 🟡
- The closure route can't be `route:cache`'d and isn't isolated for unit testing. 🟡

Traces to: `artisan-migrate/` (`routes/web.php:31-37`)

---

> The improvement tasks recorded in the unit (move off GET / add CSRF / signed URL; add observability + real outcome reporting; add authorization) require **human sign-off** — they change legacy behaviour and are not part of documenting it. As of 2026-09-21 the project owner declined them and chose to keep the endpoint as-is. 🔴


## order-management

# User Stories — Order Management (Back-office)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator.
**Owning units:** `orders-crud` (index/show/update/destroy), `orders-print`, `orders-note`, `orders-scan`.

Order creation itself happens at the terminal ([point-of-sale.md](point-of-sale.md)); this journey covers the back-office order list and the actions taken on an existing order. Orders have **no soft-delete** — deletion is permanent but reverses side effects first. The `create` and `edit` verbs are empty stubs. 🟢

---

### US-ORD-1 — Browse and search orders

**As a** Store Administrator, **I want** to list and search orders, **so that** I can find a specific sale.

- **Given** the order list
- **When** I open `GET /orders`
- **Then** orders load `with('customer')` ordered by `updated_at` desc, paginated 30/page 🟢
- **When** I enter a `q`
- **Then** it matches order id, order code (`#QT78-{id}`), or customer phone; an optional `status` filter narrows draft/done 🟢

Notes / gaps:
- The `q` filter is a grouped closure, so `status` stays ANDed with the id/code/phone match. ✅ Fixed 2026-09-21 (was ungrouped, letting an id/code match escape the `status` filter). 🟢 (`orders-crud`)

Traces to: `orders-crud/` (`index`)

---

### US-ORD-2 — View an order's detail

**As a** Store Administrator, **I want** to open a single order, **so that** I can inspect its lines and totals.

- **Given** a valid order id
- **When** I open `GET /orders/{id}`
- **Then** `findOrFail` loads it into `pages.orders-detail` (unknown id ⇒ 404) 🟢

Traces to: `orders-crud/` (`show`)

---

### US-ORD-3 — Edit / finalise within the 24h window

**As a** Store Administrator, **I want** to edit a recent order, **so that** I can correct a mistake shortly after the sale.

- **Given** an order that is `draft`, or `done` and within 24h of `updated_at`
- **When** I submit `PUT /orders/{id}`
- **Then** the totalling engine re-runs (points still award only once via `points_awarded_at`; debt stays frozen if `debt_locked`) 🟢
- **Given** a `done` order older than 24h
- **Then** the update is refused with "Không thể cập nhật đơn hàng hoàn thành quá 24h" 🟢
- **Given** a non-draft order
- **Then** the customer cannot be changed (immutable after draft) 🟢

Traces to: `orders-crud/` (`update`, `Order::is_editable`)

---

### US-ORD-4 — Annotate an order with a note

**As a** Store Administrator, **I want** to add/edit a free-text note on an order, **so that** it appears in the customer's annotated-orders panel and I can record context.

- **Given** any order
- **When** I submit the inline note form (`PUT /orders/{order}/note`, `notes` nullable|string)
- **Then** only `orders.notes` is written (bounded by `$fillable`), a toastr "Cập nhật thành công" shows, and I'm redirected back 🟢
- **And** the order now surfaces in the customer-statistics "annotated orders" panel (`whereNotNull('notes')`) 🟢

Notes / gaps:
- The note write has **no editability/status guard** — a note is editable indefinitely, unlike order edits. 🟡 (confirm intended; notes = free metadata)
- The failure branch is effectively unreachable (no halting save event). 🟡
- Any admin can edit any order's note (no per-record authorization, ADR-0009). 🟡

Traces to: `orders-note/` (`OrderController::updateNote`)

---

### US-ORD-5 — Re-print an order's receipt

**As a** Store Administrator, **I want** to re-print a past order, **so that** I can hand a shopper a duplicate receipt.

- **Given** an order id
- **When** I open `GET /orders/{order}/print` (e.g. via the `?ref=orders` link on the list)
- **Then** the 80mm receipt renders 🟢

Notes / gaps:
- Unknown id → clean `404` (`Order::findOrFail`). ✅ Fixed 2026-09-21 (was `Order::find`, a fatal 500). 🟢 (`orders-print`)

Traces to: `orders-print/` (`OrderController::printOrder`)

---

### US-ORD-6 — Delete an order and reverse its effects

**As a** Store Administrator, **I want** to delete an order, **so that** an erroneous or cancelled sale is removed and its financial effects undone.

- **Given** an order (possibly with points and/or `pos_debt`)
- **When** I request `DELETE /orders/{id}`
- **Then** in a single transaction the earned points are reversed (clamped ≥ 0), any `pos_debt` is voided (`debt_void`, clamped by current balance), and the order is **hard-deleted**; `order_product` cascades and `customer_debts.order_id` is set null 🟢
- **And** the response is `{ status, message }` JSON; an unknown id returns `{ status:false }` (uses `find`, not `findOrFail`) rather than 404 🟢

Notes / gaps:
- The retained `debt_void` trail after a hard delete is confirmed as designed (GAP-O1, team-confirmed). 🟢
- Concurrency and reversal are correct here (transactional, `lockForUpdate`). 🟢 (not a gap)

Traces to: `orders-crud/` (`destroy`), `customers-debt-actions/` (`CustomerDebt::voidForOrder`), `customers-loyalty/` (`reversePointsForOrder`)


## point-of-sale

# User Stories — Point of Sale (Checkout)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator (cashier).
**Owning units:** `pos-terminal`, `pos-scan`, `products-pricing`, `customers-scan`, `orders-crud` (store/update), `orders-scan`, `orders-print`.

The POS terminal (`GET /pos`) is the daily-use surface and the app's effective landing page (root `/` 301→`/pos`). The server action is thin — it renders `pages.pos` and injects a ~630-line in-browser cart engine; the cart's behaviour is owned by the client and confirmed against the live UI. Finalising an order is where a cart becomes a durable financial record (points + debt), and that lives in `orders-crud`. 🟢

---

### US-POS-1 — Open the terminal

**As a** cashier, **I want** the terminal to open ready to sell, **so that** I can start ringing up items immediately.

- **Given** I am authenticated
- **When** I open `/` or `/pos`
- **Then** the cashier screen loads (header "Bán hàng"), an empty cart, and the scan box focused, wired to the collaborator endpoints (`/pos/scan`, `/customers/scan`, `/orders/scan?status=draft`) 🟢

Traces to: `pos-terminal/` (`PosController::index`, `routes/web.php:22,81`)

---

### US-POS-2 — Add a product by scanning a barcode

**As a** cashier, **I want** to scan a barcode and have the exact product added, **so that** checkout is fast and unambiguous.

- **Given** the cursor is in the scan box
- **When** I scan/enter a code and the client calls `GET /pos/scan?q=<code>`
- **Then** an exact `code` match returns `{ is_barcode: true, data: <product> }` and the product is added as a cart line 🟢
- **Given** the scanned line already exists (same `code` + selling unit)
- **When** I scan it again
- **Then** the existing line's quantity increments rather than adding a duplicate line (line key = `code + '__' + (unit_id || 'base')`) 🟢

Notes / gaps:
- Soft-deleted products are excluded from lookups. 🟢
- The lookup response is a **discriminated union** on `is_barcode` (object for a barcode hit, array for a name search) — consumers must branch. 🟢

Traces to: `pos-scan/` (`PosController::scan`), `pos-terminal/` (client cart engine)

---

### US-POS-3 — Find a product by name when the barcode misses

**As a** cashier, **I want** to type part of a product name and pick from matches, **so that** I can sell items whose barcode won't scan.

- **Given** no exact `code` match
- **When** the client falls back to the name search
- **Then** `GET /pos/scan?q=<text>` returns `{ is_barcode: false, data: [ ≤10 products ] }` (empty ⇒ `data: []`) and I choose one to add 🟢

Notes / gaps:
- Leading-wildcard `LIKE '%q%'` can't use an index — degrades at catalogue scale. 🟡
- Empty/missing `q` behaviour (`LIKE '%%'`) is incidental and unconfirmed. 🔴

Traces to: `pos-scan/` (`PosController::scan` name fallback)

---

### US-POS-4 — Sell in a different unit of measure

**As a** cashier, **I want** to change a line's selling unit (e.g. box vs. can), **so that** the price reflects how the customer is buying.

- **Given** a product with conversion units
- **When** I change the line's unit
- **Then** the units payload always offers the base unit first (`unit_id` null, `conversion_qty` 1) plus each `ProductUnit`, exactly one marked default 🟢
- **And** the line is re-priced synchronously via `GET /products/get-price`, dividing the base price by the unit's `conversion_qty` (guarded `> 0`) 🟢

Notes / gaps:
- Pricing is a **synchronous per-line** call (`async:false`) — it blocks the UI briefly per line. 🟡

Traces to: `products-pricing/` (`ProductController::getPriceByCustomerType`), `pos-terminal/`

---

### US-POS-5 — Attach a customer and apply their price tier

**As a** cashier, **I want** to attach an existing customer to the sale, **so that** the correct wholesale/retail prices and their loyalty account apply.

- **Given** I type in the customer search box
- **When** the client calls `GET /customers/scan?q=<text>`
- **Then** I get up to 10 matching customers (phone or name) and pick one; the client keys the selection by **phone**, not id 🟢
- **Given** a customer is attached
- **When** their tier is `si_1` / `si_2`
- **Then** the **whole cart re-prices** for that tier (`wholesale_prices[type]`, falling back to `sale_price` when the tier has no configured price) 🟢
- **Given** no customer is selected
- **Then** lines price at retail (`khach_le`) 🟢

Notes / gaps:
- Which parameter the live client actually sends to get-price (`id` vs `phone`) needs confirmation — the client remaps select2 value to phone. 🟡

Traces to: `customers-scan/` (`CustomerController::scan`), `products-pricing/`, `pos-terminal/`

---

### US-POS-6 — Quick-add a new customer mid-sale

**As a** cashier, **I want** to create a customer without leaving the terminal, **so that** a first-time shopper can be recorded and served in one flow.

- **Given** the customer isn't found
- **When** I use the quick-add modal (which posts to `POST /customers` with `Accept: application/json`)
- **Then** the new customer is created and returned as JSON, then attached to the cart 🟢

Notes / gaps:
- A quick-added customer is always `khach_le` (tier can't be set at create time — only on edit). 🔴 (flagged in `customers-crud`)

Traces to: `customers-crud/` (`store`, content-negotiated), `pos-terminal/`

---

### US-POS-7 — Apply a discount and see running totals

**As a** cashier, **I want** to apply a discount and see the total update live, **so that** I can quote the shopper an accurate price.

- **Given** a cart with lines
- **When** I enter a `discount_amount`
- **Then** the running total shows `total = subtotal − discount_amount` (client-side; recomputed authoritatively on submit) 🟢

Notes / gaps:
- The free-form discount is **uncapped** against subtotal, so a total can go negative. 🟡 (flagged in `orders-crud`)
- The `discounts` table / `orders.discount_id` FK exist but no code applies a discount by id — only free-form `discount_amount`. 🔴 (GAP-O2)

Traces to: `pos-terminal/`, `orders-crud/` (totalling engine)

---

### US-POS-8 — Take partial payment on credit (debt)

**As a** cashier, **I want** to let a known customer owe part of the total, **so that** I can extend store credit at checkout.

- **Given** a cart total and an attached customer
- **When** I enter a `debt_amount > 0`
- **Then** the debt is accepted only if a customer is selected and `debt_amount ≤ total` 🟢
- **Given** no customer is attached
- **Then** the debt input is cleared/disabled (debt requires a customer) 🟢
- **Given** the order already posted debt once
- **Then** the debt input is **locked** (`debt_locked`) and further changes are ignored 🟢

Notes / gaps:
- Debt validation is duplicated client- and server-side; a single source of truth is desired. 🔴 (flagged in `pos-terminal`)
- On a `done` order with `debt_amount > 0`, exactly one `pos_debt` ledger row is posted and the customer's `debt_total` increases — see [accounts-receivable.md](accounts-receivable.md). 🟢

Traces to: `pos-terminal/`, `orders-crud/` (`store`, debt guard), `customers-debt-actions/` (ledger)

---

### US-POS-9 — Park the cart as a draft

**As a** cashier, **I want** to hold an unfinished cart, **so that** I can serve another shopper and come back to it.

- **Given** a cart in progress
- **When** I submit with status `draft` to `POST /orders`
- **Then** the order is saved as `draft` (no points awarded, no debt posted), a toastr confirms, and I return to `/pos` 🟢

Traces to: `orders-crud/` (`store`, draft path)

---

### US-POS-10 — Resume a parked draft

**As a** cashier, **I want** to reopen a saved draft, **so that** I can finish a held sale.

- **Given** parked drafts exist
- **When** the client calls `GET /orders/scan?status=draft`
- **Then** up to 10 newest drafts return as a JSON array, each with full order + customer + line pivots to repopulate the cart in one round-trip 🟢
- **When** I select one
- **Then** the terminal rehydrates the cart and repoints the form to `PUT /orders/{id}` 🟢

Notes / gaps:
- The unguarded `?id=` preload path (`Order::find`) is a flagged risk. 🔴 (in `pos-terminal`)
- A customer-less draft can't be found by a `q` search (the `whereHas('customer')` filter), only via the `status=draft` list. 🟡 (in `orders-scan`)

Traces to: `orders-scan/` (`OrderController::scan`), `pos-terminal/`, `orders-crud/` (`update`)

---

### US-POS-11 — Finalise the sale

**As a** cashier, **I want** to complete the sale, **so that** the money, points, and any debt are recorded.

- **Given** a cart (new or a resumed draft)
- **When** I submit with status `done` to `POST /orders` (or `PUT /orders/{id}`)
- **Then** each line is priced by tier+unit, `subtotal`/`total`/`earned_point` are computed, line prices are snapshotted, and the order is saved `done` 🟢
- **And** loyalty points are awarded to the customer **exactly once** (guarded by `points_awarded_at`, surviving `done → draft → done`) 🟢
- **And** any `debt_amount > 0` posts a single `pos_debt` ledger row and raises `debt_total` 🟢
- **And** the printable receipt (`pages.pos-print`) renders 🟢

Notes / gaps:
- Unknown product codes on submit are **silently skipped**. 🟢 (documented behaviour)
- `store`/`update` are **not** wrapped in a DB transaction (unlike `destroy`), so a mid-finalise failure can leave partial state. 🟡 (flagged in `orders-crud`)
- A `done` order can only be edited within 24h of `updated_at`; after that it is read-only ("Không thể cập nhật đơn hàng hoàn thành quá 24h"). 🟢

Traces to: `orders-crud/` (`store` / `update`, totalling + points + debt)

---

### US-POS-12 — Print / re-print the receipt

**As a** cashier, **I want** to print an 80mm receipt, **so that** the shopper gets a printed record.

- **Given** a finalised order
- **When** the done path renders, or I open `GET /orders/{order}/print`
- **Then** `pages.pos-print` renders the store block, order code (`#QT78-{id}`), date, an optional customer block (live current points), per-line unit label + `qty × price`, discount, total, and a `window.print()` control 🟢

Notes / gaps:
- `printOrder` uses `Order::findOrFail`: an unknown id returns a clean `404`. ✅ Fixed 2026-09-21 (was `Order::find`, which dereferenced null and fataled). 🟢 (flagged in `orders-print`)
- Per-line `Unit::find` in the print loop is an N+1. 🟡
- Any order prints, including a draft (no status guard). 🟡
- The customer block shows **live** current points, which may differ from the sale-time balance on a re-print. 🟡

Traces to: `orders-print/` (`OrderController::printOrder`), shared `pages.pos-print`


## product-catalog

# User Stories — Product Catalog

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator.
**Owning units:** `products-catalog` (`resource /products`), `products-pricing` (`GET /products/get-price`).

Products are the master data every other module reads (POS, pricing, orders, dashboard, statistics). A product carries a barcode, retail and wholesale prices, a base unit (by name) plus conversion units (by FK), a reward-point value, and expiry information. 🟢

---

### US-PROD-1 — List products with an expiry-aware filter

**As a** Store Administrator, **I want** to browse products and spot ones near or past expiry, **so that** I can manage stock proactively.

- **Given** the product list
- **When** I open `GET /products`
- **Then** it paginates 30/page; the `near` filter shows items expiring within the window (asc), `expired` shows past ones (desc), default is id desc (`near_expiry_days` from `Setting::get`, default 30) 🟢
- **When** I search `q`
- **Then** a grouped closure matches `code` OR `name` while staying AND-scoped to the active filter 🟢

Notes / gaps:
- Leading-wildcard `LIKE` on code/name can't use an index. 🟡

Traces to: `products-catalog/` (`index`)

---

### US-PROD-2 — Create a product

**As a** Store Administrator, **I want** to add a product with prices and units, **so that** it can be sold at the terminal.

- **Given** the create form
- **When** I fill it in (only **child** categories are selectable — `whereNotNull('parent_id')`; wholesale prices carry only `si_1`/`si_2`) and submit `POST /products`
- **Then** the product is created; `code` auto-generates as `P{category_id}{6-digit pad(max(id)+1)}` when blank, and `sale_price` defaults to `price` 🟢
- **And** `code` is unique among **live** rows only (deleted SKUs' codes are reusable) 🟢

Notes / gaps:
- `generateCode` (`max(id)+1`) is not concurrency-safe. 🟡
- `save()` + `syncProductUnits` are **not** wrapped in a transaction, so a mid-sync failure can drop a product's units after the delete-all. 🟡

Traces to: `products-catalog/` (`store`, `Product::generateCode`, `syncProductUnits`)

---

### US-PROD-3 — Define alternative selling units (conversions)

**As a** Store Administrator, **I want** to define conversion units (e.g. box = 12 cans), **so that** the terminal can sell in either unit at the right price.

- **Given** the product form's units table
- **When** I save
- **Then** `syncProductUnits` **fully replaces** the product's `product_units`: deletes existing rows, keeps only rows with a truthy `unit_id` AND `conversion_qty > 0`, marks the first as default, and bulk-inserts 🟢
- **And** at sale time price = `basePrice / conversion_qty` for a non-base unit (guarded `> 0`) 🟢

Traces to: `products-catalog/` (`syncProductUnits`), `products-pricing/` (`getPriceByCustomerType`)

---

### US-PROD-4 — Edit a product

**As a** Store Administrator, **I want** to update a product's details and prices, **so that** the catalogue stays current.

- **Given** an existing product
- **When** I submit `PUT /products/{id}`
- **Then** validation re-runs **minus** the code rule (barcode is effectively immutable via update) 🟢

Notes / gaps:
- Picture cleanup unlinks from the **wrong path** (`storage_path('app/public2')`), so old images are never deleted (failure swallowed/logged). 🔴 (GAP-P1; confirm whether `public2` was intentional)

Traces to: `products-catalog/` (`update`)

---

### US-PROD-5 — Soft-delete a product

**As a** Store Administrator, **I want** to remove a product from sale, **so that** it stops appearing while history is preserved.

- **Given** a product
- **When** I request `DELETE /products/{id}`
- **Then** it is **soft-deleted**, the `deleted` hook prefixes its name with "(DELETED) ", and a `{ status, message }` JSON response returns; it disappears from POS/pricing lookups 🟢

Traces to: `products-catalog/` (`destroy`, `Product::boot`)

---

### US-PROD-6 — Resolve a line price for a customer + unit (system-facing)

**As the** POS terminal (on the cashier's behalf), **I want** the effective unit price for a product/customer/unit, **so that** each cart line prices correctly.

- **Given** a `product_id` (required)
- **When** the client calls `GET /products/get-price` with optional customer (`id` then `phone` override) and `unit_id`
- **Then** it returns a **bare JSON number**: base price = `wholesale_prices[type]` when the tier has a configured entry, else `sale_price`; divided by `conversion_qty` for a non-base unit 🟢
- **Given** any failure (missing product, unknown customer id, etc.)
- **Then** it collapses to `response()->json($message, 400)` (a bare JSON string) 🟢

Notes / gaps:
- The error contract is **coarse** — every failure is a raw 400 and "Product not found" is reused for a missing param. 🔴
- A bad customer `id` aborts the whole call (400) instead of degrading to retail — asymmetric with the forgiving `phone` path. 🟡
- No observability on this per-scan hot path. 🔴

Traces to: `products-pricing/` (`getPriceByCustomerType`)


## reference-data

# User Stories — Reference Data (Brands / Categories / Units)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator.
**Owning units:** `brands` (`resource settings/brand`), `categories` (`resource settings/categories`), `units` (`resource settings/units`).

These three are thin Encore\Admin `ModelForm` scaffolds for product reference data. Each renders a two-column screen (a read grid on the left, an inline create/edit widget on the right) and exposes just `name` + optional `description`. Grids disable create-button/export/selector/filter/pagination, and **delete is disabled on all rows** because each is a required FK target. 🟢

---

### US-REF-1 — Manage product brands

**As a** Store Administrator, **I want** to maintain the list of brands, **so that** products can be labelled by manufacturer.

- **Given** the brands admin ("Nhãn hiệu")
- **When** I add or edit a brand (`name` required, `description`)
- **Then** it saves via the ModelForm; the default brand (**id 1**) is edit-locked and no brand is deletable (products' `brand_id` is a required FK) 🟢

Traces to: `brands/` (`BrandController`)

---

### US-REF-2 — Manage product categories

**As a** Store Administrator, **I want** to maintain categories, **so that** products can be classified and reported.

- **Given** the categories admin ("Danh mục")
- **When** I add or edit a category (`name` required, `description`)
- **Then** it saves; delete is disabled on all rows, and — unlike brands/units — **every row is editable** (the id-1 edit-lock is commented out) 🟢

Notes / gaps:
- `parent_id` is **never settable through the UI** (absent from both the widget form and `form()`), so a UI-created category keeps `parent_id` null and can **never** appear in the product dropdown (which lists only `whereNotNull('parent_id')` children). Hierarchy must be seeded directly in the DB. 🔴 (GAP-C1; intentional pre-provisioning vs unfinished feature unconfirmed)
- Category ids **1** (milk) and **8** (medicine) are hard-coded in the statistics rollup — a re-seed with different ids silently breaks the milk/medicine breakdowns. 🟡
- The commented-out id-1 lock (GAP-C2): any category, including ids 1/8, is freely renamable. 🔴

Traces to: `categories/` (`CategoryController`); consumers `products-catalog/`, `customers-statistics/`

---

### US-REF-3 — Manage measurement units

**As a** Store Administrator, **I want** to maintain units of measure, **so that** products can be sold in base and conversion units.

- **Given** the units admin ("Đơn vị")
- **When** I add or edit a unit (`name` required, `description`)
- **Then** it saves; the default unit (**id 1**) is edit-locked (the lock is active here, unlike categories) and no unit is deletable 🟢
- **And** units are consumed three ways: the product base-unit **string** dropdown (`products.unit`), the conversion table `product_units.unit_id` (FK), and order/receipt line labels via `Unit::find(pivot.unit_id)->name` 🟢

Notes / gaps:
- `units.category_id` is a **dead** column no code reads or writes. 🟡 (GAP-U1)
- Base-unit-by-name vs conversion-by-id inconsistency: renaming a unit doesn't propagate to products that captured the old name string. 🟡 (GAP-U2)
- Per-line `Unit::find` on receipts is an N+1 in print loops (in the consuming blades). 🟡

Traces to: `units/` (`UnitController`); consumers `products-catalog/`, `orders-crud/`, `orders-print/`

---

> **Cross-cutting note (all three):** the UI locks (no create button, delete disabled, id-1 edit-lock) are **grid-display gates, not server-side authorization** — the underlying resource routes still exist and are only authenticate-guarded (ADR-0009). Grids are unpaginated (`disablePagination`) — fine for short lists, unbounded as they grow. No observability on any reference-data mutation. 🟡/🔴


## reporting

# User Stories — Reporting (Dashboard)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

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

**Actor:** Store Administrator.
**Owning unit:** `dashboard` (`GET /dashboard`).

The dashboard is a back-office KPI + sales-chart screen. It is **not** the landing page — root `/` 301-redirects to `/pos`, so the dashboard is reached only by navigating to it. Per-customer statistics live under [customer-management.md](customer-management.md); this journey is the store-wide dashboard. 🟢

---

### US-RPT-1 — See store KPIs at a glance

**As a** Store Administrator, **I want** headline numbers on one screen, **so that** I can gauge the shop's activity.

- **Given** the dashboard
- **When** I open `GET /dashboard`
- **Then** three KPIs render: total products (`Product::count`), total customers (`Customer::count`), and **today's `done` orders** (`DATE(created_at) = CURDATE()`) 🟢

Notes / gaps:
- Products/customers are total row counts; only the orders KPI is day-scoped. 🟢
- Dashboard latency at scale is unconfirmed (multiple aggregate queries). 🔴

Traces to: `dashboard/` (`DashboardController::index`)

---

### US-RPT-2 — See a sales-volume chart over time

**As a** Store Administrator, **I want** a chart of order volume by period, **so that** I can spot trends.

- **Given** the dashboard
- **When** I choose a `range` of `day` | `week` | `month` (default `month`)
- **Then** a chart renders order **counts** (`COUNT(*)` of orders, not revenue/units) bucketed by the range, with empty buckets shown as 0 and labels/values aligned 🟢
- **Given** `range=day`
- **Then** the day view uses three store-open 6h windows — Sáng / Trưa / Chiều (06–12 / 12–18 / 18–24) — **excluding** 00:00–06:00 (store closed overnight; confirmed intentional) 🟢

Notes / gaps:
- An out-of-whitelist `range` value falls back to `month`. ✅ Fixed 2026-09-19 (was previously undefined — a `switch` with no matching `case`, broken query). 🟢
- The chart metric is order **count**, not revenue — confirm this is the intended KPI. 🔴
- Carbon 1.25 in-place mutation in the day `CASE` is correct but fragile. 🟢

Traces to: `dashboard/` (`DashboardController::index`, raw SQL `CASE` bucketing)

---

### US-RPT-3 — See the top-selling products

**As a** Store Administrator, **I want** the best-selling products, **so that** I know what to restock.

- **Given** the dashboard
- **When** it loads
- **Then** the top-10 products by `SUM(order_product.qty)` desc render, honouring `products.deleted_at` (soft-deleted products excluded) 🟢

Traces to: `dashboard/` (`DashboardController::index`)

---

> **Related — nightly rebuild (Scheduler):** the per-customer statistics screen ([customer-management.md](customer-management.md), US-CUST-7) reads a **nightly precomputed** `customer_order_summary` rebuilt by `Order::summaryLogging` on the daily scheduler (ADR-0005). That is a system journey with no Administrator action; it is documented here for completeness because it is the store's other reporting surface. A failed/absent nightly run is invisible on the statistics screen. 🔴
