# WP2 — [FIN] ApproveIssue: per-item source warehouse

## Goal
`Modules/Inventory/app/Actions/ApproveIssue.php` currently moves stock, checks availability, and prices EVERY item against ONE warehouse (`$issue->warehouse_id`). Make each item use its OWN warehouse (`$item->warehouse_id`) when set, falling back to `$issue->warehouse_id` when NULL. This is what lets a production issue draw different materials from different warehouses. **HIGHEST blast radius: ApproveIssue is shared by sales GDNs, adjustments, consumption, and production issues — every one of those must be byte-for-byte unchanged, because all their items have `warehouse_id = NULL` (WP1 default) so the fallback returns the issue warehouse.**

## Exact files
- `Modules/Inventory/app/Actions/ApproveIssue.php` — the seams (line numbers approximate; anchor on the code):
  - `:61` — `$issue->warehouse->allow_negative_stock` read ONCE for the whole issue → must become per-item (resolve the item's warehouse's `allow_negative_stock`).
  - `:161` — serial-warehouse resolution uses `$issue->warehouse_id` → per item.
  - `:192-197` — availability check + stock lock uses `$issue->warehouse_id` → per item.
  - `:225` — `getIssueCost(...)` warehouse → per item.
  - `:244` — `decreaseStock(...)` warehouse → per item.
  - `:270` — (context) → per item if it references the issue warehouse.
- Pattern for every seam: `$wh = (int) ($item->warehouse_id ?? $issue->warehouse_id);` and use `$wh`. For `allow_negative_stock`, resolve `Warehouse::find($wh)?->allow_negative_stock` (cache per-$wh within the loop to avoid N queries — a small `[$whId => bool]` map).

## The invariant (MUST hold — this is the whole risk)
- When `item.warehouse_id IS NULL` (every sales GDN item, adjustment, consumption, and any production line that didn't set a per-line warehouse) → `$wh === $issue->warehouse_id` → **byte-for-byte identical to today.** No extra query paths, same stock rows, same cost, same GL.
- Only when `item.warehouse_id` is a non-null value (set by WP3's production per-line routing) does the item draw from that warehouse.

## Interfaces (for WP3/WP5)
- After WP2, `ApproveIssue` honors `inventory_issue_items.warehouse_id` per item. WP3 stamps it; WP5's keeper rebuild must ensure it's on the draft items before approval.
- Do NOT touch `owner_partner_id` here — that's WP3's ownership routing (ApproveIssue moves PHYSICAL stock owner-agnostically, as today; the owner-scoped lot layer is IssueMaterials::allocateLegLots).

## Acceptance
- A sales GDN (items warehouse_id NULL) posts identically to before (stock, cost, status, GL unchanged).
- An issue whose items carry different warehouse_id decrements stock at each item's own warehouse; availability + allow_negative resolved per that warehouse.
- Adjustments / consumption / partial-issue paths unchanged.

## Tests (Pest, sqlite)
- **Regression (critical):** an existing sales-GDN / inventory-issue approval test still passes unchanged (items warehouse_id NULL → issue warehouse). Confirm InventoryIssueApiTest + PartialIssueApprovalTest stay green (baseline: InventoryIssueApiTest 27, PartialIssue green).
- **New:** an inventory issue with two items in TWO different warehouses → approve → each item's stock decremented at its own warehouse (assert stock_balances per warehouse). Cover allow_negative resolved per item warehouse.
- Baseline pre-existing red: `ConsignmentFoundationTest` settle-buy — ignore.

## Flags
- **[FIN]** — moves stock + prices + (downstream) GL. Architect re-consult on the diff (this is one of the two highest-risk WPs). migration: none. Conventional commit `feat(inventory): ApproveIssue honors per-item warehouse (ISS-2026-9166)` on hazemdev2. No FE, no main, no deploy. chown if edited as root.

## Out of scope
- Ownership / consignment routing (WP3). Per-line validation (WP4). Listener rebuild (WP5). Only the physical warehouse per item in ApproveIssue.
