# WP10 — Production roles need the LOOKUP permissions their screens actually call

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Migration:** **YES** (bridge) · **FE change:** none
**Added mid-run.** WP2 fixed the product picker; this finishes the job it started.

## Why this exists

WP2 (`27519dbe6`) granted `core.products.view` to 10 roles, and its own report flagged the risk:
*"if a factory screen resolves units/categories via a separate gated endpoint that's a distinct 403
this WP doesn't cover."*

The orchestrator checked. **It does, and the gap is wider than products was.** Queried against
`moonui2_dev_be`, **zero** of the eight Production roles hold **any** of these:

| Permission | Gate | Production screens that call it |
|---|---|---|
| `core.units.view` | `UnitController` `index/show` | **boms**, orders, toll |
| `inventory.warehouses.view` | `WarehouseController` `index/show/tree` | consignment, orders, toll, reports-inventory-costs |
| `core.partners.view` | `BusinessPartnerController` `index/show` | consignment, orders, toll |

**`core.units.view` is the blocking one.** `boms.component.ts:241` calls `unitService.listAll()` →
`GET /core/units`, and `unit_id` carries `Validators.required` on the BOM header **and on every
component line** (`:233`, `:344`, `:386`). So for a production user the units dropdown is empty and
**the BOM cannot be saved at all** — the exact thing WP1 and WP5 just made work for everyone else.

## What to build

Grant these three, **view only**, to the same role set WP2 used, by the same mechanism:
seeder edit **plus** a bridge migration (role seeders are not in `moonstack.updater.seeders`, so a
seeder-only change is dead code on every installed client).

Read WP2's implementation first — `Modules/Production/database/seeders/ProductionRoleSeeder.php` and
`Modules/Production/database/migrations/2026_08_04_100000_production_roles_products_view_bridge.php`
— and follow it exactly. Same `runningUnitTests()` guard, same `givePermissionTo` (never `sync`),
same best-effort try/catch + `Log::warning`, same idempotency, same no-op `down()`. New timestamp
sorts after WP2's.

**Grant per need, not in bulk.** Deriving the role set from the screens:
- `core.units.view` → the roles that can open BOMs / orders / toll contracts
- `inventory.warehouses.view` → the roles that can open consignment / orders / toll / cost reports
- `core.partners.view` → the same consignment / orders / toll set

If that yields the same 10 roles WP2 used, say so and grant uniformly. If a role genuinely never
reaches a screen needing one of them, leave it out and justify it. `machine_operator` stays excluded
(terminal-only; an existing test pins it at exactly 3 permissions — **do not break that test**).

**View only.** No create/update/delete on units, warehouses, or partners.

## Deliberately NOT in this WP — report, do not grant

The same sweep found three more gaps, all belonging to screens outside this feature and all crossing
into wider surfaces. **List them in your report; do not grant them.** The owner decides:

| Permission | Screen | Why it is held back |
|---|---|---|
| `core.branches.view` | factory-dashboard | org-structure surface |
| `accounting.tax-rates.view` | orders | crosses into Accounting |
| `core.users.view` | variances | user-directory surface |

## Acceptance criteria

1. **Red-before-green.** As `production_supervisor`: `GET /api/core/units` is **403 today**, **200**
   after. Same for `GET /api/inventory/warehouses` and `GET /api/core/partners`. Paste both runs.
2. The grant is idempotent — running it twice changes nothing and throws nothing.
3. `givePermissionTo` semantics proven, not assumed: give a test role a custom extra permission, run
   the grant, assert the extra survives.
4. View only — assert a production role still cannot `POST /api/core/units`.
5. `machine_operator` still holds **exactly 3** permissions (its existing test must stay green).
6. `pest Modules/Production` and `pest Modules/Core` show **zero NEW failures**. Current known-good:
   Production **632 P / 10 F** (+ whatever WP9 added), Core **719 P / 10 F**. The 10 Production
   failures are ConsignmentFoundation ×1, CostAiAnomalyVariance ×3, ProductionVariance ×6.
7. The migration ran on `moonui2_dev_be` — name it in your report, and read the grant back from the
   DB rather than trusting the seeder.

## 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").
- Scoped tests only: your new file + `pest Modules/Production` + `pest Modules/Core`.
- ⛔ **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).
- ⛔ The bridge migration MUST carry `if (app()->runningUnitTests()) return;` — `RefreshDatabase`
  re-runs `migrate` per test class; without it you re-seed the ~1000-permission catalogue per file.
- ⛔ NEVER `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` — not binlogged.
  `php artisan migrate --force` only, and run it in THIS WP.
- 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 to `docs/moonstack/CHANGELOG.md`.
- Commit on `hazemdev2`, conventional. **Do not push, do not merge.**
- moonui2 ONLY — never `/home/moonui`. Never print a git remote URL.

## Out of scope

The three held-back permissions above · any FE change · `core.product-categories.view` (no
Production screen was found calling it — say so if you find otherwise).
