# Orders-CRUD — Technical Design

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

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

## Interface

Laravel resource controller under the admin group (`['web','admin']`, empty admin prefix). Five verbs are live; `create` and `edit` are empty stubs. 🟢 (`routes/web.php:75`, `OrderController.php`)

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `/orders` | `q?`, `status?`, `page?` (query) | `text/html` — `pages.orders` | 200, 302 |
| POST | `/orders` | order payload (form) | redirect (`draft`) **or** `text/html` `pages.pos-print` (`done`) | 200/302, 422 (validation), 302 (debt-rule redirect) |
| GET | `/orders/{id}` | path id | `text/html` — `pages.orders-detail` | 200, 404, 302 |
| PUT/PATCH | `/orders/{id}` | order payload / `create_now_mode` (form) | redirect (`draft`) **or** `text/html` `pages.pos-print` (`done`) | 200/302, 404, 422, 302 |
| DELETE | `/orders/{id}` | path id | `application/json` `{status,message}` | 200, 302 |
| GET | `/orders/create` | — | empty (stub) | 200 (empty) |
| GET | `/orders/{id}/edit` | — | empty (stub) | 200 (empty) |

Controller symbols:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `index` | `(Request $request)` | view `pages.orders` | Read-only list + `q`/`status` filters. 🟢 (`:21-47`) |
| `store` | `(Request $request)` | redirect or view `pages.pos-print` | The create path + totalling engine + points/debt side-effects. 🟢 (`:64-165`) |
| `show` | `($id)` | view `pages.orders-detail` | `Order::findOrFail`. 🟢 (`:173-184`) |
| `update` | `($id, Request $request)` | redirect or view `pages.pos-print` | Edit / finalise; note the **`($id, Request)`** argument order (id first). 🟢 (`:204-337`) |
| `destroy` | `($id)` | JSON `{status,message}` | Transactional delete with reversals. 🟢 (`:362-383`) |
| `create` / `edit` | `()` / `($id, Request)` | `null` | Empty stubs — not used. 🟢 (`:53-56,192-195`) |

## The totalling engine (shared by `store` and `update`)

Both write paths compute an order identically; this is the domain core. 🟢 (`:92-119,261-288`)

```
count = 0; subtotal = 0; earned_point = 0
customerType = customer ? customer.type : ''
for each item in items:
    prod = Product::with('units')->code(item.code)->first()
    if not prod: continue                      # unknown code silently skipped
    count++                                     # counts LINES, not qty
    unitId = item.unit_id ? (int) item.unit_id : null
    if unitId:
        productUnit = prod.units()->where('unit_id', unitId)->first()
        if not productUnit: unitId = null       # fall back to base unit
    price = prod.getPriceByCustomerType(customerType, unitId)   # products-pricing
    subtotal += round(price, 1) * item.qty
    earned_point += round(prod.reward_point, 1) * item.qty
    line = [prod.id, {qty, price, unit_id: unitId,
                      conversion_qty: productUnit ? productUnit.conversion_qty : null}]
total = subtotal - discount_amount
paid  = total
```

The captured pivot `price` and `conversion_qty` freeze the sale-time economics of each line even if the product is later re-priced. 🟢 (`:108-114,277-283`; `data-dictionary.md:233,236`)

## Main Flow — `store` (POST /orders) 🟢 (`:64-165`)

