# LEDGER — POS unit-of-sale selector

**Feature slug:** `pos-unit-selector`
**Started:** 2026-08-03
**Branch:** `hazemdev2` (BE `/home/moonui2/moon-erp-be` · FE `/home/moonui2/public_html/moon-erp`)
**Source (approved):** [`plans/pos-pharmacy-fitness-analysis.html`](../pos-pharmacy-fitness-analysis.html) §2-3 + gap-table row 3 «وحدة الصرف» · [`plans/pos-settings-catalogue-and-drug-product.html`](../pos-settings-catalogue-and-drug-product.html) group 2
**Owner report (2026-08-03):** «في نقاط البيع عند اختيار منتج بباركود أو بحث عنده أكتر من وحدة قياس لا يظهر إمكانية اختيار الوحدة — سواء لها باركود أو ليس لها.»

## Owner's acceptance test

At the till, the cashier scans or searches a product that has more than one sellable unit. A **unit control appears on the line** listing that product's units (قرص / شريط / علبة). Changing it re-prices the line from that unit, and the line **shows the unit actually sold** — not the base unit. It works whether or not the chosen unit has its own barcode. Selling in the base unit is unchanged.

## What is already built — do not rebuild (verified in code 2026-08-03)

| Capability | Where | State |
|---|---|---|
| `product_units` with `conversion_factor`, own `barcode`, own `sale_price` | `Modules/Core` | ✅ |
| Conversion to base before stock decrement | `UnitConversionService` | ✅ correct |
| Price precedence: unit's own price → base × factor | `pos-cart.service.ts:671+` `resolveUnitPrice()` | ✅ |
| Min-price floor scales by the same factor | `ValidatesMinimumSalePrice` | ✅ |
| Cart merges on **(product, unit)** so a carton line and a piece line stay separate | `pos-cart.service.ts:304-309` | ✅ |
| `unit_id` accepted and required by `POST /pos/sales` | `StorePOSSaleRequest` | ✅ |
| Per-unit barcode → product **+ matched unit + price** | `POSProductController.php:141,163` (`productUnits.unit` eager-loaded) | ✅ |
| `POSProductResource` emits `product_units` | `POSProductResource.php:38` (`whenLoaded`) | ✅ |

**The arithmetic is complete and correct. This feature is entry, not maths.**

## The two actual gaps + one bug (verified, not inferred)

1. **`POSProductController::index()` (line 43) and `search()` (line 88) eager-load only `['category','baseUnit']`.** The barcode paths (141, 163) DO load `productUnits.unit`. So a product reached by **grid or search** arrives at the till with **no unit list at all**, while the same product reached by **scanning a unit barcode** arrives with one. That asymmetry is the owner's "سواء لها باركود أو ليس لها".
2. **No unit control exists in any POS template.** `grep 'product_units|matched_unit|setUnit|unit_id' src/app/features/pos --include=*.html` → **zero hits**.
3. **BUG — the line shows the wrong unit.** `invoice-table.component.html:92-93` renders `item.product?.base_unit`, so a line sold as a carton still displays «قرص». The correct unit is on the line (`item.unit_id`).

## Decisions (do not re-litigate)

1. **No new pricing logic.** The selector calls the existing `resolveUnitPrice()`. Any second price path would drift from the server's floor scaling.
2. **The cart merge key stays `(product, unit)`.** Changing a line's unit must re-evaluate that key — switching line A to the unit line B already uses must merge them, not create a duplicate the server would accept as two lines.
3. **The selector is NOT gated behind a setting in this feature.** `pos.allow_unit_switch` exists in the catalogue as a **terminal-scoped** row and was deliberately excluded from WP9 (the terminal mechanism has no description slot and `setting_definitions` has no terminal scope). Gating a capability the owner just asked for behind an unbuilt toggle would ship it invisible. **Open decision for the owner — recorded below.**
4. **Show the control only when the product actually has more than one sellable unit.** A single-unit product's line must look exactly as it does today.

## Baseline (recorded BEFORE any change)

- `pest Modules/POS` → **236 passed / 0 failed (1226 assertions)** — recorded 2026-08-03 before any edit
- FE `ng build --base-href /app/` → **exit 0** (confirmed 2026-08-03 before any edit)
- Working tree: clean, `hazemdev2` == `origin/main` + 1 unpushed commit per repo (D1). Do not disturb D1's commit.

## WP table

| WP | Scope | Repo | Depends | Review | Migration | Status | Commits / notes |
|----|-------|------|---------|--------|-----------|--------|-----------------|
| WP1 | Eager-load `productUnits.unit` in `POSProductController::index()` + `search()` so grid/search products carry their unit list, exactly as the barcode paths already do. Test the payload from both endpoints. | BE | — | orchestrator-verified | no | ✅ **done** 2026-08-03 · `afcc5679a` (+ orchestrator fix `1632b3bd4`) | red-before-green (6 failing → 7 passing); `pest Modules/POS` **243 / 0** (baseline 236/0, exactly +7); **no N+1 measured** — 6 queries for a page of 2 and 6 for a page of 12, on grid and search. Payload cost quantified: ~204 bytes per unit row. |
| WP2 | Unit control on the cart line + `setUnit(index, unitId)` in `PosCartService` reusing `resolveUnitPrice()` and re-evaluating the merge key; **fix the line to display the LINE's unit, not `base_unit`**. | FE | WP1 | orchestrator-verified | no | ✅ **done** 2026-08-03 · `b9b8c513f` | 5 headless specs, all **exercised** not asserted. **Red-checked in isolation** for both the unit-display bug (reverting the one template line reproduces «tab» on a box line) and the merge case (disabling the branch leaves 2 rows). `tsc` 0, `ng build` 0. Payload gained **no** new key — verified on the captured wire. |
| WP3 | Offline: per-unit barcode matching in `pos-offline.service.ts` (today it matches `p.barcode` only, so a carton barcode is unknown offline). | FE | WP2 | orchestrator-verified | no | ✅ **done** 2026-08-03 · `cc7f414ad` | 7 headless specs, **red-in-isolation** confirmed (reverting the one method turns C1/C4/C6 red and leaves the rest green). WP2's 5 specs re-run green. **No cache schema change needed** — `syncAllToCache()` stores the payload verbatim, so WP1's eager-load already landed `product_units` in IndexedDB. `pos_cache` stays at v2. |

