# WP10 report — Production roles get the LOOKUP permissions their screens call

**Repo:** BE `/home/moonui2/moon-erp-be` · **Branch:** `hazemdev2` · **Commit:** `36b54271e`
(`36b54271e08ac4594e9507a0b7db2dad58f448fe`) — not pushed, not merged
**FE change:** none · **Migration:** yes, and applied to `moonui2_dev_be`

---

## 1. The verified gap (before)

Queried against the live dev DB `moonui2_dev_be` before any change. Zero of the
eleven seeded Production roles held any of the three lookup reads:

```
role                          core.units.view  inventory.warehouses.view  core.partners.view   TOTAL
production_supervisor         --               --                         --                   21
production_manager            --               --                         --                   21
production_planner            --               --                         --                   12
production_cost_accountant    --               --                         --                   35
production_qc_manager         --               --                         --                   12
production_auditor            --               --                         --                   21
production_materials          --               --                         --                   25
warehouse_storekeeper         --               --                         --                   24
quality_inspector             --               --                         --                    6
cost_accountant               --               --                         --                    5
machine_operator              --               --                         --                    3
```

All three permission rows already existed on the `web` guard (Core
`RolePermissionSeeder` owns them) — the rows were there, nobody was attached.

**Gates confirmed in code, not assumed:**

| Permission | Gate |
|---|---|
| `core.units.view` | `Modules/Core/app/Http/Controllers/UnitController.php:27` — `index`, `show` |
| `inventory.warehouses.view` | `Modules/Inventory/app/Http/Controllers/WarehouseController.php:25` — `index`, `show`, `tree` |
| `core.partners.view` | `Modules/Core/app/Http/Controllers/BusinessPartnerController.php:34` — `index`, `show`, `debtChanges` |

**Screen → service map confirmed in the FE** (`moon-erp/src/app/features/production/`):

| Service | Screens calling it |
|---|---|
| `UnitService` | `boms/`, `orders/`, `toll/` |
| `WarehouseService` | `orders/`, `toll/`, `consignment/`, `reports-inventory-costs/` |
| `BusinessPartnerService` | `orders/`, `toll/`, `consignment/` |

`core.units.view` was the blocking one exactly as the brief described: the BOM
unit dropdown comes from `GET /core/units`, and `unit_id` is `Validators.required`
on the BOM header **and on every component line**, so the BOM could not be saved
at all by any factory role.

---

## 2. Red before green

Both runs with the mandated CLI PHP:

```
cd /home/moonui2/moon-erp-be && /opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G \
  vendor/bin/pest --filter='lets a production_supervisor list'
```

**BEFORE (red):**

```
  ⨯ it lets a production_supervisor list units for the BOM unit dropdow… 6.58s
  ⨯ it lets a production_supervisor list warehouses                      1.92s
  ⨯ it lets a production_supervisor list business partners               1.75s
  ✓ it lets a production_supervisor list products for the picker         1.73s   ← WP2, proves the harness
  Expected response status code [200] but received 403.
  Expected response status code [200] but received 403.
  Expected response status code [200] but received 403.
  Tests:    3 failed, 1 passed (4 assertions)
```

Full new-file run before the fix: `Tests: 9 failed, 5 passed (154 assertions)`.

**AFTER (green):**

```
   PASS  Modules\Production\tests\Feature\ProductionLookupViewPermissionTest
  ✓ it lets a production_supervisor list units for the BOM unit dropdow… 6.52s
  ✓ it lets a production_supervisor list warehouses                      1.99s
  ✓ it lets a production_supervisor list business partners               1.73s
  ✓ it grants the three lookup reads to every Production role that reac… 1.17s
  ✓ it does NOT grant the lookup reads to machine_operator (terminal on… 1.08s
  ✓ it leaves machine_operator at EXACTLY its 3 terminal permissions     1.18s
  ✓ it gives Production roles view only — no create/update/delete on un… 1.17s
  ✓ it still refuses a production_supervisor POST /api/core/units        1.69s
  ✓ it does NOT grant the held-back branches/tax-rates/users reads to a… 1.22s
  ✓ it ships a bridge migration that carries the lookup grant to instal… 1.16s
  ✓ it gives an existing client whose Production roles never held the k… 1.83s
  ✓ it is idempotent — running the lookup grant twice changes nothing a… 1.16s
  ✓ it is additive — a client custom permission on a Production role su… 1.20s
  ✓ it does not fail when a sibling-owned lookup permission row is abse… 1.17s

  Tests:    14 passed (228 assertions)
```

---

## 3. What was built

Copied WP2 (`27519dbe6`) mechanism for mechanism.