1. Validate the payload (see `contracts.md`); an empty `items` fails with "Không thể tạo đơn hàng rỗng". 🟢 (`:66-78`)
2. Instantiate `Order` with `count/subtotal/total/earned_point = 0`, `status = valided.status ?: 'draft'`. 🟢 (`:80-87`)
3. Resolve the customer by phone: `customer = Customer::code($phone)->first()` (phone is the identity key). 🟢 (`:89-90`)
4. Run the totalling engine over `items`. 🟢 (`:92-117`)
5. `total = subtotal − discount_amount`; `paid = total`. 🟢 (`:118-119`)
6. `debtAmount = round(debt_amount ?? 0, 1)`. **Debt guard:** if `debtAmount > 0` and no customer → `redirect(pos.index)` + error (early return, nothing saved); if `debtAmount > total` → `redirect(pos.index)` + error. Else set `order.debt_amount = debtAmount`. 🟢 (`:121-131`)
7. **Inside a `DB::transaction`** (✅ added 2026-09-25, was uncontained): attach customer & award points (`customer()->associate`, and if `status === 'done'`, `customer.points += earned_point`, `customer->save()`, `order.points_awarded_at = now()`); `order->save()` — **if save fails, return `false` immediately without attaching pivots** (✅ fixed 2026-09-25, was: pivots attached unconditionally even after a failed save); attach each `order_product` pivot; if `done` && `debtAmount > 0` && customer → `CustomerDebt::record(customer,'pos_debt',debtAmount,order.id,"<code> (Tổng đơn: <total> ₫)",adminId)`. Any failure rolls back the whole block atomically. 🟢 (`:135-160`)
8. Route the response: `draft` → toast "Lưu nháp thành công" + `redirect(pos.index)`; `done` → render `pages.pos-print`. On save failure → `redirect(pos.index)` + "Tạo đơn hàng thất bại.". 🟢 (`:163-175`)

## Main Flow — `update` (PUT /orders/{id}) 🟢 (`:204-337`)

1. Validate (`items` `required_without:create_now_mode`; per-item rules `sometimes`). 🟢 (`:206-218`)
2. `order = Order::findOrFail($id)` (404 if missing); capture `debtLocked = order->debt_locked`. 🟢 (`:220-221`)
3. **`create_now_mode`:** rebuild `valided` from the order's existing pivots (code/qty/unit_id) + its stored customer/notes/discount/debt and `status = request.status ?: order.status` — finalises a stored draft with no cart. 🟢 (`:223-240`)
4. **Edit window:** if `!order->is_editable` → `redirect back` + "Không thể cập nhật đơn hàng hoàn thành quá 24h". 🟢 (`:242-243`)
5. **Customer source:** `draft` reads `customer.phone` from the payload; otherwise keeps the order's current customer phone (customer immutable once finalised). Resolve `customer = Customer::code($phone)->first()`. 🟢 (`:245-251`)
6. Assign `status/notes/discount_amount`, reset counters, run the totalling engine (in-memory: `order->subtotal`/`total`/`earned_point` and the `$order_lines` array — no DB writes yet). 🟢 (`:265-299`)
7. `debtAmount = round(debt_amount ?? 0, 1)`. **If `!debtLocked`:** apply the same debt guard as `store` (customer required, `≤ total`) and set `order.debt_amount`. **If `debtLocked`:** leave `debt_amount` frozen (never re-post). ✅ Fixed 2026-09-25 — this guard now runs **before** any pivot mutation (was: after `products()->detach()`+re-attach, so a guard rejection left the line items mutated with the order unsaved). 🟢 (`:301-312`)
8. **Inside a `DB::transaction`** (✅ added 2026-09-25, was uncontained): `products()->detach()` and re-attach the recomputed pivots; attach customer; award points only if `status === 'done'` **and** `!order->points_awarded_at` (award-once); `order->save()`; if `!debtLocked && done && customer && debtAmount > 0` → `CustomerDebt::record(pos_debt)`. Any failure rolls back the whole block atomically. 🟢 (`:314-337`)
9. Route: `draft` → toast + `redirect(pos.index)`; `done` → `order->load('products')` + render `pages.pos-print`. 🟢 (`:339-350`)

## Main Flow — `destroy` (DELETE /orders/{id}) 🟢 (`:362-383`)

1. `order = Order::find($id)` (null-safe `find`, not `findOrFail`). 🟢 (`:364`)
2. `isSuccess = order && DB::transaction(fn => { reversePointsForOrder(order); voidForOrder(order, adminId); return order->delete(); })`. 🟢 (`:366-370`)
   - `Customer::reversePointsForOrder` subtracts `earned_point` from `customer.points` (clamped ≥ 0) only if `points_awarded_at` was set. 🟢 (`Customer.php:97-112`)
   - `CustomerDebt::voidForOrder` inserts a `debt_void` per un-voided `pos_debt` entry, reducing `debt_total` by `min(entry.amount, current balance)` and noting any shortfall for manual refund. 🟢 (`CustomerDebt.php:83-144`)
   - `order->delete()` is a **hard** delete (no SoftDeletes); `order_product` cascades and `customer_debts.order_id` is set null. 🟢 (`data-dictionary.md:203,230,174`)
