# WP8 — Editable product units + the pack-composition helper

**Repo:** FE (BE only if a real gap appears) · **Branch:** `hazemdev2` · **Migration:** none

## Goal

Two things the owner asked for — "each unit has its own price" and "its composition, e.g. how many strips per box" — are **already fully supported by the backend** and blocked purely by the UI.

1. Make the product-units table **editable**: conversion factor, barcode, purchase price, sale price, `is_sale`, `is_purchase`.
2. Add a **composition helper** so the user types "1 box contains ⟨10⟩ ⟨strip⟩" and the screen stores the correct base-relative factor.

## What already exists — verified, do not rebuild

- Table `product_units`: `conversion_factor` decimal(15,6), `barcode`, `purchase_price`, `sale_price`, `is_purchase`, `is_sale`, `unique(['product_id','unit_id'])`.
- Routes: `Modules/Core/routes/api.php:108` — `Route::apiResource('products.units', ProductUnitController::class)->only(['index','store','update','destroy'])`.
- **Both FormRequests already accept every field** and already validate `conversion_factor => ['sometimes','numeric','gt:0']` with a translated message; `StoreProductUnitRequest::withValidator()` already enforces that an alternative unit shares the base unit's unit group.
  ⚠️ The approved analysis claimed this validation was missing — **it was wrong**. Verify by reading the two request classes, then rely on them.
- FE service `src/app/core/services/product.service.ts` already has all four methods — including **`updateProductUnit` at line ~332 (`PUT {apiUrl}/{productId}/units/{unitId}`) which has ZERO callers.** Wiring it is the core of this WP.
- FE models `src/app/core/models/product.model.ts` — `ProductUnit` (~156-169) and `CreateProductUnit` (~171-179) already declare `barcode`, `purchase_price`, `sale_price`, `is_purchase`, `is_sale`, all optional and **never populated by any caller**.

## What is actually broken

- `products.component.html:701-742` — the units table renders `purchase_price` and `sale_price` at `:720-721` as **plain text**, and has **no cell at all** for `barcode`, `is_sale`, `is_purchase`. (`is_sale`/`is_purchase` appear in **zero** `.html` files app-wide.)
- `products.component.ts:1005-1049` `addProductUnit()` — in create mode it buffers a row with **hardcoded** `barcode:null, purchase_price:'0', sale_price:'0', is_purchase:false, is_sale:false`; in edit mode it POSTs **only** `{unit_id, conversion_factor}`.
- Create-mode flush at `:1241-1248` re-POSTs the buffered rows, again with only `{unit_id, conversion_factor}`.
- State is signals, not a FormArray: `productUnits`, `newUnitId`, `newUnitFactor`, `nextTempUnitId` at `products.component.ts:196-201` (negative temp ids buffer create-mode rows).

## The composition trap — the reason the helper exists

`UnitConversionService::factorToBase()` returns `product_units.conversion_factor` **directly** — no chain walk, no `parent_unit_id` column. Every factor is relative to the **product's base unit**.

So "box = 10 strips, strip = 10 tablets" must be stored as **strip = 10, box = 100**. If the user types `box = 10` meaning "10 strips", every stock movement, sale, purchase and valuation is wrong by 10× — and **nothing errors**: the service is explicitly documented to resolve a mis-configuration to identity `1.0` rather than throw, precisely so it never breaks a stock movement.

**Do NOT add `parent_unit_id` or recursion.** That primitive carries every inventory, sales, purchase and valuation path in the system, and changing it would reinterpret every existing row. The fix is a UI affordance:

- Next to the factor input, offer: **"1 ⟨this unit⟩ contains ⟨N⟩ ⟨other unit⟩"** — a number + a unit picker limited to units already on this product (or the base unit).
- The screen multiplies: `factor = N × factorOf(chosenUnit)` and writes the **base-relative** result into `conversion_factor`.
- **Always show the resulting base-relative factor** next to it ("= 100 tablet"), so the stored number is never a surprise.
- The raw factor field stays editable for anyone who wants to type it directly.

## Also fix

The unit shown on the POS/product line uses `base_unit.abbreviation` even when the line's unit is different — **out of scope here** (POS), but if you touch shared unit-label code, do not make it worse.

Hardcoded English toasts exist nearby (`products.component.ts:1046 'Unit added'`, `:1467`, `:1538`, `:1341`). If you touch those lines, convert them to i18n keys; do not go hunting beyond your diff.

## Acceptance criteria

1. In **edit** mode: changing a unit's sale price, purchase price, barcode, `is_sale` or `is_purchase` persists via `PUT products/{id}/units/{unitId}` and survives a reload.
2. In **create** mode: buffered rows carry the user's real values (not the hardcoded zeros) and are POSTed with all fields on save.
3. The composition helper produces the correct base-relative factor for a 3-level pack (tablet base → strip 10 → box 100), and the computed value is displayed before saving.
4. A factor of `0` or negative is rejected — confirm the existing `gt:0` rule fires and the message is shown to the user.
5. Adding a unit from a different unit group is rejected with the existing group-mismatch message (do not duplicate that rule client-side; surface the server's).
6. Existing products with existing units are unaffected on load.
7. `ng build` green.

## ➕ Folded in from WP7 — the manufacturer picker has no endpoint to call

WP6 made `products.manufacturer_id` readable and writable through Core's product endpoints. WP7 was supposed to put a manufacturer dropdown on the drug tab and **could not**: there is no manufacturers endpoint reachable from Core. The only ones are `Modules/WebStore/routes/admin.php:36` (prefix `api/store/admin`) and a public catalog route. Core's own `routes/api.php:104` carries a comment saying exactly this — *"the mistake `manufacturers` made was living in WebStore where the products screen can never reach it."*

So the field is writable but unpickable. Close it:

1. **BE** — expose a **read** endpoint for manufacturers in `Modules/Core/routes/api.php` for the products screen to populate a dropdown: company-scoped, `?search=`, `?is_active=`, paginated, gated by an existing products/read permission (do not mint a new permission unless there is genuinely no suitable one — say which you chose and why). Reuse `Modules/WebStore/app/Models/Manufacturer.php`; **do not duplicate the model or the table**. Write/update/delete stay in WebStore — this is a picker, not a second CRUD.
2. **FE** — add the `PRODUCTS.MANUFACTURER` control to the drug tab in `src/app/features/products/products.component.*`. WP7 already added the i18n key to **both** files, so no new i18n work; wire the control and include `manufacturer_id` in the save payload (WP6's FormRequests already accept it).
3. Prove it: a test that the endpoint is company-scoped and that a product can be created with a `manufacturer_id` and read it back.

If after reading the code you conclude a read-only Core endpoint is the wrong shape, **say so with your reasoning and stop** — do not build a parallel manufacturers CRUD in Core.

## Out of scope

- Beyond the manufacturer endpoint above, no BE change unless you find a genuine gap — if you do, **report it, do not silently patch the BE**.
- Variants (`hasVariants` block) — untouched.
- The drug tab — WP7.
- POS.

## Environment

- Build: `cd /home/moonui2/public_html/moon-erp && npx ng build --base-href /app/` — pre-authorized.
- If you add i18n keys: both `en.json` and `ar.json`, additively, at matching positions. **NEVER `git checkout`/`restore`/`stash` those two files.**
- `chown moonui2:moonui2` after each edit; **moonui2 only — never `/home/moonui`**.
- Working tree is dirty by design — never revert/stash/commit what you did not write; do not commit at all.
