# WP1 — Foundation: the scope engine, the assignment tables, the mode settings

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Migration:** YES · **FE change:** none
**Nothing is wired to a controller in this WP.** Engine + schema + settings + tests only.

## Why this exists

Today a warehouse keeper sees **every stock document in the company** — the only SQL condition is
`company_id`. `warehouses.manager_id` and `petty_cash.custodian_id` exist, are set from the UI, and
are **read by zero queries** (verified: they appear only in `$fillable` and a relation). All existing
"scoping" is done **in the browser** after the rows arrive.

This WP builds the machinery. WP3 wires it.

## What already exists — build on it, do not rebuild

`Modules/Core/app/Support/DataScope.php` is a working, tested scope engine used in **~35 call sites**
across LIS and Clinic (and **zero** in Inventory/Accounting/POS/Sales/Purchases). It has:

- three modes `own` / `branch` / `all`, from `roles.data_scope`
- `apply()` — `own` ⇒ `where(created_by, user.id)` (owner column overridable); `branch` ⇒
  `whereIn(branch_id, userBranchIds)`; `all` ⇒ unrestricted
- `applyReportBranch()` — the same for raw `DB::table` report queries
- `operatingBranchId()` — stamps new records
- `effectiveScope()` — resolves across a user's roles
- dedicated tests (`tests/Feature/DataScopeBranchNullTest.php`, `LisCashierReconciliationScopeTest.php`)

`branch_user` (+`is_primary`) and `bank_account_branch` (+`is_primary`) are the two existing
assignment-pivot precedents. Follow their shape; do **not** introduce a third idiom (no polymorphic
`scopables` table — it matches nothing here and no existing helper can build its queries).

## ⚠️ Four fail-open behaviours that must NOT be inherited

The existing engine fails open in three documented ways, and a fourth trap is new to this feature.
**Getting these wrong makes the feature worse than not shipping it**, because the screens will look
protected.

| # | Existing behaviour | Required behaviour here |
|---|---|---|
| 1 | **Broadest wins** across roles — and a past migration backfilled **every pre-existing role to `all`**, so adding any ordinary second role silently removes the restriction (the class docblock says so) | **Narrowest wins.** And resource assignment governs regardless of role. |
| 2 | `orWhereNull(branch_id)` — a resource with no branch is visible to everyone | No such escape on the resource axis. |
| 3 | **Empty assignment ⇒ unrestricted** ("no branches assigned → don't restrict") | **Empty assignment ⇒ no rows**, when the mode is `assigned`. |
| 4 | — | **Background jobs run with no authenticated user.** Postings, queued approvals and console commands must be **explicitly exempt**, or they will crash or filter everything away. Decide the exemption rule deliberately and document it in the code. |

## Structural facts that shape the design (verified — do not re-derive)

- **Inventory documents have NO `branch_id` column.** They have `warehouse_id`. So the scope axis
  here is the **resource**, not the branch — `DataScope::apply()`'s hardcoded `branch_id` cannot serve.
- **`inventory_transfers` has TWO warehouse columns** (`from_warehouse_id`, `to_warehouse_id`). A
  single-column design cannot express it. Decide and document the semantics — recommendation: the row
  is visible if **either** endpoint is in scope, since both keepers legitimately need to see it.
- **`inventory_stock_balances` and `inventory_movements` have no creator column** — mode `own_records`
  is inapplicable to them by nature; only resource scoping is meaningful. Aggregates must not silently
  return everything when the mode is `own_records` — decide the fallback and document it.
- The five inventory **documents** (`issues`, `receipts`, `adjustments`, `transfers`, `counts`) all
  have `created_by`, and it **is** populated on create — so `own_records` is data-ready for them.
- **There are ZERO global scopes in the codebase** (`addGlobalScope` count: 0), and company scoping is
  hand-written per controller method (8 occurrences in `InventoryIssueController` alone). `scopeTenant()`
  exists to centralise it and is barely used — proof that an opt-in helper is not a default.

## What to build

