# WP2 — Production roles must hold `core.products.view`

**Repo:** BE (`/home/moonui2/moon-erp-be`) · **Branch:** `hazemdev2` · **Migration:** **YES** (bridge) · **FE change:** none

## The defect (verified, do not re-derive)

The shared product picker calls `GET /api/core/products`, gated by
`permission:core.products.view` (`Modules/Core/app/Http/Controllers/ProductController.php:36`,
`only: ['index','show']`).

**No seeded Production role holds it.** Verified two ways — by searching
`Modules/Production/database/seeders/ProductionRoleSeeder.php` (zero occurrences of any
`core.products.*` key) and by reading `moonui2_dev_be`:

| Role | Production perms | `core.products.view` |
|---|---|---|
| production_supervisor | 20 | **0** |
| production_manager | 20 | **0** |
| production_planner | 11 | **0** |
| production_cost_accountant | 32 | **0** |
| production_qc_manager | 11 | **0** |
| warehouse_storekeeper | 2 | **0** |

The Angular route guard checks the *Production* permission, so the screen opens normally; the
picker's request is then refused **403**, the interceptor does not surface 403, and the dropdown
just stays empty with no message. A production supervisor opens a BOM, searches for a component,
and finds nothing.

## Why a seeder edit alone is NOT enough

`ProductionRoleSeeder` is **not** in `config/moonstack.php` → `moonstack.updater.seeders`
(verified — the list contains `RolePermissionSeeder` and the setting-definition seeders, not this
one). Seeders outside that list run on a **fresh install only**. A seeder-only change is therefore
dead code for every client already live.

## What to build

1. **Grant the permission to the Production roles** in `ProductionRoleSeeder`. The seeder
   `syncPermissions` each role on every run and deliberately attaches only keys that already exist
   on the `web` guard (skip-with-log otherwise) — follow that existing convention exactly. Note
   `core.products.view` is owned by `Modules/Core/.../RolePermissionSeeder`, so it is a
   **sibling-owned** key: per this seeder's own documented rule, do **not** `firstOrCreate` it here,
   attach it only if it exists.
2. **Decide the role set deliberately and justify it in your report.** Every role that reaches a
   product picker needs it. At minimum the roles that can open BOMs / orders / MPS / consignment /
   routing / tools. `warehouse_storekeeper` (2 perms) is a judgement call — state your reasoning.
   **Grant `core.products.view` only** — do not hand Production roles create/update/delete on the
   product catalogue.
3. **Write a bridge migration** so the grant reaches installed clients. Follow the established
   pattern exactly — read these three first, they are the precedent and they explain the mechanism:
   - `Modules/Core/database/migrations/2026_08_01_620000_pos_refunds_permission_bridge.php`
   - `Modules/Core/database/migrations/2026_07_12_120000_wp8a_action_permissions_bridge.php`
   - `Modules/Core/database/migrations/2026_07_04_120000_grant_new_role_permissions_bridge.php`

   The pattern's load-bearing parts, all of which you must keep:
   - **`if (app()->runningUnitTests()) return;`** — `RefreshDatabase` re-runs `migrate` per test
     class; without this guard you re-seed the whole ~1000-permission catalogue once per test file.
     Your test calls the grant explicitly instead.
   - **try/catch + `Log::warning`, best-effort** — a grant hiccup must not roll back an update.
   - **`givePermissionTo`, never `sync`** — a client's custom role tweaks must survive.
   - **Idempotent** — safe to re-run against any database.
   - `down()` is a documented no-op.
   - Migration timestamp must sort **after** the existing latest.
4. **Run the migration on `moonui2_dev_be` in this same WP** (dev BE runs live — code without the
   migration means 1054s for everyone using dev).

## Acceptance criteria

1. **Red-before-green.** A test that authenticates as a `production_supervisor` and calls
   `GET /api/core/products` gets **403 today**; after the change it gets **200**. Paste both runs.
2. The grant is idempotent — running it twice changes nothing and throws nothing.
3. A role's **other** permissions are unchanged by the bridge (prove `givePermissionTo` semantics,
   not `sync`: give a test role a custom extra permission, run the grant, assert the extra survives).
4. Production roles gain **view only** — assert a production role still cannot
   `POST /api/core/products`.
5. `pest Modules/Production` and `pest Modules/Core` show **zero NEW failures** vs `../baseline.md`.
6. The migration ran on `moonui2_dev_be`; say so in your report with the migration name.

## 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).
- ⛔ NEVER `migrate:fresh` / `migrate:refresh` / `db:wipe` on `moonui2_dev_be` — not binlogged.
  Use `php artisan migrate --force` only.
- 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.
- Commit on `hazemdev2`, conventional (`fix(production): …`). **Do not push, do not merge.**
- moonui2 ONLY — never `/home/moonui`. Never print a git remote URL (embedded tokens).

## Out of scope

WP1's BOM save work · any FE change · granting Production roles anything beyond
`core.products.view` · touching the Core `RolePermissionSeeder` role definitions.
