## Context

Prior explore-mode analysis on this same bug went through several rejected/reconsidered designs before landing here:
1. "Merge to whichever unit is finer" — always avoids decimals, but was rejected because it silently collapses operationally distinct dispensing actions (e.g. 1 whole "Chai" + 3 loose "Viên" for a prescription) into a single undifferentiated line.
2. "Replace the old line when units differ" — avoids decimals differently, but was rejected because it silently discards the previous line's quantity (undercharge risk).
3. "Remove the per-line unit-select dropdown entirely" (since the unit is now chosen deliberately at add-time via per-unit search entries) — considered simpler and briefly adopted, but reconsidered once implementation surfaced a gap: exact-barcode scanning (still single-default-unit, no per-unit picker — see Decision 2) has no other way to correct a mis-scanned line's unit. Removing the dropdown would make any non-default-unit sale of a barcode-scanned product completely unreachable without switching to name-search instead. The dropdown is kept (Decision 3 below), just changed so a switch only relabels a line rather than converting its quantity.

This design keeps lines separate per unit (Decisions 1–2), which satisfies the no-decimals/no-data-loss constraints from designs 1–2 above, and keeps a lightweight way to correct a line's unit after the fact (Decision 3) without reintroducing either problem.

**Current architecture** (`app/Http/Controllers/PosController.php`'s inline `Admin::script` block, rendered by `#scannerItem`/autocomplete markup in `resources/views/vendor/admin/index.blade.php`):
- A cart line is identified purely by product `code`: DOM row id (`id="[[:code]]"`), `duplicate(order.code,'code')` lookups (6 call sites: `addItem`, `updateItem`, `deleteItem`, the exact-scan check, the qty `+`/`-` handler, the customer-type-change re-render loop), and form field names `items[<code>][qty]`/`items[<code>][unit_id]`.
- Autocomplete search results render one `<li>` per matched **product**, priced at its default unit only (`getDefaultUnit()`); clicking it calls `addItem` with that default unit baked in. There is no per-unit choice at add time.
- Barcode-exact-match (`is_barcode: true`) skips the autocomplete list and adds immediately at the default unit — unaffected by this change, since there is no per-unit barcode in the data model (`ProductUnit` has no barcode column). This is also why the unit-switch dropdown can't simply be removed (see point 3 above): it's the only correction path this flow has.
- `updateItem()` — the "re-add same code" path — always discards whatever unit the fresh scan implies in favor of the existing line's `selected_unit_id` (`curData.selected_unit_id`), then does `qty + 1`.
- `OrderController::store()`/`update()` iterate `$valided['items'] as $code => $item` and build `$order_attach[$prod->id] = [...]` — both keyed in a way that silently keeps only the last entry if two submitted lines share a code/product id. The `create_now_mode` draft-rebuild block (`OrderController.php:214-221`) has the same bug (`$products[$item->code] = [...]`).
- `order_product` (the pivot table) has no primary key or unique constraint on `(order_id, product_id)` — it already supports multiple rows per product per order; confirmed by reading both of its migrations.
- Every view that renders order lines (`orders-detail.blade.php`, `pos-print.blade.php`, `customer-orders.blade.php`) already iterates `$order->products` as a flat list of pivot rows with no per-product-id uniqueness assumption — they need no changes to correctly render multiple lines for one product.

## Goals / Non-Goals

**Goals:**
- A product can appear as more than one cart line simultaneously, one per unit actually sold, with no automatic cross-unit conversion or merging on add.
- No cart quantity is ever displayed, submitted, or persisted as a non-whole number.
- A cashier has a real way to add a specific unit deliberately (not just whatever the product's configured default happens to be) via search, and a real way to correct a line's unit after adding it (via the dropdown) — including for barcode-scanned lines, which have no add-time unit picker.
- Order persistence (`OrderController`) keeps all submitted lines, even when several share a product code, and gains automated test coverage proving it.
- `customer-orders.blade.php` shows an empty-state row consistent with the rest of the system (carried over unchanged from the prior version of this proposal).

**Non-Goals:**
- Not adding per-unit barcodes to the data model — barcode scanning stays single-default-unit; the new per-unit picker only applies to the name-search/autocomplete flow. Correcting a barcode-added line's unit still goes through the dropdown (Decision 3), not a second scan.
- Not building a general multi-select "add several units at once" dialog — one click still adds exactly one line (or increments one existing line) at a time, same interaction cardinality as today.
- Not converting/scaling a line's quantity when its unit is switched via the dropdown — the number is left exactly as-is; only price, subtotal, and label are recomputed at the new unit (Decision 3).
- Not introducing a JS test framework/runner — none exists in this project (`package.json` has no test tooling); JS cart-interaction coverage is a documented manual QA checklist (see Tasks), not automated tests. Automated coverage is added where it's actually feasible: PHPUnit feature tests around `OrderController`.
- Not fixing `OrderController`'s `earned_point` (`reward_point * qty` without dividing by `conversion_qty`) — pre-existing, independent bug, out of scope, flagged as a known issue.

## Decisions

### 1. Cart-line identity: `(code, unit_id)` instead of `code`, joined with a CSS-selector-safe separator

Every place currently keying on `order.code` alone moves to a composite key: `code + '__' + (unit_id ?? 'base')`:
- DOM row id (`#scannerItem` template, currently `id="[[:code]]"`)
- `duplicate()` lookups in `addItem`, `updateItem`, `deleteItem`, the exact-barcode-scan check, the qty `+`/`-` handler, the customer-type-change re-render loop
- Form field names: `items[<code>][...]` → `items[<lineKey>][...]`, with `code` and `unit_id` submitted as explicit values within that line's fields (the template already carries a hidden `code` input per row; `unit_id` is already carried via `.unit-id-input`)

`addItem` looks up an existing line by the FULL `(code, unit_id)` key, not just `code`. If found → `updateItem()` (qty + 1, unchanged logic otherwise). If not found → a new line is created, even if other lines already exist for the same product code under different units.

**Separator choice — `__`, not `::`:** an initial implementation joined `code` and `unit_id` with `::` (e.g. `P000123::5`). Since these composite keys are also used verbatim as DOM element `id` attributes, and every line lookup does `$('#' + lineKey)`, this broke at runtime: `::` is CSS syntax for a pseudo-element, so `#P000123::5` is an invalid/differently-parsed selector and jQuery's selector engine throws rather than matching the element. This affected *every* cart line, not just multi-unit ones (`updateItem`, `deleteItem`, the qty `+`/`-` handler, and the customer-type-change loop all do a `$('#' + lineKey)` lookup). Caught during this change's own verification pass and fixed by switching the separator to `__` (plain characters, valid unescaped in a CSS id selector) everywhere `lineKey` is constructed, both in the `#scannerItem` template and in `PosController.php`.

### 2. Autocomplete gains one entry per configured unit

`addProductRow`'s non-barcode branch currently renders one `<li>` per matched product. It changes to render one `<li>` per `(product, unit)` pair — i.e., loop `_prod.units` and render one row per unit, each showing that unit's price and label, each carrying its own `unit_id` in its `data('item')` payload (a shallow-cloned product object with `selected_unit_id` pre-set to that specific unit, bypassing `getDefaultUnit()`). Clicking a specific unit's row calls `addItem` exactly as today, just with a specific unit already chosen instead of the implicit default.

Barcode-exact-match stays a single row (`is_barcode: true` short-circuits before the per-unit list is ever built) — unaffected.

### 3. The per-line unit-select dropdown is kept, but a switch only relabels — it never converts the quantity

The `.unit-select` change handler (`PosController.php`) and the `<select class="unit-select">`/`.unit-id-input` markup in the `#scannerItem` template stay. Switching a line's unit:
- Recomputes that line's price and subtotal at the newly-selected unit (via `getPriceByCustomerType`), and updates its label — exactly as the original, pre-existing dropdown behavior did.
- **Leaves the quantity number untouched.** It is not multiplied or divided by any `conversion_qty` ratio. The number represents how many times the cashier added/scanned this line, not a physical amount that must be preserved when re-expressed in a different unit; the cashier can adjust it directly via the existing quantity `+`/`-` input after switching, if the number itself also needs to change.
- If the target unit already has its own separate cart line for the same product (i.e., the switch would create a second line at the same `(code, unit_id)`), the two lines merge: the switched line's quantity is added directly onto the existing line's quantity (no conversion needed, since both are the same unit once merged), the existing line's price/subtotal recompute, and the line being switched from is removed.

**Why relabel instead of convert:** the original bug report ("1 Hộp + add 1 Viên → 61 Viên") is about *combining two separate additions* — that's now handled entirely by Decisions 1–2 (each addition becomes/increments its own line; no unit-switch involved at all). The dropdown's only remaining job is *correcting a single line's mistaken unit* (most often after a barcode scan, which always defaults to the product's configured default unit with no way to pick otherwise). For a correction, converting the quantity by a ratio is the wrong semantic — e.g. a line at quantity 5 "Viên" (added via 5 scans) switched to "Hộp" should not become `5 × (1/60) = 0.083` Hộp; the 5 was a count of add actions, and relabeling it as "5 Hộp" (with the cashier free to then correct the number if that's not what they meant) is what a mistaken-unit correction actually means. This also means no unit-conversion arithmetic exists on the switch path either, so a decimal quantity remains structurally impossible — the same guarantee an earlier "remove the dropdown" design aimed for, without losing the ability to fix a line after adding it.

### 4. Backend: line-indexed submission and persistence

`OrderController::store()`/`update()` change `foreach($valided['items'] as $code => $item)` to iterate the submitted items as a plain list (each carrying its own `code` and `unit_id` fields, matching the new frontend field shape), and change `$order_attach[$prod->id] = [...]` to append `[$prod->id, $pivotData]` pairs to a list, attaching each pair individually (`$order->products()->attach($id, $pivotData)` per line) rather than building one associative array — the associative form is exactly what silently collapsed same-product lines before. The `create_now_mode` rebuild block (`OrderController.php:214-221`, which turns an existing order's pivot rows back into a submittable `items` shape) gets the identical fix, since it has the same code-keyed bug and feeds directly into the loop above.

### 5. Testing strategy

No `tests/` directory existed before this change, and there is no JS test runner in this project. Given that constraint:
- **Automated (PHPUnit, Feature — `tests/Feature/OrderControllerTest.php`):** `OrderController::store`/`update`, specifically: submitting two items with the same product code but different `unit_id`s persists as two separate `order_product` rows (not one, silently overwritten); each row's `qty`/`unit_id`/`conversion_qty`/`price` matches its own submitted line; `order->subtotal`/`total` sum correctly across both; the `create_now_mode` rebuild path preserves an existing order's multiple same-product lines when re-submitting; an unrecognized `unit_id` falls back to base-unit pricing; a normal single-line order still creates exactly one row. This is the one part of this change with real, persistent-data correctness risk (a regression here means an order silently underrepresents what was sold), so it gets real test coverage rather than relying on manual QA.
- **Manual QA checklist (documented in tasks.md, not automated):** the POS cart JS — adding a product via autocomplete at two different units produces two lines; re-adding at an already-present unit increments that line; switching a line's unit relabels it without touching quantity; switching to a unit that collides with an existing separate line merges them; reloading a draft order that already has two lines for one product does not collapse them.
- **Test-infrastructure bugs found and fixed while getting the above to actually pass** (both pre-existing, unrelated to this change's own logic, but blocking verification of it):
  - `tests/TestCase.php`'s hand-rolled schema builder (`createSchema()`) created a `customers` table without `softDeletes()`, while the `Customer` model uses the `SoftDeletes` trait — any query touching `Customer::code()` (used by `OrderController::store`/`update` to resolve the submitting customer) failed with `no such column: customers.deleted_at` against the sqlite test DB. Fixed by adding `$table->softDeletes();` to that block, matching what the real `products` table already had in the same file.
  - The test's own row-matching assertions compared `$row->unit_id === $unitId` (strict `===`) against a value read back from the sqlite test connection, which returns it as a string, not the int a PHP variable holds — always false. Fixed to a loose/cast comparison (`(int) $row->unit_id === $unitId`).

## Risks / Trade-offs

- **[Risk] More cart lines for the same product may look cluttered for the common case (someone just buys 2 units, no unit-mixing involved).** → Mitigation: unaffected — same-unit repeats still increment a single existing line exactly as today; extra lines only appear when a cashier deliberately picks a different unit.
- **[Risk] A cashier might expect switching a line's unit to also convert its quantity number (that was an earlier design iteration's behavior).** → Mitigation: accepted trade-off — see Decision 3's rationale; the dropdown's job is correcting a mistaken unit, not converting an accumulated quantity, and the quantity input remains directly editable right after switching if the number also needs adjusting.
- **[Risk] Missing one of the several `duplicate()`/keying call sites during implementation** (same class of risk flagged in the original risk assessment for this approach) → Mitigation: tasks.md enumerates every call site explicitly by file/line so none are implicitly skipped, including the easy-to-miss `create_now_mode` backend path. Caught in practice: the `::` → `__` separator bug (Decision 1) was found exactly this way, by exercising the actual DOM lookups rather than trusting the code by inspection alone.
- **[Risk] `order->count` (the stored "số sản phẩm" counter, incremented once per submitted line in `OrderController`) will now count 2 for a product sold in 2 units on one order, which is arguably correct (2 line items) but could read as "2 products" to someone expecting product-count.** → Not mitigated in this change; flagged as an accepted, pre-existing semantic (the field has always counted line items, not distinct products, even before this change — a product appearing once already increments it once regardless of quantity).
- **[Risk] No automated coverage for the JS cart interaction itself** → Mitigation: accepted trade-off given no JS test infrastructure exists in this project; covered by an explicit manual QA checklist instead (see Non-Goals and Testing strategy above).

## Migration Plan

No data migration — `order_product` already supports multiple rows per product per order. Deploy as a single combined edit to `PosController.php`, `resources/views/vendor/admin/index.blade.php`, `OrderController.php`, and `customer-orders.blade.php`; existing single-unit orders/carts are unaffected (a product with only ever one unit behaves exactly as before, since it never has more than one line to begin with). Rollback: revert the same files; no persisted state to unwind.

## Open Questions

None outstanding.
