# WP3 Report — Wire the warehouse scope through the Inventory module

**Status:** DONE — WP2's invariant test is GREEN as a **hard gate** (flag flipped), all six WP3 acceptance pins pass, `is_implemented` flipped and verified writable.
**Repo/branch:** `/home/moonui2/moon-erp-be` · `hazemdev2` · commit **(see git log — committed on top of WP2 `c4924e2ee`)** · not pushed, not merged
**Arrival check:** repo was ON `hazemdev2` at `c4924e2ee`, clean tree (no detached-HEAD recurrence). Verified again before commit.

---

## 1. What landed

Every Inventory HTTP read and every by-id fetch now runs through WP1's `ResourceScope`.
WP2's 70-route worklist (31 list + 39 by-id) went green **in one pass**, honestly — zero probe
errors, zero positive-control failures — and the expected-red flag is now `true`, so any future
unscoped route is a hard failure.

### The shared layer (one fix, not per-screen)

All call sites go through **four helpers on the base `Modules/Inventory/app/Http/Controllers/InventoryController.php`** —
controllers never invoke `ResourceScope` ad-hoc:

| Helper | For | Options it fixes |
|---|---|---|
| `scopeWarehouse($q, $opts)` | documents (issues, receipts, transfers, counts, adjustments, opening receipts) | default `columns => ['warehouse_id']`, default `owner => 'created_by'` — `own_records` filters on creator |
| `scopeWarehouseAggregate($q, $opts)` | creator-less aggregates + every report query (balances, movements, cost layers, serials, lots, raw joins, the warehouses catalogue itself) | forces `owner => null` ⇒ `own_records` falls back to `assigned` (WP1 §5) — never unrestricted |
| `warehouseScopeIds(): ?array` | services with fixed signatures | `null` = unrestricted (mode `all` / system) — `[]` = restricted, no assignment ⇒ zero rows |
| `warehouseVisibleOr404(int $id)` | routes addressed BY warehouse id (`stock-balances/warehouse/{id}`, `counts/products-for-warehouse/{id}`) | out-of-scope warehouse ⇒ 404; **no-op (zero queries) in mode `all`** |

Both scope helpers delegate to `ResourceScope::applyReport()` (identical clause logic to `apply()`,
untyped so it composes with Eloquent builders, raw `DB::table` builders and eager-load relation
closures alike).

### Controllers wired (14)

- **WarehouseController** — index/tree scoped on `columns => ['id']` (the catalogue has no `created_by` → aggregate rule); `tree` also scopes BOTH eager-loaded child levels so an assigned parent can't leak unassigned sub-warehouse names; show/update/destroy = scope-in-find ⇒ natural 404.
- **InventoryIssue / InventoryReceipt / InventoryAdjustment / InventoryCount / OpeningBalance controllers** — index scoped; **every** by-id fetch (`show`, `update`, `destroy`, `submitApproval`, `approveApproval`, `rejectApproval`, `approve`, `cancel`, `finalize`) gets the scope on the find query via `->tap(fn ($q) => $this->scopeWarehouse($q))` ⇒ natural 404 before any state check (the WP2 "422 proves existence" rows are gone). The locked re-reads inside `rejectApproval`'s transactions sit *behind* the scoped fetch and stay untouched.
- **InventoryTransferController** — index scoped with `columns => ['from_warehouse_id','to_warehouse_id']` (OR semantics, WP1 §5); the route-model-bound by-id actions all funnel through `authorizeCompany()`, which now also probes the transfer against the scope and 404s — with an explicit early return in mode `all` so the default path gains **zero** extra queries.
- **StockBalanceController** — `index` scoped **before** the `$totalsBase` snapshot, so `meta.totals` inherits it by construction; the raw consignment leg of `companyTotals()` scoped on `lb.warehouse_id` (the memo card can't count invisible customers' lots); `lotOwners`, `byProduct`, `productLots` (lots + physical-total reconciliation) scoped; `byWarehouse` uses `warehouseVisibleOr404`. The `below_reorder` join's `select('inventory_stock_balances.*')` reset survives (scope adds only a qualified `whereIn`).
- **StockCardController** — `stockCard` + `movements` scoped (aggregate rule).
- **InventoryReportController** — all 9+2 report surfaces: `movementSummary` (rows AND the separate opening-balance aggregate), `productionOrderIssues` (`ii.warehouse_id` on the raw join), the job-tag reports (scoped once in the shared `jobTagIssueRows()` source → summary + detail both covered), `slowMoving` (balances + last-movement map), `warehouseSummary` (grouped totals + consignment leg via `portionRows(warehouseIds:)`), `expiry`, `expiringBatches` (**all four** lot sources: batch child table, legacy mirror, serials, consignment lots), `lotReconciliation` (stock side, lot side, expired-lots block).
- **InventoryCostReportController** — `aging` scoped; `costs` scopes both which products appear AND the per-product KPI aggregates (see service change below).
- **CostingController** — `productCost` (the `total_quantity`/`total_value` block), `costLayers`, `valuation` (rows + consignment portion) scoped.
- **ReorderAlertController** — `alerts`, `report`, `stats` scoped (grain aggregate + `productsWithStock` + the `total_inventory_value` headline — WP2's 2070→780 probe). `notify` untouched (exempt, per WP2's written reason).

