# Code Analysis — TinyPOS (`tnx-pos`)

> Produced by the Reversa **Archaeologist** (phase: excavation) · doc_level: `complete`
> Generated on 2026-09-16

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

> **Progress:** This is a consolidated, incrementally-built document. All **11 of 11** modules analyzed: **auth**, **dashboard**, **pos**, **products**, **customers**, **orders**, **debts**, **gifts**, **brands**, **categories**, **units**. The Archaeologist (excavation) phase is complete.

---

## Module: `auth`

**Purpose:** Authentication and authorization. 🟢

**Key finding — two auth stacks, only one live:**

- **Live stack — Encore\Admin (via `salipropham/laravel55-admin`).** `routes/web.php:20` calls `Admin::registerAuthRoutes()`, which registers the admin login/logout/password routes. The whole application route group runs under middleware `['web','admin']` with an empty URL prefix and namespace `App\Http\Controllers` (`config/admin.php:23-31`). The `admin` guard uses the session driver and the `Encore\Admin\Auth\Database\Administrator` model on table `admin_users` (`config/admin.php:50-64`). 🟢
- **Dead stack — Laravel framework scaffolding (confirmed intentional).** `app/Http/Controllers/Auth/{Login,Register,Forgot,Reset}Controller.php` are the stock Laravel 5.6 controllers. They (and `config/auth.php:71`) reference `App\User::class`, **which does not exist** in `app/` (there is no `app/User.php`). No `Auth::routes()` call exists in `routes/web.php`, so none of these are reachable. Confirmed with the team: this app is admin-only (Encore\Admin is the sole auth stack) and self-registration was never meant to work — this is dead scaffolding by design, not a bug. 🟢

**Control flow of note:**

- `RedirectIfAuthenticated::handle` redirects authenticated users to `/home` (`app/Http/Middleware/RedirectIfAuthenticated.php:16`). 🟢
- `HomeController::index` immediately `return redirect()->to('pos')`; the Encore Admin dashboard-builder block after it is unreachable dead code (`app/Http/Controllers/HomeController.php:12-40`). Combined with `Route::redirect('/', '/pos', 301)` (`routes/web.php:22`), the effective landing page is `/pos`. 🟢

**RBAC (data-driven, from `2016_01_04_173148_create_admin_tables.php`):** 🟢

- `admin_users` (Administrator): `id`, `username` (unique, 190), `password` (bcrypt, 60), `name`, `avatar?`, `remember_token?`.
- `admin_roles` (Role): `id`, `name` (unique, 50), `slug` (50).
- `admin_permissions` (Permission): `id`, `name` (unique, 50), `slug` (50), `http_method?`, `http_path?`.
- `admin_menu` (Menu): `id`, `parent_id` (default 0), `order` (default 0), `title` (50), `icon` (50), `uri?`.
- Pivots: `admin_role_users`, `admin_role_permissions`, `admin_user_permissions`, `admin_role_menu`; plus `admin_operation_log` (disabled — `operation_log.enable = false`). 🟢

**Gaps:**
- 🟡 The actual permission-enforcement logic lives in `vendor/` (Encore\Admin). The RBAC matrix must be read there by the Detective.

**Complexity:** medium.

---

## Module: `dashboard`

**Purpose:** Admin landing dashboard — KPI counts, a sales-volume chart bucketed by day/week/month, and a top-10 best-selling products list. 🟢

**Entry point:** `DashboardController::index` (`app/Http/Controllers/DashboardController.php:26`), rendering `pages.dashboard`. The controller is registered as a resource but only `index` is used. 🟢

