# WP3 — [FIN] IssueMaterials: per-line owner + warehouse routing

## Goal
Make `IssueMaterials` honor a per-line `owner_partner_id` (consignor) and `warehouse_id` on production material issues: stamp them on the draft `inventory_issue_items`, allocate each line's lots from the chosen warehouse, and route a line with a chosen customer to THAT customer's off-book consignment lots + ledger (decoupled from the toll contract) — with own material still hitting WIP as today. OFF-path (no per-line override on any line) = byte-for-byte unchanged.

## Precedence (frozen — architect)
Per resolved line `owner_partner_id`:
- **NULL** → run the existing `ownershipLegs()` auto-split from the order material `supply_source`/own_qty/customer_qty. Customer leg owner stays `order->tollContract?->business_partner_id`. **OFF-path byte-for-byte.**
- **> 0** → the WHOLE line is a single CUSTOMER leg for that partner; bypass `ownershipLegs`/`fromOrderMaterial`; no WIP; draw that partner's off-book lots + update its consignment ledger.
- **0** (explicit) → the WHOLE line is a single OWN leg (WIP as today), regardless of the material's supply_source.

## Exact seams (`Modules/Production/app/Actions/IssueMaterials.php` — anchor on symbols)
1. **`resolveLines()`** (~:730-793) — carry two new keys onto each resolved line: `warehouse_id = $line['warehouse_id'] ?? null` and `owner_partner_id = array_key_exists('owner_partner_id',$line) ? (int)$line['owner_partner_id'] : null`. (Distinguish "not sent" (NULL, derive) from explicit 0.)
2. **`createDraftInventoryIssue()`** (~:798-825) — stamp `warehouse_id` and `owner_partner_id` from the resolved line onto each `inventory_issue_items` row (columns added in WP1). NULL stays NULL.
3. **`applyEffects()`** (~:274) — replace the single `$warehouseId = (int)$issue->warehouse_id` with per-item `$lineWarehouseId = (int)($item->warehouse_id ?? $issue->warehouse_id)` inside the item loop; pass that into `allocateLegLots`/`recordIssue` for that line. (ApproveIssue already moves physical stock per-item after WP2.)
4. **`ownershipLegs()`** (~:442-476) — at the top: if the resolved line carries a non-null `owner_partner_id`, return ONE leg `{quantity, ownership: owner>0?CUSTOMER:OWN, owner_id: owner, carry_batches:true}` and skip `fromOrderMaterial`/`allocateIssue`. Otherwise run the existing auto-split (legs get `owner_id => null`). Add an `owner_id` key to the leg shape.
5. **`allocateLegLots()`** (~:499-547) — replace the `$owner` derivation (~:516-518): `$owner = $leg['owner_id'] ?? ($routesToCustomer ? $tollCustomerId : InventoryLotBalance::OWNER_COMPANY)`; `$routesToCustomer = ($leg['ownership'] === CUSTOMER) && $owner > 0`. The `ConsignmentService::recordIssue(...)` call (~:538-546) uses that `$owner`. OFF-path (`owner_id === null`) must collapse to the EXACT current expression → byte-for-byte.
6. **WIP:** unchanged — only OWN legs feed `$ownIssued`/`actual_material_cost` (~:333-334,:371,:396-399). An override-customer line (single CUSTOMER leg) never hits WIP. Confirm.

## Fold-in from WP2 review (do this here)
- **WP2-M1 (lock ordering):** in `Modules/Inventory/app/Actions/ApproveIssue.php` (~:103-105) the deadlock-avoidance sort key is `product_id:product_variant_id`. Add the item's warehouse to the key so same-product-in-two-warehouses lines take stock-balance locks in a stable total order: sort key `sprintf('%011d:%011d:%011d', product_id, product_variant_id, (int)($item->warehouse_id ?? $issue->warehouse_id))`. Keep it byte-for-byte for single-warehouse (NULL) issues (the appended segment is constant → order unchanged).
- **WP2-M2 (COGS account):** WP3 stamps per-item warehouse ONLY on production-order issues (reference_type=production_order). Do NOT populate per-item warehouse on sales GDN issues — so `PostSaleCogsOnIssueApproved` stays correct (single-warehouse). Confirm your change is scoped to the production path only.

## Acceptance
- OFF-path: a production issue with NO per-line owner/warehouse override → identical stock, legs, WIP, ledger, cost as before (own + toll auto-split unchanged).
- Per-line consignor: a line with `owner_partner_id = X (>0)` → issues as a single CUSTOMER leg drawing partner X's off-book lots from the line's warehouse, bumps X's consignment ledger, does NOT hit WIP; own lines in the same issue still hit WIP.
- Per-line warehouse: a line with `warehouse_id = W` decrements W (via WP2's ApproveIssue) and allocates lots at W.
- Explicit owner=0 on a normally-customer material → issues as OWN (WIP).

## Tests (Pest, sqlite) — extend Production suites
- OFF-path regression: existing own/toll/split issue tests unchanged (baseline ProductionMaterialOwnershipTest 13 + over-issue 22).
- per-line consignor: order with an own material; issue a line with owner_partner_id=customerX from a consignment warehouse → CUSTOMER leg, X's lot balance drawn, X's ledger bumped, WIP NOT increased for that line; a sibling own line DOES hit WIP.
- explicit owner=0 pushes a customer/split material to OWN (WIP).
- per-line warehouse: line issued from warehouse W draws W's lots.
- Ignore the pre-existing ConsignmentFoundationTest settle-buy red.

## Flags
- **[FIN]** — WIP + consignment ledger + lot allocation. Architect re-consult on the diff. migration: none (WP1 did it). Conventional commit `feat(production): per-line consignor + warehouse routing on material issue (ISS-2026-9166)` on hazemdev2. No FE, no main, no deploy. chown if root.

## Out of scope
- Validation / availability pre-check (WP4). Listener rebuild / held echo (WP5). FE (WP6). Do NOT change the controller validation here (WP4). Do NOT change ApproveIssue except the one lock-key line (WP2-M1 fold).