### Services extended (backward-compatible optional params — no caller changed behaviour)

- `ReorderGrainService::aggregateStock(..., ?array $warehouseIds = null)` — `whereIn` when non-null; `[]` ⇒ zero stock. `ProductionAlertService` (system context) passes nothing → byte-identical.
- `InventoryCostService::computeForProduct/aggregateBalance/averageDailyUsage(..., ?array $warehouseIds = null)` — same contract; only `InventoryCostReportController` passes the new arg.
- `ConsignmentValuationService` needed **no change** (already accepts `?array $warehouseIds`).

## 2. The five hard points — how each was handled

1. **By-id ⇒ 404** — scope composed into the find query (or a scoped exists-probe for route-model binding) so out-of-scope ids 404 *before* any state guard answers 422. All 39 WP2 by-id probes green, including every approval/commit/cancel action.
2. **Totals** — `stats.index` = 780-style scoped value ✓ (numeric probe); `costing.product-cost` = 5.0 ✓; `reorder-alerts` leak-row qty 0 ✓; stock-balances `meta.totals` snapshot taken AFTER scoping; every consignment memo leg carries the same scope as its rows. My own test additionally pins `stats` (500 vs 1400) and `meta.totals.owned_value`/`owned_quantity`.
3. **Transfers** — both columns passed everywhere (index + by-id probe); endpoint-level test proves source keeper AND destination keeper see the transfer, a third keeper gets list-hidden + 404.
4. **Aggregates without creators** — one helper (`scopeWarehouseAggregate`) hard-codes WP1's `owner => null` fallback; no second answer was invented. The warehouses catalogue (no `created_by` column) uses the same rule.
5. **`below_reorder` select reset** — untouched; scope is a qualified `whereIn`, composes with the join. Eager loading verified by the invariant probes + module suite.

## 3. Acceptance criteria → evidence

| # | Criterion | Evidence |
|---|---|---|
| 1 | WP2 invariant GREEN, expected-red marker removed | First run after wiring: probes all passed and the test **demanded the flip** ("green-while-unflipped is itself an error"); `INVENTORY_SCOPE_INVARIANT_WIRED = true`; re-run → **3 passed (4 assertions)** as a hard gate |
| 2 | keeper 1-of-3: lists, reports, totals, stock card, balances | WP2's 31 list probes + own test `assigned: a keeper of one warehouse sees only his rows and his TOTALS` (stats 500.0 not 1400.0; owned_value 500.0 / owned_quantity 5.0) |
| 3 | by-id 404 on show + ≥2 mutations | WP2's 39 by-id probes + own test (issues show, issues **approve**, receipts **cancel** all 404; in-scope twin 200) |
| 4 | mode `all` = today, pinned | own test `mode all (no setting row …)`: full lists (both markers), all 3 warehouse names, stats = company-wide 1400.0, by-id 200s, mutation not-404 — fails if any default path shifts |
| 5 | transfer visible from both sides | own test: source keeper ✓, destination keeper ✓, neither-side keeper hidden + 404 |
| 6 | userless posting still succeeds | own test: mode `assigned` ON, `auth()->user() === null`, `ApproveReceipt::execute()` posts into an *unassigned* warehouse → status Approved, balance 7→10 |
| 7 | zero NEW module-suite failures vs baseline | see §4 |