3. Respond `{status:true, message:trans('admin.delete_succeeded')}` on success, else `{status:false, message:trans('admin.delete_failed')}`. 🟢 (`:372-382`)

## Main Flow — `index` (GET /orders) 🟢 (`:21-47`)

1. Set header "Đơn hàng"; base query `Order::with('customer')->orderBy('updated_at','desc')`. 🟢 (`:23-28`)
2. If `q` present: grouped closure `->where(fn => where('id',$q)->orWhere(CONCAT('#QT78-',id), $q)->orWhereHas('customer', phone LIKE %q%))` — ✅ Fixed 2026-09-21 (was ungrouped, letting an id/code match bypass `status`). 🟢 (`:31-39`)
3. If `status` present: `->where('status',$status)`. 🟢 (`:40-42`)
4. `->paginate(30)`; render `pages.orders` with `list`. The blade offers a status `<select>` (JS-redirect) and a `q` box, and each row links to `orders.show` and to the print route with `?ref=orders`. 🟢 (`:44-46`, `orders.blade.php`)

## Alternative Flows

- **Unknown product code (create/update):** the line is skipped; totals/points reflect only resolved products. 🟢 (`:96,265`)
- **`unit_id` not a real ProductUnit:** `unitId` nulled → base-unit price, `conversion_qty = null`. 🟢 (`:101-106`)
- **Walk-in (no customer):** no points, no debt possible; a `debt_amount > 0` submission is rejected by the debt guard. 🟢 (`:123,134`)
- **`done → draft → done` re-finalisation:** `debt_locked` and `points_awarded_at` guards ensure the debt and points post exactly once. 🟢 (`Order.php:57-67`, `:311`)
- **`show` unknown id:** `findOrFail` → 404. 🟢 (`:181`)
- **`destroy` unknown id:** `find` returns null → `isSuccess` false → `{status:false}` (no 404). 🟢 (`:364-382`)
- **Unauthenticated:** admin group middleware → `302 auth/login`. 🟢 (`routes/web.php:24-28`)

## Dependencies

- **`Product` + `products-pricing`** — `Product::code()` resolves lines; `getPriceByCustomerType($type,$unitId)` prices each. 🟢 (`:96,107`; `products-pricing`)
- **`Customer` + `customers-crud`** — `Customer::code($phone)` resolves the buyer; `customer.points`/`type` read and points written. 🟢 (`:90`; `customers-crud`)
- **`CustomerDebt` + `customers-debt-actions`** — `record('pos_debt')` posts the sale's credit; `voidForOrder` reverses it on delete. 🟢 (`CustomerDebt.php`)
- **`ProductUnit`** — resolves the chosen selling unit and its `conversion_qty` snapshot. 🟢 (`:102,271`)
- **`pos-terminal` / `pos-scan`** — the browser cart is the producer of the `store`/`update` payloads; those units are documented separately. 🟢 (`pos-terminal`)
- **Views** — `pages.orders` (list), `pages.orders-detail` (show), `pages.pos-print` (receipt, shared with `orders-print`). 🟢
- **`Admin::user()`** — supplies `created_by` for the ledger entries. 🟢 (`:150,368`)

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| Order finalisation is the architectural spine — one action prices lines, awards points, and posts debt | `store`/`update` call pricing + `points` + `CustomerDebt::record` inline | 🟢 (`:107,137,150`) |
| Points idempotency via `points_awarded_at` timestamp guard | `if ($request->status === 'done' && !$order->points_awarded_at)` | 🟢 (`:311`) |
| Debt idempotency via `debt_locked` (existence of a `pos_debt` row) | `getDebtLockedAttribute` + `if (!$debtLocked …)` | 🟢 (`Order.php:65-67`, `:296,321`) |
| Pivot captures sale-time `price`/`conversion_qty` (immune to later re-pricing) | `attach($prodId, ['price'=>$sale_price,'conversion_qty'=>…])` | 🟢 (`:109-114`) |
| Hard delete with explicit financial reversal inside a transaction | `DB::transaction(reversePoints + voidForOrder + delete)` | 🟢 (`:366-370`) |
| `create_now_mode` re-prices from stored pivots at current prices | rebuild items from `$order->products` pivots, re-run engine | 🟢 (`:223-240`) — confirmed intentional 2026-09-25 |
| Draft persistence: an order can be saved incomplete and finalised later | `status ?: 'draft'`; `create_now_mode` finalisation | 🟢 (`:86,223`) |

