# Orders-CRUD — Requirements

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

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

## Overview

`orders-crud` is the order lifecycle back-office resource (`resource('/orders', 'OrderController')`) — the **architectural spine** of the system. It owns the order listing (`GET /orders`), order creation (`POST /orders`, the POS cart's submit target), the order detail page (`GET /orders/{id}`), order edit/finalisation (`PUT /orders/{id}`), and order deletion with reversals (`DELETE /orders/{id}`). `store`/`update` embed the **totalling engine** that prices each cart line, computes `subtotal`/`total`/`earned_point`, awards loyalty points, and posts customer debt — the single place where a sale becomes a durable financial record. 🟢 (`routes/web.php:75`, `OrderController.php:14-421`)

The `create` and `edit` verbs are **empty stubs** (`//`), so this unit exposes five live verbs: `index`, `store`, `show`, `update`, `destroy`. 🟢 (`OrderController.php:53-56,192-195`)

Three `/orders/*` routes are declared **before** the resource so they are not shadowed by its wildcard show route, and each belongs to a **separate unit**, out of scope here: `GET /orders/scan` (`orders-scan`), `GET /orders/{order}/print` (`orders-print`), and `PUT /orders/{order}/note` (`orders-note`). 🟢 (`routes/web.php:72-75`)

## Responsibilities

- **List orders** newest-activity-first (`Order::with('customer')->orderBy('updated_at','desc')`), with an optional `q` search (order id / `#QT78-{id}` code / customer phone) and an optional `status` filter (`draft`|`done`), paginated 30/page, rendered as `pages.orders`. 🟢 (`:21-47`)
- **Create an order** from a POS cart submission: validate the payload, price every line via the totalling engine, compute totals and earned points, enforce the debt rules, persist the order and its `order_product` pivots, conditionally award points and post a `pos_debt`, then redirect (draft) or render the printable receipt (done). 🟢 (`:64-165`)
- **Show an order's detail** (`Order::findOrFail($id)` → `pages.orders-detail`). 🟢 (`:173-184`)
- **Edit / finalise an order** (`PUT`): re-run the totalling engine (either on submitted items or, in `create_now_mode`, on the order's existing pivots), enforce the 24h edit window, apply or freeze the debt, award points at most once, and re-render / redirect. 🟢 (`:204-337`)
- **Delete an order** transactionally with full financial reversal: reverse awarded points, void the order's `pos_debt` ledger entries, then hard-delete the order (cascading pivots, nulling `customer_debts.order_id`). 🟢 (`:362-383`)

## Business Rules

- **Two-state lifecycle: `draft` ↔ `done`.** `Order::$status = ['draft','done']`; a new order defaults to `draft` when no status is submitted. A draft can be finalised to `done`, and a `done` order can be edited (including flipped back to `draft`) while still inside its edit window. 🟢 (`Order.php:16`, `OrderController.php:86,254`)
- **Human-facing code is derived, not stored.** The order id renders as `#QT78-{id}` via the appended `code` attribute (`Order::$prefix_id = '#QT78-'`). 🟢 (`Order.php:18,49-51`)
- **`count` is the number of lines, not summed quantity.** It increments once per successfully-resolved product line. 🟢 (`OrderController.php:97,266`; `flowcharts/orders.md:71`)
- **Unknown product codes are silently skipped.** Each line resolves `Product::with('units')->code($code)->first()`; a line whose code matches no live product contributes nothing to totals, points, or pivots and raises no error. 🟢 (`:96,265`)
- **Line pricing is customer-type- and unit-aware.** `price = $prod->getPriceByCustomerType($customerType, $unitId)` (owned by `products-pricing`); `subtotal += round(price,1) * qty`. The customer type comes from the attached customer (`''` for a walk-in). 🟢 (`:107-108,276-277`)
- **Unit resolution falls back to the base unit.** A submitted `unit_id` is honoured only if the product has a matching `ProductUnit`; otherwise `unit_id` is nulled (base unit) and `conversion_qty` is left null. 🟢 (`:99-106,268-275`)
- **Totals.** `total = subtotal − discount_amount` and `paid = total`; `discount_amount` is a free-form rounded amount (`round(discount_amount ?: 0, 1)`). 🟢 (`:82,118-119,256,287-288`)
- **Debt rules (POS credit).** `debt_amount = round(debt_amount ?? 0, 1)`. If `debt_amount > 0` there **must** be an attached customer; `debt_amount` must **not** exceed `total`. Violations abort with a redirect + error and no persistence. 🟢 (`:121-131,294-306`)
- **Points are awarded exactly once.** On finalisation (`status === 'done'`) with an attached customer, `customer.points += earned_point` and `order.points_awarded_at = now()`. `update` awards only when `points_awarded_at` is still null, so a `done → draft → done` cycle never double-awards. `earned_point = Σ round(reward_point,1) * qty`. 🟢 (`:136-140,311-315`; `Order.php` `points_awarded_at` guard)
- **Debt is posted exactly once (`debt_locked`).** A `pos_debt` ledger row is written via `CustomerDebt::record` only when the order is `done`, has a customer, and `debt_amount > 0`. `Order::debt_locked` (a `pos_debt` row already exists) freezes `debt_amount` on subsequent edits so the debt is never re-posted, even across a `done → draft → done` cycle. 🟢 (`:149-151,321-323`; `Order.php:65-67`)
- **24h edit window on finalised orders.** `is_editable` is true for any `draft`, or for a `done` order whose `updated_at` is within `Order::$limit_hours_editable` (24) hours. `update` refuses (`redirect back` + error) once a `done` order ages out. 🟢 (`Order.php:53-55`, `OrderController.php:242-243`)
- **Customer is immutable on a non-draft order.** `update` only reads `customer.phone` from the request while the order is still `draft`; on a `done` order it keeps the already-attached customer. 🟢 (`:245-249`)
- **`create_now_mode` finalises a stored draft without a cart.** It rebuilds the item list from the order's existing `order_product` pivots (code/qty/unit_id) and re-runs the engine — re-pricing at **current** catalog prices, not the stored pivot price. **Confirmed intentional** (2026-09-25, `questions.md#question-12`). 🟢 (`:223-240`)
- **Deletion is permanent and reverses finances.** `Order` has **no SoftDeletes**; `destroy` runs inside a `DB::transaction`: `Customer::reversePointsForOrder` (subtract awarded points, clamped ≥ 0), `CustomerDebt::voidForOrder` (insert `debt_void` entries, reduce `debt_total` by `min(entry, balance)`), then `order->delete()`. `order_product` cascades; `customer_debts.order_id` is set null. 🟢 (`:362-383`; `Customer.php:97-112`, `CustomerDebt.php:83-144`)
- **Walk-in orders are allowed.** `customer_id` is nullable; a `done` order without a customer simply earns no points and can carry no debt. 🟢 (`:134,308`; `data-dictionary.md:208`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | List orders, `updated_at` desc, eager-loading the customer, paginated 30/page | Must | `GET /orders` returns 200 with up to 30 rows, newest activity first, each showing code / customer / line count / total / status. 🟢 |
| RF-02 | Optional `q` search over order id, `#QT78-{id}` code, and customer phone | Should | `?q=#QT78-5` and `?q=5` both surface order 5; `?q=<phone>` surfaces that customer's orders. 🟢 |
| RF-03 | Optional `status` filter (`draft`\|`done`) | Should | `?status=done` lists only finalised orders. 🟢 |
| RF-04 | Create an order from a validated cart payload: price lines, compute totals + points, enforce debt rules, persist order + pivots | Must | A valid `POST /orders` with items persists an order whose `subtotal`/`total`/`earned_point`/`count` match the engine and whose `order_product` rows carry `qty`/`price`/`unit_id`/`conversion_qty`. 🟢 |
| RF-05 | Reject an empty cart | Must | `POST /orders` with no `items` returns a validation error "Không thể tạo đơn hàng rỗng". 🟢 |
| RF-06 | Enforce debt rules on create | Must | `debt_amount > 0` with no customer → redirect + "Phải chọn khách hàng khi có tiền nợ."; `debt_amount > total` → redirect + "Số tiền nợ không được lớn hơn tổng tiền hàng."; nothing persisted. 🟢 |
| RF-07 | Award points once on `done` with a customer | Must | A `done` order with a customer increments `customer.points` by `earned_point` and stamps `points_awarded_at`; re-finalising never re-awards. 🟢 |
| RF-08 | Post a `pos_debt` once on a `done` debt-bearing order | Must | A `done` order with a customer and `debt_amount > 0` writes exactly one `pos_debt` ledger row and raises `customer.debt_total`; a later edit does not re-post (`debt_locked`). 🟢 |
| RF-09 | Redirect draft to POS; render printable receipt on done | Should | A `draft` save toasts "Lưu nháp thành công" and redirects to `pos.index`; a `done` save renders `pages.pos-print`. 🟢 |
| RF-10 | Show an order's detail | Should | `GET /orders/{id}` renders `pages.orders-detail`; an unknown id returns 404. 🟢 |
| RF-11 | Edit/finalise within the 24h window; finalise a stored draft via `create_now_mode` | Must | `PUT /orders/{id}` recomputes totals and re-attaches pivots; a `done` order older than 24h is refused; `create_now_mode` finalises using the stored pivots. 🟢 |
| RF-12 | Delete an order with financial reversal | Must | `DELETE /orders/{id}` reverses awarded points, voids `pos_debt`, hard-deletes the order and its pivots, and returns `{status,message}` JSON. 🟢 |
| RF-13 | Require an authenticated admin session | Must | Anonymous request → `302` to `auth/login`. 🟢 (`routes/web.php:24-28`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | Admin authentication required (admin route group middleware `['web','admin']`) | `routes/web.php:24-28,75` | 🟢 |
| Consistency | Deletion reversal is atomic — points reversal, debt void, and delete run inside one `DB::transaction` | `OrderController.php:366-370` | 🟢 |
| Consistency | Debt/points mutations serialise concurrent writers via `lockForUpdate` inside `CustomerDebt::record` / `reversePointsForOrder` / `voidForOrder` | `CustomerDebt.php:41,94`, `Customer.php:104` | 🟢 |
| Consistency | ✅ Fixed 2026-09-25 — `store`/`update` wrap points/order-save/pivot-mutation/`pos_debt`-record in a `DB::transaction`; a mid-sequence failure now rolls back atomically | `OrderController.php:135-160,314-337` | 🟢 |
| Correctness | ✅ Fixed 2026-09-21 — `index`'s `q` search is now a grouped closure, so `status` stays ANDed with the id/code/phone OR (was an ungrouped-OR bug, same class as `customers-crud`/`products-catalog`/`customers-loyalty`) | `OrderController.php:31-39` | 🟢 |
| Performance | Order list bounded 30/page (`paginate(30)`); customer eager-loaded to avoid N+1 in the list | `OrderController.php:28,44` | 🟢 |
| Performance | `q` search over customer phone uses leading-wildcard `LIKE '%q%'` (no index) | `OrderController.php:36` | 🟡 |
| Observability | None — no log/metric/trace on order create, finalise, debt post, or delete | `OrderController.php` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Given an authenticated administrator and a POS cart with valid items and status "done" for a customer
When he submits POST /orders
Then an order is persisted with correct subtotal/total/earned_point/count, the order_product pivots are attached, the customer's points increase by earned_point, points_awarded_at is recorded, and the pos-print page is rendered

Given an authenticated administrator
When he submits POST /orders with no items
Then he receives a validation error "Không thể tạo đơn hàng rỗng" and no order is created

Given a cart with debt_amount > 0 and no customer selected
When POST /orders is submitted
Then the request is redirected to pos.index with the error "Phải chọn khách hàng khi có tiền nợ." and nothing is persisted

Given a "done" order with a customer and debt_amount > 0
When it is finalised
Then exactly one pos_debt row is recorded, customer.debt_total increases by debt_amount, and the order becomes debt_locked so a later edit does not record the debt again

Given a "done" order whose updated_at is more than 24 hours old
When PUT /orders/{id} is called
Then it receives a redirect back with the error "Không thể cập nhật đơn hàng hoàn thành quá 24h" and the order is not changed

Given a "done" order that awarded points and recorded a pos_debt
When DELETE /orders/{id} is called
Then, within a transaction, the awarded points are reversed (clamped ≥ 0), a debt_void entry is inserted reducing debt_total, the order and its pivots are permanently deleted, and a JSON {status:true} is returned

Given a request with no authenticated admin session
When any /orders verb is called
Then it receives HTTP 302 redirecting to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| Create order with totalling engine (RF-04) | Must | The core write path — a sale only exists once an order is persisted |
| Empty-cart rejection (RF-05) | Must | Guards the primary invariant (no empty orders) |
| Debt rules on create (RF-06) | Must | Money correctness — credit cannot exceed the sale or lack a debtor |
| Award points once (RF-07) | Must | Loyalty integrity; the idempotency guard is a hard requirement |
| Post `pos_debt` once (RF-08) | Must | A/R integrity; double-posting would corrupt `debt_total` |
| Edit/finalise + 24h window (RF-11) | Must | Finalising drafts and correcting recent orders is a daily operation |
| Delete with reversal (RF-12) | Must | A deletion that skipped reversals would leave dangling points/debt |
| List orders (RF-01) | Must | The operational entry point to every order |
| Show detail (RF-10) | Should | Inspection; also reachable from the list and print |
| `q` search / `status` filter (RF-02, RF-03) | Should | Convenience narrowing over a full list |
| Draft-vs-done routing (RF-09) | Should | UX: drafts return to POS, done prints a receipt |
| Admin authentication (RF-13) | Must | Enforced by the route group for the whole back office |

> Priority inferred from the unit's role as the transactional spine (order finalisation calls pricing, points, and debt) and its position at the centre of the dependency graph.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/OrderController.php:21-47` | `OrderController::index` (list + `q` + `status`) | 🟢 |
| `app/Http/Controllers/OrderController.php:64-165` | `OrderController::store` (create + totalling engine) | 🟢 |
| `app/Http/Controllers/OrderController.php:173-184` | `OrderController::show` | 🟢 |
| `app/Http/Controllers/OrderController.php:204-337` | `OrderController::update` (edit / finalise) | 🟢 |
| `app/Http/Controllers/OrderController.php:362-383` | `OrderController::destroy` (delete + reversals) | 🟢 |
| `app/Http/Controllers/OrderController.php:53-56,192-195` | `create` / `edit` empty stubs | 🟢 |
| `app/Models/Order.php:12-67` | `Order` (`$fillable`, `$appends`, `$status`, `$prefix_id`, `$limit_hours_editable`, `code`/`is_editable`/`debt_locked`, `products`/`customer`/`debts`) | 🟢 |
| `app/Models/Customer.php:97-112` | `Customer::reversePointsForOrder` | 🟢 |
| `app/Models/CustomerDebt.php:38-64,83-144` | `CustomerDebt::record` (`pos_debt`), `voidForOrder` | 🟢 |
| `routes/web.php:75` | `resource('/orders','OrderController')` (`orders.*`) | 🟢 |
| `resources/views/pages/orders.blade.php` | Order list table + status/`q` filter | 🟢 |
| `resources/views/pages/orders-detail.blade.php` | Order detail page (rendered by `show`) | 🟢 |
| `resources/views/pages/pos-print.blade.php` | Printable receipt (rendered by `store`/`update` on done) | 🟢 |
| `_reversa_sdd/flowcharts/orders.md` | `store` / `update` / `destroy` flow diagrams | 🟢 |
| `_reversa_sdd/data-dictionary.md:200-237` | `orders` + `order_product` schema | 🟢 |
