# WP2 — Activate conversion: thread unit_id from lines into StockService [FIN]

## Goal
Wire the entered `unit_id` from each transaction line into the WP1 StockService methods so conversion ACTUALLY happens. Go in the architect's risk order. Stamp `base_quantity` on lines at post-time so reversals reverse the exact base qty. The SACRED identity invariant still holds: a line whose unit == product base (or product with no alternate units) → factor 1.0 → byte-for-byte unchanged. This is where multi-unit products start moving stock/cost correctly.

## WP1 interface (already committed 3ae98beba)
- `increaseStock($data)` / `decreaseStock($data)` read optional `$data['unit_id']` and `$data['product_id']`, convert internally (increase also inverse-converts unit_cost).
- `getIssueCost(companyId, productId, variantId, warehouseId, quantity, ?unitId=null)` — pass the line unit.
- `reserve(...,?unitId=null)` / `release(...,?unitId=null)`.
- `availableQuantity(...)` returns BASE — callers convert their comparison input themselves (via `UnitConversionService::toBase`).
- `base_quantity DECIMAL(15,3) NULL` exists on: sales_invoice_items, sales_delivery_note_items, sales_return_items, purchase_bill_items, purchase_grn_items, purchase_return_items, inventory_receipt_items, inventory_issue_items, inventory_transfer_items, inventory_adjustment_items.

## Site-groups — implement in THIS order, test each before the next
### (1) ApproveIssue direct availability check [FIRST — highest risk]
`Modules/Inventory/app/Actions/ApproveIssue.php:183-208`: a direct `StockBalance::lockForUpdate()` compares the line-unit `$qty` against base on-hand (`$available`) at ~:200. Convert `$qty` to base via `UnitConversionService::toBase($productId, $item->unit_id, $qty)` BEFORE the `$available < $qty` comparison. Then pass `unit_id` into the `decreaseStock` call (:232) and `getIssueCost` (:212). Stamp `base_quantity` on the issue item. (This is also the ISS-9105 over-issue site — do not disturb that logic; only convert the qty used for the availability compare + stock/cost calls.)

### (2) getIssueCost cost reads
Every `getIssueCost(...)` caller passes the line's `unit_id` as the new last arg: `ApproveIssue.php:212`, `PostSalesInvoice.php:226`. So COGS is computed on base qty.

### (3) Purchases receipt (increase — qty AND cost)
`PostPurchaseBill.php:630` builds the InventoryReceipt item; `ApproveReceipt.php:185` calls `increaseStock`. Pass `unit_id` into the `increaseStock $data` so qty→base and unit_cost→base_cost (value preserved). Stamp `base_quantity` on purchase_bill_items / inventory_receipt_items.

### (4) Sales issue/deliver/return
`PostSalesInvoice.php:223,249` (decreaseStock), `ConfirmDeliveryNote.php:84`, `PostSalesReturn.php:197-210` (re-stock via increaseStock — for a return, the cost basis is the return line; convert qty, and cost consistently). Pass `unit_id`; stamp `base_quantity`.

### (5) Production reserve/release + backflush + the CANCEL seam
- Reserve/release: `ReleaseProductionOrder.php:80`, `IssueMaterials.php:320`, `CancelMaterialIssue.php:161`, `StageMaterialsBulk.php:187`, `ProductionOrderController.php:469/553` — pass the material line's `unit_id` so holds are in base. RESERVE and RELEASE for the same line MUST pass the same unit_id (asymmetry leaks reservations).
- `IssueMaterials.php:243` decreaseStock — pass unit_id; stamp base_quantity on the mfg issue line.
- **CANCEL/REVERSAL seam (the drift fix):** `CancelReceipt`, `CancelIssue`, `CancelAdjustment`, `CancelTransfer` currently re-read raw `$item->quantity` and re-post through increase/decrease. Change them to reverse the **stamped `base_quantity`** (pass NO unit_id, or unit_id such that factor is 1, so the already-base value is not re-converted) — so reverse == forward regardless of any later factor edit. If base_quantity is null (legacy/pre-WP2 rows), fall back to raw qty (those are all factor-1 identity rows). Verify each cancel path.

## FIFO precision MUST-DO (from WP1 [FIN] review — MEDIUM)
The FIFO cost layer stores `unit_cost` at `decimal(15,3)`. WP1's `base_cost = unit_cost / factor` is value-exact at the WAC balance (product computed before rounding), but a NON-INTEGER factor (e.g. cost 10, factor 3 → base_cost 3.333…) rounds when persisted to the layer, so reconstructing layer value as `qty × 3-decimal unit_cost` diverges sub-cent from the value-preserved total. On activation, ensure the FIFO layer preserves the TOTAL value: e.g. persist/consume from the value-preserved `total_cost` (or store a base total-value on the layer), not `qty × 3-decimal unit_cost`. Add a test: receive a factor-3 line (cost 10) → issue it fully back → COGS + remaining value net to the original total exactly (no 0.001 residual). This is the one place activation can leak value — handle it.

## Identity + activation rule
For EVERY site: pass the line's ACTUAL entered `unit_id`. For single-unit products / lines where unit==base, `toBase` returns factor 1 → identity. Do NOT pass a converted qty into StockService (StockService converts) — pass the ENTERED qty + unit_id (convention D5). NEVER double-convert.

## Acceptance criteria
- **Identity (SACRED):** all existing Inventory/Sales/Purchases/Production tests keep their result vs the CORRECTED baseline (Inventory 434 pass / 1 pre-existing OpeningBalance fail; capture Sales/Purchases/Production baselines before touching them). ZERO new failures.
- **Activation round-trips (new tests):** for a product base=kg + carton(factor 12): (a) purchase-receive 2 cartons → on-hand +24 kg, inventory value = 2×carton_cost; (b) sell/issue 1 carton → on-hand 12 kg, COGS on 12 kg; (c) return 1 carton → back to 24; (d) full round-trip nets stock AND value to origin; (e) availability: issuing 3 cartons when only 24 kg on-hand (=2 cartons) → correctly blocked (not a unit-mismatch false pass/fail); (f) cancel a factor-12 receipt → reverses exactly 24 (via stamped base_quantity), even if the factor is edited between post and cancel.
- Reserve/release symmetry for a factor≠1 production material.

## Tests
- New `Modules/Inventory/tests/Feature/UnitConversionActivationTest.php` (+ per-module round-trip tests where the flow lives: a Sales one, a Purchases one, a Production one). Pest/sqlite.
- Re-run touched module suites BATCHED (Inventory 4×8; Sales; Purchases; Production guard suites) vs baseline — zero new failures.

## Flags
- **[FIN]** heavy — architect + code-reviewer review mandatory (this is where value can leak). Migration: none (columns exist from WP1). Run any dev checks live.

## Out of scope
- FE (WP3), pricing per unit (WP4), display/seed/validation (WP5). Only BE call-site wiring + base_quantity stamping + the cancel seam.