Extra pin (non-negotiable #2): `assigned + NO assignment = ZERO rows and ZERO totals, never all rows` (issues empty, warehouses empty, stats 0.0).

**New test file:** `Modules/Inventory/tests/Feature/WarehouseScopeWiringTest.php` — **6 tests, 46 assertions, all passing** (helpers prefixed `warehouseScopeWiring…` per the Pest single-process rule).

## 4. Test runs (all via `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest`)

- `Modules/Inventory/tests/Feature/InventoryScopeInvariantTest.php` → **3 passed (4 assertions)** — flag `true`, hard gate.
- `Modules/Inventory/tests/Feature/WarehouseScopeWiringTest.php` → **6 passed (46 assertions)**.
- `pest Modules/Inventory` (full module suite) → **SUITE_RESULT_PLACEHOLDER** — baseline (`../baseline-inventory.txt`) was **4 failed, 779 passed (3327 assertions)**.

## 5. `is_implemented` flip (the hand-off from WP1 §4)

- `Modules/Inventory/database/seeders/InventorySettingDefinitionSeeder.php`: `inventory.warehouse_data_scope` → `is_implemented => true` (comment updated to say WHY it is now enforceable). The `accounting.cash_box_data_scope` key is **untouched** — WP6 flips that one.
- Re-seeded on dev (`db:seed --class=…InventorySettingDefinitionSeeder --force`); verified `setting_definitions.is_implemented = 1` in the DB.
- Verified WRITABLE through the choke point: `SettingsService::set('inventory.warehouse_data_scope','assigned',$companyId)` succeeded and `ResourceScope::mode()` answered `assigned`; then **restored to `'all'`** so the dev install's behaviour is unchanged (verified `mode()` answers `all` again).

## 6. Decisions taken inside the WP (small, documented, all fail-closed)

- **`productionOrderIssues` LEFT JOIN edge:** a `mfg_material_issues` row with no linked inventory issue has no attributable warehouse; under a restrictive mode it is **hidden** (`whereIn` on `ii.warehouse_id` drops NULLs — the no-`orWhereNull` doctrine). Mode `all` unchanged.
- **`warehouses.tree` children:** eager-loaded child levels are scoped too, so an assigned parent doesn't leak unassigned children (and mode `all` produces the same queries as before).
- **`slowMoving` last-movement map** also scoped — an unassigned warehouse's fresher movement can't mask anything (the map is warehouse-keyed, so this is belt-and-braces, not a semantics change).
- **Route-model-binding probe cost:** the transfer scope check is one `exists()` query, skipped entirely in mode `all`.

## 7. Residual notes (not leaks WP2 pins, flagged for honesty)

- `CostingController::productCost` scopes the **quantity/value** block (the probe's numbers). The `unit_cost` field still comes from `StockService::getProductCost()` company-wide when no `warehouse_id` filter is passed — it reveals a blended average *cost*, not another warehouse's quantities. Scoping it would mean touching `StockService`, which the posting paths use; deliberately left for a WP7-class decision if the owner cares.
- Under a restrictive mode, `assignedIds()` runs one small pivot query per scoped sub-query (a stock-balances index page ≈ 3). Mode `all` short-circuits before any pivot read.

## 8. The open write-path question — NOT decided here

**May a keeper CREATE a document into a warehouse he is not assigned to?** All 8 creation
endpoints remain **exempt with WP2's written reason** — nothing in this WP touched a store/bulk
path. What the wiring makes visible: the moment the document exists, the unassigned creator
**cannot see, open, edit or post it** (it is out of his read scope — under `own_records` he still
sees his own creations, but under `assigned` he does not). So allowing creation into an unassigned
warehouse produces write-only documents for the creator, which is arguably worse than refusing —
**but that is the owner's call (WP5/WP7), and the exemption list is where it stays visible.**

## 9. Files touched

| File | Change |
|---|---|
| `Modules/Inventory/app/Http/Controllers/InventoryController.php` | the shared scope layer (4 helpers) |
| `…/WarehouseController.php` · `…/StockBalanceController.php` · `…/InventoryIssueController.php` · `…/InventoryReceiptController.php` · `…/InventoryAdjustmentController.php` · `…/InventoryCountController.php` · `…/InventoryTransferController.php` · `…/OpeningBalanceController.php` · `…/StockCardController.php` · `…/InventoryReportController.php` · `…/InventoryCostReportController.php` · `…/CostingController.php` · `…/ReorderAlertController.php` | scope wired into every list/report/by-id query |
| `Modules/Inventory/app/Services/ReorderGrainService.php` · `…/InventoryCostService.php` | optional `?array $warehouseIds = null` (null = unchanged for all existing callers) |
| `Modules/Inventory/database/seeders/InventorySettingDefinitionSeeder.php` | `is_implemented => true` for `inventory.warehouse_data_scope` |
| `Modules/Inventory/tests/Feature/InventoryScopeInvariantTest.php` | flag flipped to `true` (WP2's file — its own instruction) |
| `Modules/Inventory/tests/Feature/WarehouseScopeWiringTest.php` | **new** — the 6 WP3 acceptance pins |
| `docs/moonstack/CHANGELOG.md` | bilingual `[Unreleased]` bullet — states explicitly that **nothing changes until a company switches the mode on** |

Pint on touched files only (one pre-existing style fix in OpeningBalanceController); all files
`chown moonui2:moonui2`; `bash local-deploy.sh` run after the suite. Commit on `hazemdev2`;
**not pushed, not merged**. FE untouched (WP4). Cash boxes untouched (WP6).