**DAG:** strictly sequential — WP2 needs WP1's payload, WP3 extends WP2's cart path. One writer per repo at a time.

## ⚠️ CORRECTED INTERFACE — the brief's shape was wrong; WP2 must bind to THIS

Read from `POSProductResource.php:38-52`, not guessed:

```
product_units: [ { id, unit_id,
                   unit: { id, name, abbreviation } | null,
                   barcode, conversion_factor, sale_price, purchase_price,
                   is_sale, is_purchase } ]
```

Three corrections, each of which would have been a **silent** WP2 failure:
1. **`name`/`abbreviation` are nested inside `unit`**, not top-level. `pu.name` is `undefined`.
2. **`unit` may be `null`** — the resource guards on the relation being loaded.
3. **`purchase_price` exists** and the brief omitted it.

Wire types: `id`/`unit_id`/`unit.id` are ints · `conversion_factor` is a **string** `decimal:6` (`"12.000000"`) · `sale_price`/`purchase_price` are **nullable strings** `decimal:3` · `is_sale`/`is_purchase` are real booleans · `barcode` nullable string.

Three more contract facts:
- The array contains **purchase-only units too** (`is_sale: false` is emitted, matching the barcode path) — the selector must filter client-side.
- The **base unit is NOT in this array** — it lives only in `base_unit`. The selector must synthesize the base option.
- **No explicit ordering** — plain `hasMany`, DB order.

### Bug found and fixed while writing WP1's test (orchestrator, `1632b3bd4`)

`units` has a **`symbol`** column and **no `abbreviation`**, so `POSProductResource`'s `$unit->abbreviation` resolved to nothing on **every** path, `base_unit` included. Every unit in POS returned `abbreviation: null` and the screen fell back to the full name — «قرص واحد» where «قرص» was meant. The JSON key is unchanged so no consumer moves; only the value became real. WP1's test had pinned the null as a known defect; that assertion is now the guard against it silently returning. `POSProductApiTest` **21 passed**.

### 🔴 WP2's load-bearing find — WP1 had been fixing an endpoint the screen never called

`PosProductService.searchProducts()` called **`GET /core/products?search=`** and rebuilt each row through a hand-written whitelist that **dropped `product_units`**. So:
- the acceptance criterion "added from **search** shows a unit control" was unachievable no matter what the UI did, and
- WP1's eager-load on `/pos/products/search` was landing on an endpoint the frontend **never hit**.

Switched to `GET /pos/products/search?q=` — the same `POSProductResource` the grid and barcode paths already use, so units, real stock and `min_sale_price` all arrive. FE-only; no backend change.

**Second-order win:** `/core/products` sits behind `core.products.view`, which a cashier has no reason to hold — the same silent-403 class already documented in the POS `CLAUDE.md` and fixed for settings in the earlier plan.

**This is why the owner saw the gap on the search path specifically.**

## Open decisions for the owner

1. **Should the unit selector be switchable per terminal (`pos.allow_unit_switch`)?** Shipped ungated for now — a wholesale counter and a retail counter may genuinely want different answers, but the toggle needs the terminal-settings path built first. Say the word and it becomes a follow-up package.

## Deferrals / findings for later

- 🔴 **The till cannot restart at all without the server.** Found in WP3: with `/auth/me` unanswered the shell bounces the whole SPA to Login before any POS code runs, and there is **no service worker**, so a reload cannot even fetch the bundle from a dead server. "Offline" today means *the connection dropped while the app was already open* — not *the app can start offline*. Far bigger than this plan; worth its own package.
- **Purchase-only units still resolve by barcode offline** (`is_sale: false` + a barcode). Deliberate: `POSProductController::barcode()` does not filter them either, and WP3 chose parity with the server over a local opinion. If the **server** should refuse them, that is a BE package.
- **`sku` fallback added to the offline barcode lookup** (tried last, after the product barcode). The server's product query is `barcode = ? OR sku = ?`; offline had neither. Same "works online, unmatched offline" class.
- **`components/cart-panel/` is dead code** — nothing references it. Worth deleting in a cleanup pass.
- The `pos.allow_unit_switch` terminal gate remains unbuilt; the selector ships ungated (see Open decisions).

## Progress log

- **2026-08-03** — Phase C complete. CHANGELOG bullet written (`e6c1087b4`), FE built and **deployed to `/app` as `main-V3VKJUPJ.js`**. Final `pest Modules/POS`: **243 passed / 0 failed (1338 assertions)** — baseline was 236/0, exactly +7 from WP1's payload tests, zero new failures.
- **2026-08-03** — Phase A complete. All three findings verified directly in code (they matched the analysis exactly). Baselines: `pest Modules/POS` **236/0**, FE `ng build` **exit 0**. Three briefs written. WP1 dispatched.