## Internal State

Persistent state owned by this unit: the `orders` row (`subtotal`, `total`, `discount_amount`, `earned_point`, `paid`, `debt_amount`, `points_awarded_at`, `count`, `status`, `notes`, `customer_id`) and its `order_product` pivot rows (`qty`, `price`, `unit_id`, `conversion_qty`). Two guard fields drive lifecycle idempotency: `points_awarded_at` (points-award once) and the derived `debt_locked` (debt-post once). Denormalised customer state (`points`, `debt_total`) is **written** by this unit but **owned** by `customers-*`. 🟢 (`data-dictionary.md:202-237`, `Order.php:12-67`)

## Observability

None. No log, metric, or trace on order creation, finalisation, point award, debt posting, or deletion — the highest-value financial mutations in the system produce no operational signal. A failed `CustomerDebt::record` (e.g. an over-balance throw, which cannot occur for `pos_debt` but could on any future change) inside the `store`/`update` transaction would silently roll back everything with no trace of why. 🔴 (`OrderController.php`, absence)

## Risks and Gaps

- ✅ **Fixed 2026-09-25: `store`/`update` are now transactional.** Points award, `order->save()`, pivot attach/detach, and `CustomerDebt::record` are wrapped in a single `DB::transaction` in both methods (matching `destroy`, which was already wrapped). A failure between steps now rolls back atomically instead of leaving partial state. Confirmed via `php artisan tinker` against the real app: a debt-guard-rejected `update()` call leaves pivots and `updated_at` byte-identical (guard now runs before any mutation — see below); the existing `OrderControllerTest` suite (5 tests) still passes. (`questions.md#question-11`)
- ✅ **Fixed 2026-09-25: pivots no longer attached after a failed `save()`.** In `store`, the attach loop now runs only when `$order->save()` returns truthy; a failed save returns `false` from the transaction closure immediately. (`:146-153`)
- ✅ **Fixed 2026-09-25: `update`'s debt guard now runs before pivot mutation.** Previously `products()->detach()`+re-attach ran unconditionally before the debt-guard check, so a guard rejection (a routine validation path, not a system error) still left the order's line items mutated while `order->save()` was never reached. The guard now runs immediately after totals are computed (in-memory) and before any DB write. (`:301-337`)
- ✅ **Index search grouping — Fixed 2026-09-21.** `q`'s `where/orWhere/orWhereHas` chain is now wrapped in a grouped closure, so `status` stays ANDed with the whole OR group (was: `id=q OR code=q OR (customerPhoneMatch AND status=?)`, letting an id/code match ignore `status`). Same class as the fixes in `customers-crud`/`products-catalog`/`customers-loyalty`. (`:31-39`)
- 🟢 **Confirmed intentional (2026-09-25): `create_now_mode` re-prices at current catalog prices**, discarding the stored pivot `price` — finalising an old draft can change its totals vs. when it was drafted. `questions.md#question-12` closed. (`:223-240,276`)
- 🟡 **Free-form `discount_amount`** has no upper bound relative to `subtotal`; a discount larger than subtotal drives `total` negative (the debt guard then rejects any positive debt). (`:82,118`)
- 🟡 **No per-record authorization.** Any authenticated admin can view, edit, finalise, or delete any order — authentication-only access control. (ADR-0009)
- 🔴 **No observability** on the create/finalise/debt/delete paths (see above).
- 🟢 **Deletion reversal and debt/points concurrency are correct** (not gaps) — `destroy` is transactional and `CustomerDebt::record`/`reversePointsForOrder`/`voidForOrder` all `lockForUpdate` the customer, preventing overdraw / double-count races.