| File | What |
|---|---|
| `Modules/Production/database/seeders/ProductionRoleSeeder.php` | **modified** — new `CORE_LOOKUP_VIEWS` const spread into the 10 roles that already carry `CORE_PRODUCTS_VIEW`. Sibling-owned, so **not** `firstOrCreate`'d; attached only by the existing `run()` existence filter. Fresh-install path. |
| `Modules/Production/database/seeders/GrantProductionLookupViewSeeder.php` | **new** — additive fleet-update grant. `givePermissionTo`, never `sync`. Per-key existence check with `Log::warning` skip. Idempotent (skips a held key). |
| `Modules/Production/database/migrations/2026_08_04_110000_production_roles_lookup_views_bridge.php` | **new** — bridge. `if (app()->runningUnitTests()) return;`, best-effort `try/catch` + guarded `Log::warning`, no-op documented `down()`. Timestamp sorts immediately after WP2's `2026_08_04_100000`. |
| `Modules/Production/tests/Feature/ProductionLookupViewPermissionTest.php` | **new** — 14 tests. Every top-level helper prefixed `productionLookupView*` (5 of them) — no redeclare risk. |
| `Modules/Production/tests/Feature/ProductionProductViewPermissionTest.php` | **modified, 2 lines** — see §6. |
| `docs/moonstack/CHANGELOG.md` | bilingual `[Unreleased]` bullet. |

**Why the bridge is mandatory:** `ProductionRoleSeeder` is absent from
`config/moonstack.php` → `moonstack.updater.seeders`, so on an installed client it
never runs. `php artisan migrate` is the only thing the updater always runs.

---

## 4. Which role got which permission, and why

Derived per screen as the brief required, then checked whether the three
derivations collapse. **They do** — to exactly the 10 roles WP2 used.

Every seeded Production role except `machine_operator` holds at least one of
`production.boms.view` / `production.orders.view` / `production.toll_contracts.view`,
and the `/factory/orders` route alone (gated on `production.orders`) calls **all
three** lookups. So a per-permission split would have produced three identical
lists. Granted uniformly, and said so.

| Role | units | warehouses | partners | Reason |
|---|---|---|---|---|
| `production_supervisor` | ✅ | ✅ | ✅ | BOM editor (the blocking unit dropdown), orders |
| `production_manager` | ✅ | ✅ | ✅ | orders + toll contracts (customer, warehouse) |
| `production_planner` | ✅ | ✅ | ✅ | authors BOMs, creates orders, MPS/MRP |
| `production_cost_accountant` | ✅ | ✅ | ✅ | consignment, standard costs, inventory-cost reports |
| `production_qc_manager` | ✅ | ✅ | ✅ | orders, batches/genealogy |
| `production_auditor` | ✅ | ✅ | ✅ | read-only across every production view surface |
| `production_materials` | ✅ | ✅ | ✅ | issues/stages against a warehouse, orders |
| `warehouse_storekeeper` | ✅ | ✅ | ✅ | same storekeeping screens, orders |
| `quality_inspector` | ✅ | ✅ | ✅ | batches (`boms.view`) + orders |
| `cost_accountant` (legacy) | ✅ | ✅ | ✅ | orders + cost reports |
| **`machine_operator`** | ❌ | ❌ | ❌ | **excluded** — terminal-only, no lookup on that screen; pinned at exactly 3 permissions by an existing test, still exactly 3 |

**View only.** No `…create` / `…update` / `…delete` on units, warehouses or
partners, and no `core.partners.freeze` / `.unfreeze`. Asserted per role for all
11 write keys, plus a live `POST /api/core/units` → **403**.

---

## 5. Held back — reported, not granted (owner decides)

Confirmed still ungranted on every Production role, in the DB read-back **and**
pinned by a test so a later bulk grant has to be a conscious act:

| Permission | Screen | Why 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 |

**`core.product-categories.view`** — verified out of scope. A grep of the whole
`features/production/` FE tree for `product-categories` / `productCategor` /
`ProductCategoryService` returns **zero hits**. No Production screen calls it.

---

## 6. The one file from a landed commit I touched, and why

`ProductionProductViewPermissionTest.php` (WP2, `27519dbe6`) used
**`core.partners.view`** as its stand-in for "a permission the client hand-added
from the Roles screen", to prove `GrantProductionProductViewSeeder` is additive.
WP10 now *seeds* `core.partners.view` onto `production_supervisor`, which would
have made that assertion vacuous. Swapped for a synthetic key
`client.custom.picker.view` that no seeder will ever grant, with a comment
explaining the swap. Two lines. The test's intent is preserved, not weakened; the
WP2 behaviour under test is unchanged and still passes.

---

## 7. Migration applied to `moonui2_dev_be`, and read back from the DB

```
$ php artisan migrate --force
   INFO  Running migrations.
  2026_08_04_110000_production_roles_lookup_views_bridge ....... 228.97ms DONE
```

`migrations` row: `{"id":751,"migration":"2026_08_04_110000_production_roles_lookup_views_bridge","batch":265}`