**KPIs (`:33-37`):** 🟢
- `products` = `Product::count()`
- `customers` = `Customer::count()`
- `orders` = orders where `status = 'done'` AND `DATE(created_at) = CURDATE()` (today's completed orders).

**Sales chart — dynamic SQL bucketing (`:44-102`):** 🟢
The controller builds a raw SQL `CASE ... END AS range_date` expression in PHP, then runs `Order::select(DB::raw($query))->groupBy('range_date')->pluck('quantity','range_date')`. `range` defaults to `'month'`.
- **day:** buckets labelled `Sáng` / `Trưa` / `Chiều` (morning/noon/afternoon).
- **week:** one bucket per day from `startOfWeek` to `endOfWeek`.
- **month:** 5-day windows across the current month; labels like `01/09-06/09`; `yearRange` spans the covered year(s).

Results are pivoted into `labelChart` / `valueChart` arrays (`Arr::pluck`) for the view.

**Top products (`:106-111`):** raw query joining `order_product` to non-soft-deleted `products`, `SUM(order_product.qty)` desc, `limit 10`. 🟢

**Gaps / smells:**
- 🟢 **Day-range buckets reuse a mutating variable — works, but fragile.** The `day` `CASE`'s three `WHEN` branches all reuse `$startDay` (`:53-59`), which `Carbon::addHours(6)` mutates in place at each use (`nesbot/carbon: 1.25.*` per `composer.lock` — `add*()` mutates `$this` rather than returning an immutable copy). Since PHP evaluates `.` concatenation left-to-right, this actually yields three correct, non-overlapping 6h windows: `Sáng` 06:00–12:00, `Trưa` 12:00–18:00, `Chiều` 18:00–24:00. Verified by manual trace and confirmed with the team — not a bug. Still worth a defensive rewrite (explicit `clone`/separate variables per branch) since correctness currently depends on a non-obvious mutation side effect.
- 🟢 Orders created 00:00–06:00 fall outside all three buckets and are excluded from the "day" chart. Confirmed intentional with the team: the store is closed overnight and orders in that window are negligible, so no bucket is needed for it.
- 🟢 Unused/erroneous imports `App\Models\OrderProduct` (no such model — only an `order_product` table) and `Cassandra\Custom` (stray IDE import) — confirmed dead: neither class exists/is referenced anywhere else. Harmless under autoload-on-use.

**Complexity:** medium.

---

## Module: `pos`

**Purpose:** The cashier point-of-sale terminal. `PosController` renders the sales screen and serves product lookups; the actual cart lives client-side and order persistence is delegated to `OrderController` (the POS form posts to `/orders`). 🟢

**Endpoints & methods:** 🟢
- `GET /pos` → `PosController::index` (`:18`). Sets header `Bán hàng` (Sales), builds admin URLs (`pos/scan`, `customers/scan`, `orders/scan?status=draft`, `orders`, `customers.statis`), and — when `request()->id` is present — preloads an `Order` draft with `customer` + `products.units.unit`, replacing each product's `units` relation with a computed payload (`:31-38`). It then injects a ~630-line client script via `Admin::script($sc)` and returns `pages.pos`.
- `GET /pos/scan` → `PosController::scan` (`:54`). Exact `code` match → `{is_barcode:true, data:product}`; otherwise a `name LIKE '%q%'` search returns up to 10 results → `{is_barcode:false, data:[...]}`. Each product carries a `units` payload.
- `PosController::buildUnitsPayload(Product)` (`:79`). Always prepends a base unit (`unit_id:null`, `label:product->unit`, `conversion_qty:1`); appends each `ProductUnit` (`unit_id`, unit name, `conversion_qty`, `is_default`). If no `ProductUnit` is default, the base unit becomes default.
- `create/store/show/edit/update/destroy` are empty stubs (`:687-746`) — the resource verbs are unused. 🟢

**Pricing (source of truth for POS): `Product::getPriceByCustomerType($type, $unitId)` (`app/Models/Product.php:90`):** 🟢
```
basePrice = wholesale_prices[type]  (if type set and key exists)  else  sale_price
if unitId and productUnit.conversion_qty > 0:  return basePrice / conversion_qty
else:                                           return basePrice
```
The client fetches this synchronously via `GET /products/get-price?phone=<customer>&product_id=<id>&unit_id=<id>` (`ProductController::getPriceByCustomerType`).

**Client-side cart engine (injected JS, `:40-672`):** 🟢 (confirmed correct by the team against the live UI — see Gaps below)
- Barcode/autocomplete add (`addItem`/`addProductRow`), line dedup via a custom `Array.prototype.duplicate(lineKey)`.
- **Line key** = `code + '__' + (unit_id || 'base')`. Changing a line's unit (`.unit-select` handler, `:362-407`) merges into an existing same-product+unit line by **direct qty addition**; qty represents scan/add count and is preserved across unit changes (no physical conversion).
- Customer select2 ajax against `customers/scan`; on customer change every line is re-priced for the customer `type`.
- **Debt rules on submit (`:308-334`):** if `debt > 0`, a customer must be selected and `debt` must not exceed the current order total. The debt input is disabled/locked (server ignores it) when the order is `done` or already has a recorded `pos_debt` ledger entry (`debt_locked`, `:549-554`).
- Order-draft rehydration (`fillOrderDraft`, `:519-564`) repoints the form to `PUT /orders/{id}`; `F2` hotkey focuses the scanner; an `onbeforeunload` guard warns about unsaved work.

**Dependencies:** `products`, `orders`, `customers`, `ProductUnit`, and `Admin::script` (Encore\Admin).

**Gaps:**
- 🟢 The core POS logic is a large inline JS string, opaque to PHP-level static analysis alone — but the team manually walked the add/re-unit/submit flow against the live UI and confirmed it matches this doc (scan/add, unit-change merge, debt-lock rules).
- 🟡 Order creation/update (validation, totals, earned points, debt posting) lives in `OrderController` — covered in the `orders` module.

**Complexity:** high.

---

## Module: `products`

**Purpose:** Product catalogue CRUD, unit-conversion setup, and the price lookup that feeds the POS. `ProductController` is a standard Laravel resource controller; `Product` is a `SoftDeletes` Eloquent model. 🟢

**Endpoints & methods:** 🟢
- `GET /products` → `index` (`ProductController.php:23`). Reads `Setting::get('near_expiry_days', 30)`; filters on the `expiring` query param: `near` (`expiry_date` between today and today+N, asc), `expired` (`expiry_date < today`, desc), or default (order by `id` desc). A `q` param adds a `code LIKE %q% OR name LIKE %q%` search, then `paginate(30)`.
- `GET /products/get-price` → `getPriceByCustomerType` (`:273`). Resolves an optional customer from `id` or `phone`, reads `unit_id`, and returns `Product::getPriceByCustomerType($customerType, $unitId)` as JSON. This is the endpoint the POS cart calls synchronously.
- `GET /products/create` → `create` (`:62`); `POST /products` → `store` (`:87`); `GET /products/{id}/edit` → `edit` (`:143`); `PUT /products/{id}` → `update` (`:173`); `DELETE /products/{id}` → `destroy` (`:225`).
- `show` (`:132`) is an empty stub. 🟢

**Create/update forms** load `categories` (only those with a non-null `parent_id` — i.e. leaf/child categories), `brands`, `units` (both as `name=>name` and `id/name`), `Product::$attr` (weight options), and `Customer::$types` **minus `khach_le`** (retail) — so wholesale-price inputs are offered only for wholesale tiers `si_1`/`si_2`. 🟢

**`store` / `update` rules (`:90-124`, `:175-217`):** 🟢
- Validation: `name`, `price`, `unit`, `category_id` (exists), `brand_id` (exists) required; `code` unique among **non-deleted** rows (`unique:products,code,NULL,id,deleted_at,NULL`); `pictures` `mimes:jpeg,jpg,png,webp`; `wholesale_prices` an array; `expiry_date` `Y-m-d`.
- Empty `code` → `Product::generateCode($category_id)`; empty `sale_price` → falls back to `price`.
- Picture upload stored on the `public` disk via `storage_url($path)`. On update, the previous file is `unlink`-ed first, but the path is rebuilt as `storage_path('app/public2') . str_replace('storage/', DIRECTORY_SEPARATOR, $product->pictures)` — an odd `public2`/separator construction wrapped in `try/catch` that only `logger()`s failures. 🔴 (looks wrong; old images likely never actually deleted — confirm in interpretation)
- Both persist units through `syncProductUnits`.

**`syncProductUnits($product, $rows)` (`:241`):** 🟢 Hard-`delete()`s the product's existing `ProductUnit` rows, then filters incoming rows to those with `unit_id` truthy **and** `conversion_qty > 0`. At most one row becomes default — the **first** row flagged `is_default` wins (`$hasDefault` latch); the rest are forced non-default. Survivors are bulk-inserted with `ProductUnit::insert()` (manual `created_at`/`updated_at`; model events bypassed).

**Pricing — `Product::getPriceByCustomerType($type, $unitId)` (`Product.php:94`):** 🟢 (already summarised in `pos`) `wholesale_prices[$type]` when the type key exists, else `sale_price`; divided by the chosen unit's `conversion_qty` when `> 0`. `wholesale_prices` is stored as a JSON **string** and exposed as a decoded object via accessor/mutator (`:47-55`).

**`Product` model behaviour:** 🟢
- `boot()` hooks the `deleted` event to rename the row to `'(DELETED) ' . name` and re-save (`:35-38`) — so a soft-deleted product carries a visibly-marked name. `destroy` therefore both soft-deletes and renames.
- `generateCode($prefix)` = `'P' . $categoryId . str_pad(max(id)+1, 6, '0', LEFT)` (`:85`). 🟡 `max('id')+1` is not concurrency-safe and ignores soft-deleted rows in the prefix, though the unique index only covers live rows.
- Appended `is_expired` = `expiry_date < today` (`:42`).

**Gaps / smells:**
- ✅ **Fixed** (confirmed real bug, fixed by the team 2026-09-17): `index` search used to combine an `orWhere` with the `expiring` `whereDate` filters without a grouping closure, so a `near`/`expired` search's name branch escaped every expiry constraint (SQL `AND` binds tighter than `OR`). Now wrapped in a nested `where(fn ($query) ...)` closure so the `code`/`name` search stays properly `AND`-scoped to the expiry filter.
- 🔴 Picture-cleanup path on `update` (`:198`) is confirmed incorrect: `storage_path('app/public2')` doesn't match the `public` disk's actual root (`app/public`, per `config/filesystems.php`), so `unlink()` always fails silently (caught, only logged) and old product images are never actually deleted — orphaned files accumulate on disk. **Status: pending** — team wants to confirm with their lead whether `public2` was intentional (e.g. a different environment/legacy convention) before deciding to fix.
- 🟡 `generateCode` race condition under concurrent creates.

**Complexity:** medium.

---

## Module: `customers`

**Purpose:** Customer CRM — CRUD, POS quick-lookup/quick-add, purchase history and per-customer statistics, the reward-points/gift redemption flow, and the customer debt (accounts-receivable) ledger. `CustomerController` is a fat resource controller with many extra actions; `Customer` is a `SoftDeletes` model. 🟢

**Endpoints & methods (`routes/web.php:53-62`):** 🟢
- Resource `/customers`: `index` (`:27`), `create` (`:56`), `store` (`:73`), `edit` (`:124`), `update` (`:146`), `destroy` (`:175`). `show` (`:113`) is an empty stub.
- `GET /customers/scan` → `scan` (`:282`) — `phone`/`fullname LIKE`, `take(10)`, JSON. Feeds the POS customer select2.
- `GET /customers/{c}/orders` → `orders` (`:190`) — the customer's orders (optional `category` filter via `whereHas('products')`), plus `amountTotal = Order::sum('total')`.
- `GET /customers/{c}/statistic` → `statis` (`:213`) — reads the precomputed `CustomerOrderSummary` row.
- `GET /customers/{c}/debt` → `debt` (`:262`) — paginated `CustomerDebt` ledger + `debt_total`.
- `GET /customers/{c}/check-gift` → `checkGift` (`:340`); `GET /customers/{c}/gift-received` → `getListGiftReceived` (`:321`).
- `POST /customers/{id}/redeem-points` → `redeemRewardPoints` (`:295`).
- `POST /customers/{c}/debts` → `storeDebt` (`:354`); `POST /customers/{c}/repayments` → `storeRepayment` (`:376`).

**`store` (`:73`):** 🟢 Validates `fullname`, `phone` (`numeric`, unique among non-deleted), optional `email`/`address`/`gender` (`male|female|other`)/`dependant`, and `birthday` matched by a `d/m/yyyy` regex. `birthday` is parsed with `Carbon::createFromFormat('d/m/Y', …)` into `birthday2` (`Y-m-d`); on parse failure it **nulls `birthday`** (not `birthday2`) inside the `catch`. When the request `expectsJson` (POS quick-add), it returns the created customer as JSON. 🟢

**`statis` (`:213`) — precomputed statistics:** 🟢 Reads one `CustomerOrderSummary` row (nightly job, see below) and unpacks `categories_statistic` (JSON) into three buckets: milk (`Order::$category_milk_id = 1`), medicine (`Order::$category_medicine_id = 8`), and everything else aggregated by category with summed `qty_total`/`amount_total`. The whole unpack is wrapped in `try/catch` that degrades to empty collections. `debt_total`/`points_total` come from the summary row (`debt_total` from the live customer). 🟡 The page is only as fresh as the last nightly run.

**Reward points & gifts:** 🟢
- `Customer::checkGiftAvailable($gift)` (`Customer.php:64`) gates redemption on: gift exists and `quantity_available !== 0`; per-customer redemption count `< gift->limit` (unless `limit === 0` = unlimited); and `gift->points <= customer->points`.
- `redeemRewardPoints` (`:295`) runs the check, attaches the gift pivot (`points`, `note`), increments `gift->used`, and decrements `customer->points`. ✅ **Fixed** (2026-09-17): these three writes are now wrapped in `DB::transaction()` with `lockForUpdate()` on both `Customer` and `Gift`, plus a re-check of `checkGiftAvailable` under the lock — mirrors the `CustomerDebt::record` pattern elsewhere in the codebase.

**Debt ledger:** 🟢 `storeDebt` and `storeRepayment` both delegate to `CustomerDebt::record($customer, $type, $amount, …)` with `manual_debt` / `repayment`. `record` (`CustomerDebt.php:38`) locks the customer row (`lockForUpdate`) inside a transaction, rejects a repayment that exceeds `debt_total` (throws), adjusts `debt_total`, and writes a ledger row with `balance_after`. The lifecycle is shared with the POS (`pos_debt`) and order-deletion (`debt_void`) paths documented in `orders`.

**`Customer` model:** 🟢 `type` accessor defaults to `khach_le` when null (`:49`); appended `type_label` maps to `$types` (`khach_le`=Retail, `si_1`/`si_2`=Wholesale 1/2). `reversePointsForOrder` (`:97`) clamps points at `0` (`max(0, points - earned_point)`) under a row lock, guarded on `points_awarded_at` — used when an order is deleted.

**Gaps / smells:**
- 🟢 `scan` returns `response()->json(null, 204)` only when `$result` is falsy, but an Eloquent `Collection` is **always truthy** (empty or not) — the 204 branch is dead; an empty search always returns `[]` with 200 (`:288-292`). Confirmed harmless by the team — left as-is, not fixed.
- ✅ `redeemRewardPoints` non-atomic writes — fixed (see above).
- ✅ **Fixed** (2026-09-17): the `add_col_birthday2_to_customers_table` migration (`2022_05_09_092607_…`) had its `up()` body commented out, so a fresh `migrate:fresh` on a new environment would leave `birthday2` missing (SQL error on customer creation) even though the model reads/writes it. `up()` now creates `birthday2 string nullable`, matching `down()`.
- 🟡 `index` month-birthday view mutates the underlying query (`$list->getQuery()->orders = null`) to reorder by day — fragile internal-API use.

**Complexity:** high.

---

## Module: `orders`

**Purpose:** Order lifecycle — creation and update from the POS cart, listing/printing, point-earning, and the POS-debt posting that ties into the customer ledger. `OrderController` holds the pricing/totalling engine; the POS screen (`pos` module) is only the front end. `Order` has **no `SoftDeletes`** — deletion is permanent. 🟢

**Endpoints & methods (`routes/web.php:72-75`):** 🟢
- Resource `/orders`: `index` (`:21`), `store` (`:64`), `show` (`:173`), `update` (`:204`), `destroy` (`:362`). `create` (`:53`) and `edit` (`:192`) are empty stubs.
- `GET /orders/scan` → `scan` (`:402`) — search by customer `phone`/`fullname` + optional `status`, `take(10)` with `customer`+`products`. Feeds the POS "load draft" picker.
- `GET /orders/{o}/print` → `printOrder` (`:392`); `PUT /orders/{o}/note` → `updateNote` (`:340`).
- `index` (`:21`) searches by raw id, `#QT78-{id}` (via `CONCAT`), or customer phone, and filters by `status`.

**`store` — totalling engine (`:64`):** 🟢
1. Validates a non-empty `items` array (`items.*.code`, `items.*.qty >= 1`, optional `unit_id`), optional `customer.phone`, `discount_amount`/`debt_amount` (`>= 0`), `status` (`draft|done`).
2. Resolves the customer by phone (`Customer::code()` = match on `phone`).
3. Per line: finds the product by `code`; resolves `unit_id` to a real `ProductUnit` (nulls it if not found); `price = getPriceByCustomerType($customerType, $unitId)`; `subtotal += round(price,1) * qty`; `earned_point += round(reward_point,1) * qty`; `count++` (counts **lines**, not total quantity). Products with an unknown `code` are silently skipped.
4. `total = subtotal - discount_amount` (rounded to 1 dp); `paid = total`.
5. **Debt rules:** `debt_amount > 0` requires a customer, and `debt_amount` must not exceed `total`, else redirect back with an error. (Same rules the POS enforces client-side.)
6. Persists, then attaches `order_product` pivot rows (`qty`, `price`, `unit_id`, `conversion_qty`).
7. **Only when `request->status === 'done'`:** if a customer is attached, `customer->points += earned_point` and `points_awarded_at = now()`; if `debt_amount > 0`, `CustomerDebt::record(..., 'pos_debt', …)` posts the debt. `draft` saves silently and returns to POS; `done` renders the print view.

**`update` (`:204`):** 🟢 Mirrors `store`, plus:
- `create_now_mode`: rebuilds `items` from the order's existing pivot rows to finalise a stored draft without re-sending the cart.
- `is_editable` guard: rejects edits to a `done` order older than `Order::$limit_hours_editable = 24`h.
- `debt_locked` guard: if a `pos_debt` ledger entry already exists, the debt amount is frozen and never re-posted (handles `done → draft → done` re-finalisation so debt posts exactly once).
- Points are awarded only if `!points_awarded_at` (idempotent). Pivot rows are `detach()`-ed and re-attached.

**`destroy` (`:362`):** 🟢 Inside a DB transaction: `Customer::reversePointsForOrder` (claws back earned points, clamped ≥ 0), `CustomerDebt::voidForOrder` (writes `debt_void` reversal entries, clamped by current `debt_total`, flags any shortfall in the note), then hard-`delete()`. `order_product` FK cascades; `customer_debts.order_id` is set null (reversal entries keep the link via `related_debt_id`).

**`Order` model:** 🟢 appended `code` = `#QT78-{id}`; `is_editable` = draft, or done within 24h of `updated_at`; `debt_locked` = a `pos_debt` debt row exists. `summaryLogging()` (`:69`) is a large raw-SQL upsert into `customer_order_summary`, driven **daily** by the scheduler (`app/Console/Kernel.php:30-32`) and the `CustomerOrderSummaryLogging` artisan command — it aggregates order totals, points, and per-category/`attr_weight` JSON statistics (special-casing milk, medicine, and the "consumption" category ids `[34,41,42]` collapsed to `attr_weight='ALL'`).

**Gaps / smells:**
- 🟡 `store` and `update` duplicate ~60 lines of near-identical totalling logic — a prime consolidation target (Refactor phase).
- 🟡 Orders are hard-deleted; historical order rows vanish while their `debt_void` ledger trail persists — intentional per the void design, but worth confirming for reporting.
- 🟡 The `Discount` model / `orders.discount_id` FK exist but no code applies a discount by id; only the free-form `discount_amount` is used. Legacy/unused relation.

**Complexity:** high.

---

## Module: `debts`

**Purpose:** A read-only accounts-receivable overview screen — the list of customers who currently owe money. 🟢

**Routing & shape:** `routes/web.php:67` registers a single GET route `/debts → DebtController@index` (name `debts.index`); there is no resource route, so this module has exactly one endpoint. The sidebar menu item is seeded by migration `2026_08_28_000003_add_debt_menu_item.php` ("Công nợ", icon `fa-credit-card`, uri `/debts`, order 5). 🟢

**`DebtController::index` (`:10`):** 🟢
1. Sets the page header/breadcrumb to "Danh sách công nợ" (Debt list).
2. Base query: `Customer::where('debt_total', '>', 0)->orderBy('debt_total', 'desc')` — every customer with a positive running balance, largest debtor first.
3. Optional search: when `q` is present, it is wrapped in a **grouping closure** — `where(fn: phone LIKE %q% OR fullname LIKE %q%)` — so the OR stays AND-scoped to the `debt_total > 0` filter. This is the correct precedence pattern (contrast the historical `products.index` bug that was fixed 2026-09-17). 🟢
4. Paginated (`->paginate(30)`) and returned via `view('pages.debts', compact('customers'))`. 🟢

**Where the writes live:** This controller never mutates anything. The two action buttons on the page ("Thu nợ" / record repayment and "Ghi nợ tay" / record manual debt) post to the **customers** module endpoints `POST /customers/{customer}/repayments` and `POST /customers/{customer}/debts`, which delegate to `CustomerDebt::record(...)` (documented under the `customers` module). The `debts` module is therefore a pure projection over `customers.debt_total` and the `customer_debts` ledger — it introduces no entities or business rules of its own. 🟢

**Gaps / smells:**
- ✅ **Fixed** (2026-09-17): the listing used to load **all** debtor rows with `->get()` (no pagination), unlike `products`/`customers`. Now paginated (`->paginate(30)`), consistent with the rest of the app.
- 🟢 No new data structures — see the `customers` module for `customer_debts` and `customers.debt_total`.

**Complexity:** low.

---

## Module: `gifts`

**Purpose:** Catalogue of loyalty rewards ("Quà tặng") that customers redeem with points. Full CRUD is delegated to the Encore\Admin scaffolding; a JSON `scan` endpoint feeds the POS/customer gift-redemption picker. 🟢

**Routing & shape:** Under the `settings` prefix (`routes/web.php:86-92`): `GET settings/gifts/scan → GiftController@scan`, then `resource settings/gifts → GiftController`. The controller `use ModelForm` (Encore\Admin), so `store`/`update`/`destroy` are provided by the trait and driven by `form()`; the class only overrides `index`, `edit`, `grid`, `form`, and adds `scan`. 🟢

**`GiftController::index` (`:19`):** 🟢 Renders an Encore\Admin two-column layout: left is the read-only `grid()`, right is a "new gift" widget form posting to `settings/gifts` (the trait `store`). The grid disables the create button, export, row selector, filter, pagination and the default row actions — creation happens only through the side widget form.

**`grid()` (`:51`):** 🟢 Columns: id (sortable), name, image (rendered `<img>`), points, limit, a computed `used/quantity` column, and an `active` status label (green Active / grey Inactive).

**`form()` (`:76`) — validation & lifecycle hooks:** 🟢
- Rules: `name` required; `image` `max:1024|mimes:jpeg,png,jpg,gif,svg,webp`; `points`/`limit` `nullable|numeric,min:0`.
- `quantity` uses a **dynamic rule**: on edit it becomes `nullable|numeric|min:{used}` — you cannot set stock below the number already redeemed (custom message "Số lượng không được bé hơn tổng đã dùng"). 🟢
- A read-only HTML widget echoes the current `used` count.
- `saving` hook: coerces null `points`/`limit`/`quantity` to `0`; forces `used = 0` on create (preserves existing `used` on edit). 🟢
- `saved` hook: if an image was uploaded, re-opens it from `storage/app/public/{image}` and `fit(300,300)` crops-and-resizes in place via `intervention/image`. 🟢

**`scan()` (`:115`):** 🟢 `Gift::where('name','like',"%q%")`, optionally filtered by `active` when the request carries that param, `take(10)`, returned as `{data:[...]}`. Used to look up redeemable gifts by name.

**`Gift` model (`app/Models/Gift.php`):** 🟢 `SoftDeletes`; `$fillable = [name,image,points,limit,quantity,used,active]`; appends `quantity_available = quantity - used`; `scopeActive` = `where('active',1)`; `image` accessor returns `asset(storage_url($value))` or a `noimage.png` fallback. The redemption gate (`checkGiftAvailable`, `limit`=0 → unlimited, `quantity_available === 0` → out of stock, points sufficiency) lives in the **customers** module (`Customer::checkGiftAvailable`) and consumes these fields.

**Gaps / smells:**
- 🟡 `quantity_available` is computed (`quantity - used`) and never persisted; `checkGiftAvailable` tests it with strict `=== 0`, so an over-redemption that drove `used > quantity` (available negative) would read as "in stock". The min-`used` quantity rule and the transactional redemption (fixed 2026-09-17) make that state hard to reach, but the strict comparison is worth noting.
- 🟡 Full create/update/delete behaviour ultimately lives in the vendored `ModelForm` trait (not in this repo's `app/`); the analysis above reflects the overridden `form()`/`grid()` config, which is what actually governs validation and persistence.

**Complexity:** medium.

---

## Module: `brands`

**Purpose:** Simple lookup table of product brands/labels ("Nhãn hiệu"), managed through the Encore\Admin scaffolding. Referenced by `products.brand_id`. 🟢

**Routing & shape:** Under `settings` (`routes/web.php:87`): `resource settings/brand → BrandController` (note the **singular** resource name `brand`). Like `gifts`, the controller `use ModelForm`, so `store`/`update`/`destroy` come from the trait; the class overrides only `index`, `edit`, `grid`, `form`. 🟢

**`BrandController::index` (`:19`):** 🟢 Two-column Encore\Admin layout: read-only `grid()` on the left, a "new brand" widget form (fields `name` required, `description`) posting to `settings/brand` on the right.

**`grid()` (`:53`):** 🟢 Columns id (sortable), name, description. Create button / export / row-selector / filter / pagination all disabled. Row actions are constrained:
- `disableDelete()` on **every** row — brands can never be deleted from the UI (they are FK targets of `products`). 🟢
- `disableEdit()` specifically when the row key is `1` — brand id 1 is treated as a protected/default record. 🟢 Confirmed with the team: this is the default/fallback brand (e.g. "Không xác định").

**`form()` (`:75`):** 🟢 `name` (required) and `description` — that's the entire editable surface.

**`Brand` model (`app/Models/Brand.php`):** 🟢 Bare Eloquent model; only relationship is `products()` = `hasMany(Product::class)` (via `products.brand_id`). No casts, no soft-deletes, no fillable guard.

**Gaps / smells:**
- 🟢 `brand_id` is `required` on `Product` (per the products module); brands are never deletable and id 1 is edit-locked — confirmed mandatory default brand.
- 🟢 No custom algorithms or lifecycle hooks; the module is a thin reference-data editor.

**Complexity:** low.

---

## Module: `categories`

**Purpose:** Product-category reference data ("Danh mục"), managed through the Encore\Admin scaffolding and referenced by the required `products.category_id`. The table also carries a nullable `parent_id` that gives categories a two-level (group → child) shape used only when populating the product form's category dropdown. 🟢

**Routing & shape:** Under the `settings` prefix (`routes/web.php:88`): `resource settings/categories → CategoryController`. Like `brands`/`gifts`, the controller `use ModelForm` (Encore\Admin), so `store`/`update`/`destroy` come from the trait and are driven by `form()`; the class overrides only `index`, `edit`, `grid`, `form`. 🟢

**`CategoryController::index` (`:20`):** 🟢 Two-column Encore\Admin layout: read-only `grid()` on the left, a "new category" widget form (fields `name` required, `description`) posting to `settings/categories` on the right.

**`grid()` (`:54`):** 🟢 Columns id (sortable), name, description. Create button / export / row-selector / filter / pagination all disabled. Row actions:
- `disableDelete()` on **every** row — categories can never be deleted from the UI (they are FK targets of `products`). 🟢
- 🟡 **Status: pending (grouped with the `parent_id` gap below).** Unlike `units` and `brands`, the id-1 edit-lock is **commented out** here (`:67-69`), so every category row remains editable — there is no protected default category enforced in the UI. Team has not yet confirmed whether this is intentional (categories have no "default record" concept) or an oversight — deferred to Detective/PM alongside the `parent_id` gap.

**`form()` (`:77`):** 🟢 `name` (required) and `description` — that is the entire editable surface. **`parent_id` is never exposed** by either the index widget form or `form()`.

**Two-level hierarchy & the product dropdown (key business rule):** 🟢 `ProductController::create()`/`edit()` build the category dropdown with `Category::whereNotNull('parent_id')->pluck('name','id')` (`app/Http/Controllers/ProductController.php:73,157`). Only **child** categories (those with a `parent_id`) are assignable to products; top-level categories (`parent_id` null) act as group headers. `store`/`update` validate `category_id` as `required|exists:categories,id`.

**Domain significance:** 🟢 Category ids `1` (milk / "Sữa") and `8` (medicine / "Thuốc") are special-cased in the customer/order statistics rollup (`Order::summaryLogging`, `CustomerController::statis` — see the `orders` and `customers` modules).

**`Category` model (`app/Models/Category.php`):** 🟢 Bare Eloquent model; only relationship is `products()` = `hasMany(Product::class)` (via `products.category_id`). No `parent()`/`children()` relation is defined despite the `parent_id` column. No casts, no soft-deletes, no fillable guard.

**Gaps / smells:**
- 🔴 **GAP (status: pending):** the UI never sets `parent_id` (not in the widget form nor `form()`), yet the product dropdown only shows categories where `parent_id` is not null. A category created/edited through the admin keeps `parent_id = null` and therefore can never be assigned to a product — parent/child linkage must be done directly in the DB. Team has not yet confirmed whether this is intentional (pre-provisioned categories) or an unfinished feature — deferred to Detective/PM for a decision.
- 🟡 No `parent()`/`children()` Eloquent relations exist even though `parent_id` is queried directly, so the hierarchy is implicit (a single `whereNotNull` filter), not modelled.
- 🟢 No custom algorithms or lifecycle hooks; the module is a thin reference-data editor.

**Complexity:** low.

---

## Module: `units`

**Purpose:** Unit-of-measure reference data ("Đơn vị"), managed through the Encore\Admin scaffolding. Units feed the product base-unit dropdown and the per-product unit-conversion table (`product_units`), and label order lines on receipts. 🟢

**Routing & shape:** Under the `settings` prefix (`routes/web.php:89`): `resource settings/units → UnitController`. Like `categories`/`brands`/`gifts`, the controller `use ModelForm` (Encore\Admin), so `store`/`update`/`destroy` come from the trait and are driven by `form()`; the class overrides only `index`, `edit`, `grid`, `form`. 🟢

**`UnitController::index` (`:20`):** 🟢 Two-column Encore\Admin layout: read-only `grid()` on the left, a "new unit" widget form (fields `name` required, `description`) posting to `settings/units` on the right.

**`grid()` (`:54`):** 🟢 Columns id (sortable), name, description. Create button / export / row-selector / filter / pagination all disabled. Row actions:
- `disableDelete()` on **every** row — units can never be deleted from the UI (they are FK targets of `product_units` and `order_product`). 🟢
- `disableEdit()` when the row key is `1` (`:66-71`) — unit id 1 is a protected/default unit. 🟢 Confirmed with the team. (Note: this id-1 lock is **active** here, whereas the identical block in `CategoryController` is commented out — see the `categories` module gap.)

**`form()` (`:76`):** 🟢 `name` (required) and `description` — the entire editable surface. The `units.category_id` column is never exposed.

**Consumption across the app:** 🟢
- `ProductController::create()`/`edit()`: `Unit::all()->pluck('name','name')` builds the base-unit dropdown, which is stored as the **string** `products.unit` (not an FK); `Unit::all(['id','name'])` populates the conversion-units editor bound to `product_units.unit_id` (`app/Http/Controllers/ProductController.php:75-76,159-160`).
- `pos-print.blade.php:57`, `orders-detail.blade.php:72`, and `customer-orders.blade.php:66`: `\App\Models\Unit::find($pivot->unit_id)->name ?? ''` renders the chosen unit label on receipts/detail/customer order history (per-row `Unit::find`, N+1-prone). 🟡
- `order_product.unit_id` is an FK to `units` with `onDelete('set null')` (`2026_08_27_000002_add_unit_columns_to_order_product_table.php`); `product_units.unit_id` also references `units`.

**`Unit` model (`app/Models/Unit.php`):** 🟢 **Completely empty** — no relations, casts, or fillable. The inverse `belongsTo(Unit)` lives on `ProductUnit::unit()`.

**Gaps / smells:**
- 🟡 The `units` table declares a nullable `category_id` column (`2019_05_15_121417_create_units_table.php`) that **no code reads or writes** — an apparently dead/unused column. Confirm before relying on it (units are not category-scoped anywhere in the app).
- 🟡 `products.unit` is a free string keyed by unit **name**, while conversions use `product_units.unit_id` (an FK). The base unit and the conversion units are therefore modelled inconsistently (name vs id); a renamed unit would desync existing product base-unit strings.
- 🟡 `Unit::find($pivot->unit_id)->name` is called per line inside the print/detail loops (N+1); guarded from fatal null-deref only by the trailing `?? ''`.
- 🟢 No custom algorithms or lifecycle hooks; a thin reference-data editor.

**Complexity:** low.

---

_All 11 modules analyzed — the Archaeologist (excavation phase) is complete. Next phase: interpretation (Detective, Architect)._
