# WP3 — Wire the warehouse scope through the Inventory module

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Migration:** no · **FE change:** none
**The biggest package in the feature.** This is where the security property actually starts existing.

## Goal

Make `ResourceScope` govern every Inventory read and every Inventory action, so that a keeper in mode
`assigned` sees and touches **only** his warehouses' data.

Until this WP lands, the engine (WP1) is unused and the invariant test (WP2) is red. **WP2's failure
list is your checklist** — read `../tasks/WP2-report.md` first; it enumerates the exact routes.

## Read first

- `../tasks/WP1-report.md` — the engine API, table names, setting keys. Written for you.
- `../tasks/WP2-report.md` — the failing-route list, the exemption allow-list and its reasons, and the
  mechanism that marks the expected red (you flip it on).

## The scope of the work

Roughly **~30 read endpoints across 12 controllers** plus **~20 mutation endpoints**:

`StockBalanceController` (index · lotOwners · byProduct · productLots · byWarehouse) ·
`StockCardController` (stockCard · movements) · `InventoryIssueController` ·
`InventoryReceiptController` · `InventoryTransferController` · `InventoryAdjustmentController` ·
`InventoryCountController` (incl. `productsForWarehouse`) · `OpeningBalanceController` ·
`InventoryReportController` (9 report endpoints) · `ReorderAlertController` · `CostingController` ·
`WarehouseController`.

## The five things that make this hard — handle each deliberately

1. **Scoping the lists is not enough.** `show`, `update`, `approve`, `cancel`, `ship`, `receive` and
   friends fetch by id with only a company guard. A keeper could open or approve another warehouse's
   issue by id. **Out-of-scope by-id access must return 404**, not 403 — 403 confirms the record
   exists (settled decision).
2. **The totals/summary queries are separate raw queries.** `StockBalanceController::companyTotals()`
   and much of `InventoryReportController` are `DB::table` joins, invisible to Eloquent scoping. They
   must use the engine's report variant. **If the totals card disagrees with the filtered list, the
   feature is broken in the most confusing possible way** — the keeper sees a company-wide value
   above his own rows.
3. **Transfers have two warehouse columns.** Per WP1's decision the row is visible when **either**
   endpoint is in scope. Pass both columns; do not scope on one and forget the other.
4. **Aggregates have no creator.** `stock_balances` and `movements` cannot honour `own_records`; WP1
   defined the fallback — use it, do not improvise a second answer here.
5. **`below_reorder` and similar filters do raw joins with a `select` reset.** Composing a new
   `whereHas`/`whereIn` with them is fine, but the `select('inventory_stock_balances.*')` reset must
   survive or eager loading silently breaks.

## Non-negotiable behaviours

- **Mode `all` (the default) must produce byte-identical results to today.** Every existing install
  upgrades into this WP unchanged. This is the single most important regression property.
- **Mode `assigned` + no assignment ⇒ no rows** — never all rows.
- **System/unauthenticated contexts stay exempt** — postings, listeners and queued jobs must keep
  working. WP1 built the exemption; make sure every wired path honours it.
- **Do not change** pagination, ordering, or any existing filter's semantics.

## Acceptance criteria

1. **WP2's invariant test goes GREEN** for Inventory, with its expected-red marker removed. That test
   is the acceptance criterion — if it passes honestly, the module is covered.
2. A keeper assigned to 1 of 3 warehouses: lists, reports, totals, stock card and balances all show
   only his warehouse's data — assert the **totals** as well as the rows.
3. By-id access to an out-of-scope record returns **404** on `show` **and** on at least two mutation
   endpoints (e.g. `approve`, `cancel`).
4. Mode `all` returns exactly what it returns today — pin this with a test that would fail if any
   default-path behaviour shifted.
5. A transfer is visible from **both** its source and destination keeper's side.
6. A posting/listener path that runs without an authenticated user still succeeds.
7. `pest Modules/Inventory` shows **zero NEW failures** vs `../baseline-inventory.txt`.


## ⚠️ Hand-off detail from WP1 — do not miss this

WP1 seeded the mode settings with **`is_implemented = false`**, so the settings choke point currently
**refuses any write to the mode**. That is deliberate: until the wiring exists, an admin must not be
able to switch a company into a mode that nothing enforces.

**Flipping `is_implemented => true` for `inventory.warehouse_data_scope` is part of THIS work package**, and it is
the last thing you do — after the wiring is green. Re-seed and confirm the setting becomes writable.

Also from WP1, exactly as built:
- `ResourceScope::apply($query, $user, 'warehouse', $opts)` · `::applyReport($query, ...)` for raw
  `DB::table` queries. Option keys: **`columns`** (1–2 columns; two ⇒ OR semantics — transfers pass
  both `from_warehouse_id` and `to_warehouse_id`), **`owner`** (string, or an explicit `null` to
  select the aggregate fallback).
- **`$user === null` is the documented system-context exemption** — postings and queued jobs pass
  through unrestricted. Do not "helpfully" pass a fallback user.
- Creator-less aggregates (`stock_balances`, `movements`) under `own_records` fall back to
  **`assigned`**, never to unrestricted — WP1 asserts this; do not re-decide it.
- Roles can only **narrow** the company mode, never widen it.

## Environment / rules

- Tests: `cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest --filter='…'`
  (bare `php` is php-cgi → "Undefined constant STDOUT").
- **Run your own test file + WP2's invariant test + `pest Modules/Inventory`.** This is the one WP
  where the module suite is worth running mid-feature, because it is the regression surface.
- ⛔ **Pest loads every test file into ONE process** — prefix every top-level helper with its file's
  subject. Duplicate top-level function = fatal redeclare, exit 255, zero output (5 occurrences here).
- ⛔ NEVER `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` — not binlogged.
- API auth header is `X-Authorization: Bearer` (not `Authorization`).
- `./vendor/bin/pint` on touched files only. `chown moonui2:moonui2` every edited file.
  `bash local-deploy.sh` after BE edits.
- Add a bilingual `[Unreleased]` bullet — **this is the first user-visible change of the feature**.
  Say plainly that nothing changes until a company switches the mode on.
- Commit on `hazemdev2`, conventional. **Do not push, do not merge.**
- moonui2 ONLY — never `/home/moonui`. Never print a git remote URL.

## Out of scope

Any FE change (WP4) · cash boxes (WP6) · cost centres / POS (WP7) · deleting the legacy
`manager_id` column.