No `migrate:fresh` / `migrate:refresh` / `db:wipe` was run at any point.

**Read back from the DB** (not from the seeder), same query as §1 plus the
held-back and write keys:

```
role                        units  whs  partners | branches taxrates users | any write | TOTAL (was)
production_supervisor       HELD   HELD HELD     | --       --       --    | --        | 24 (21)
production_manager          HELD   HELD HELD     | --       --       --    | --        | 24 (21)
production_planner          HELD   HELD HELD     | --       --       --    | --        | 15 (12)
production_cost_accountant  HELD   HELD HELD     | --       --       --    | --        | 38 (35)
production_qc_manager       HELD   HELD HELD     | --       --       --    | --        | 15 (12)
production_auditor          HELD   HELD HELD     | --       --       --    | --        | 24 (21)
production_materials        HELD   HELD HELD     | --       --       --    | --        | 28 (25)
warehouse_storekeeper       HELD   HELD HELD     | --       --       --    | --        | 27 (24)
quality_inspector           HELD   HELD HELD     | --       --       --    | --        |  9 (6)
cost_accountant             HELD   HELD HELD     | --       --       --    | --        |  8 (5)
machine_operator            --     --   --       | --       --       --    | --        |  3 (3)
```

Exactly +3 on each of the 10 roles, +0 on `machine_operator`. Nothing else moved.

**Idempotency proven on the real DB too**, not only in tests:

```
role_has_permissions before=3056   after re-running the grant twice=3056
```

---

## 8. Test results — zero new failures

All runs with `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest`.

| Suite | Before (known-good) | After | Delta |
|---|---|---|---|
| New file `ProductionLookupViewPermissionTest` | 9 failed / 5 passed | **14 passed** | red → green |
| `pest Modules/Production` | 640 passed / 10 failed | **654 passed / 10 failed** (2036.87s) | +14 passed = exactly the 14 new tests; **failures unchanged** |
| `pest Modules/Core` | 719 passed / 10 failed | **719 passed / 10 failed** (1406.70s) | **identical** |

**The 10 Production failures are the documented pre-existing set**, confirmed by
running those three files alone after the change:

```
$ pest ConsignmentFoundationTest.php CostAiAnomalyVarianceTest.php ProductionVarianceTest.php
   FAILED  ConsignmentFoundationTest              RuntimeException      × 1
   FAILED  CostAiAnomalyVarianceTest > it F…                            × 3
   FAILED  ProductionVarianceTest > it BE-V…                            × 6
  Tests:    10 failed, 23 passed (184 assertions)
```

1 + 3 + 6 = 10 — i.e. every failure in the full Production run comes from these
three files, exactly as the brief documented. None of them touches roles or
permissions.

The 10 Core failures are also the known set (AdminApiTest, BackupSchedulingTest,
CountryApiTest ×2, ProductCategorySlugTest, RoleLifecycleTest,
RoleTenantScopingTest ×2, SessionApiTest, SettingsServiceTest) — same count and
same names as the baseline.

The two neighbouring permission test files were also run together to prove the
`machine_operator` pin survived: `ProductionRoleSeederTest` +
`ProductionProductViewPermissionTest` → **24 passed (194 assertions)**.

---

## 9. Housekeeping

- `./vendor/bin/pint` on the 5 touched PHP files → `{"result":"pass"}`.
- `chown moonui2:moonui2` on all 6 edited/created files.
- `bash local-deploy.sh` → `انتهى الديبلوي بنجاح ✓` (autoload dumped, caches cleared, no pending migrations).
- Bilingual `[Unreleased]` CHANGELOG bullet added, placed alphabetically next to WP2's.
- Committed on `hazemdev2`. **Not pushed, not merged.**

---

## 10. Concerns / follow-ups

1. **The three held-back permissions are still real 403s.** `core.branches.view`
   (factory-dashboard), `accounting.tax-rates.view` (orders) and `core.users.view`
   (variances) each still fail silently the same way for a production user. The
   orders screen in particular now resolves units, warehouses and partners but
   will still get nothing back for tax rates. Owner call.
2. **Scope of the sweep.** This WP covered the services the four named screens
   import. Other factory screens may resolve further gated lookups (e.g. cost
   centres, projects, currencies) that no one has queried yet. The same
   seeder+bridge pattern extends cleanly if more turn up.
3. **`ProductionRoleSeeder` is still not in `moonstack.updater.seeders`.** Every
   future role change to it will need its own bridge migration. That is now three
   bridges deep (WP2, WP10, plus the Core ones). Worth deciding whether the
   seeder should be made update-safe and registered, rather than growing one
   bridge per grant.
4. **No FE change was needed or made** — but the FE still swallows a 403 from a
   lookup with no message. That is the root reason all four of these defects were
   invisible; a global 403 toast would have surfaced every one of them on day one.