1. **The engine.** Extend the `DataScope` family (do not fork it) so a caller can scope by a
   **resource column** with:
   - a configurable resource column, and support for **two** columns on one row (transfers);
   - an optional owner column for `own_records`;
   - the four fail-closed behaviours above;
   - a raw-query variant mirroring `applyReportBranch()`, because half the reports are `DB::table`
     joins that no Eloquent mechanism will ever touch;
   - an explicit, documented **exemption** for unauthenticated/system contexts.
   Keep the existing branch behaviour working unchanged — LIS and Clinic depend on it.
2. **Assignment tables.** `warehouse_user` and `petty_cash_user` on the `branch_user` pattern
   (resource id, user id, unique pair, timestamps). Multi-assignment must be expressible — that is the
   whole point; `manager_id` is 1:1 the wrong way round.
3. **Legacy migration, non-enforcing.** Copy existing `warehouses.manager_id` and
   `petty_cash.custodian_id` values into the new tables **as a starting point**. Do **not** delete the
   old columns in this WP. These columns have been settable for months with **no effect**, so their
   contents are unvalidated — the data must be reviewable before it becomes a live restriction
   (WP5 surfaces it). Log/report what was migrated.
4. **Mode settings, per resource type.** Using the existing settings mechanism
   (`SettingDefinitionSeeder` + `SettingsService`): an enum per resource type, e.g.
   `inventory.warehouse_data_scope` and `accounting.cash_box_data_scope`, values
   `all | assigned | own_records`, **default `all`** (= today's behaviour), `scope: 'company'`,
   bilingual labels and descriptions.
   ⚠️ **Read the mode at COMPANY scope only.** `SettingsService::get()` supports per-branch and
   per-user overrides by design — a security mode must never be softenable by a user-scoped row.
   Use the exact-read variant and say in your report which you used.

## Acceptance criteria — all by test

1. Mode `all` ⇒ identical rows to today (the no-op case; this is what protects every existing install).
2. Mode `assigned` + user assigned to 2 of 5 warehouses ⇒ only those two warehouses' rows.
3. **Mode `assigned` + user assigned to NOTHING ⇒ ZERO rows.** Not "all rows". This single test is
   the difference between a security feature and a decoration.
4. **Narrowest wins:** a user with a restricted role **and** an `all` role gets the restricted result.
5. A transfer whose `from_warehouse_id` is in scope and `to_warehouse_id` is not (and vice versa)
   behaves per your documented decision — assert both directions.
6. Mode `own_records` on a document type filters by creator; on a balance/movement aggregate it
   behaves per your documented fallback — assert it rather than leaving it implicit.
7. **A system/unauthenticated context is exempt** — a posting run with no logged-in user still writes
   and still reads what it needs. Assert it; this is criterion 4 of the fail-open table.
8. The mode setting cannot be softened by a per-user or per-branch settings row — assert that a
   user-scoped row saying `all` does not defeat a company mode of `assigned`.
9. **Existing branch scoping is untouched** — LIS and Clinic tests stay green.
10. The legacy migration is idempotent and re-running it creates no duplicates.

## 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 plus `pest Modules/Core` only.** The full suites run once at Phase C —
  do not run them here. (This is a deliberate change to keep the run short.)
- ⛔ **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**. Five occurrences
  in this project.
- ⛔ NEVER `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` — not binlogged.
  `php artisan migrate --force` only, and run it in THIS WP (dev BE runs live).
- New migration timestamps must sort after the existing latest.
- 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.
- **No CHANGELOG bullet yet** — nothing is user-visible until WP3 wires it. Phase C writes the notes.
- Commit on `hazemdev2`, conventional. **Do not push, do not merge.**
- moonui2 ONLY — never `/home/moonui`. Never print a git remote URL.

**Your report must state the exact API of the engine** (method signatures + options), the exact table
and column names, and the exact setting keys — WP2 and WP3 are written against them and cannot see
your code.

## Out of scope

Wiring any controller (WP3) · any FE change · the `petty_cash_transactions` creator column (WP6) ·
deleting the legacy columns · cost centres / POS (WP7).
