# W1-BE-B report — managed catalog: seeds, in-place management API, permissions, ICD favourites

Date: 2026-10-23 · BE `/home/moonui3/moon-erp-be` (hazemdev3) · not committed.
Also covers the orchestrator addendum (Fable spec §B): DELETE returns 204 or 409, `restore`, `with_usage`.

## API (final shape, for W1-FE-1)
The path prefix stays **`/api/clinic/clinical-lookups`**, not `clinic/lookups` as the brief wrote, because the FE `ClinicalLookupService` already uses it.

| Call | Perm (route middleware) | Behaviour |
|---|---|---|
| `GET clinical-lookups?lookup_key&specialty[&include_inactive=1][&with_usage=1]` | `clinic.history.view` | `include_inactive` / `with_usage` only work for `clinic.history.manage\|clinic.lookups.manage`; for anyone else they are ignored. Hidden rows come after active ones with `is_active:false`. `usage_count` is added per row only with `with_usage`. |
| `POST clinical-lookups` | `clinic.history.manage\|clinic.lookups.add` | Bug 3: `parent_id` must be a root (category) row with the same company and the same lookup_key, else 422. `specialty` must be null or one of `ClinicalType::values()` (general/nutrition/dental/gyn_ob). Duplicate check is now per parent. |
| `PATCH clinical-lookups/{id}` | `…\|clinic.lookups.manage` | Accepts `value_ar, value_en, is_active, display_order, parent_id, is_alert, alert_severity(info\|warn\|danger)`. Another company's id → 404. A bad parent, a category that has children being moved under a parent, or a duplicate label among siblings → 422. `code` never changes, so recorded answers keep resolving. |
| `POST clinical-lookups/reorder {lookup_key?, parent_id?, ids[]}` | `…\|clinic.lookups.manage` | Sets `display_order = 1..n`. Any id from another level/company → 422 and nothing is written. If `lookup_key` is left out, it is taken from the ids. |
| `DELETE clinical-lookups/{id}` | `…\|clinic.lookups.manage` | Unused → soft delete plus the doctors' ★ on it removed → **204**. Used → **409 `{message, usage_count}`** and nothing changes; the FE then offers «إخفاء» (`PATCH is_active=false`). |
| `POST clinical-lookups/{id}/restore` | `…\|clinic.lookups.manage` | Undoes a delete or a hide: un-deletes and sets `is_active=true`. Company-scoped, 404 otherwise. |
| `GET icd10/chapters` | `clinic.diagnosis.view` | The 22 WHO chapters (`key` I…XXII, `from`/`to` block, `label_ar/en`, `count`) plus a `custom` entry for the clinic's own C- codes. |
| `GET icd10?chapter=X\|J\|custom&group=J06&favorites=1&q=` | `clinic.diagnosis.view` | Browse mode, paginated (`per_page` up to 200, default 50), ordered by code. Custom codes never show under chapter II. Every icd10 row (search and browse) now has `is_favorite`. |
| `POST/DELETE/GET favorites` type `icd10` | as before | `favoritable_id` = `icd10_codes.id`. |

**What counts as "used"** (`LookupCatalogService::usageCounts`, live rows only — the soft-deleted findings each autosave leaves behind do not count):
- child values of the row
- `lookup_id` in encounter_findings / encounter_complaints / past_medical_history / past_surgical_history / family_history
- history answers holding the code: `answer_value`, the `value_json` code arrays, and `history_answer_values` mirror rows when that table exists (each answer counted once)
- `patient_alerts.code` (for allergen/condition rows) and `clinic_referrals.team` (for team rows)

Favourites do not count.

## Files
New:
- Modules/Clinic/app/Support/GeneralClinicalCatalogSeed.php
- Modules/Clinic/database/seeders/GeneralClinicalCatalogSeeder.php
- Modules/Clinic/app/Support/Icd10Chapters.php
- Modules/Clinic/app/Http/Requests/UpdateLookupRequest.php
- Modules/Clinic/app/Http/Requests/ReorderLookupsRequest.php
- migrations 2026_11_20_200001_clinic_lookups_permission_bridge.php and 2026_11_20_200002_seed_general_clinical_catalog.php
- Modules/Clinic/tests/Feature/W1bCatalogManagementTest.php

Changed (Clinic):
- LookupCatalogService.php, LookupCatalogController.php, StoreLookupRequest.php, ClinicalLookupResource.php (`usage_count`, only when computed)
- DiagnosisController.php, Icd10CodeResource.php (`is_favorite`), FavoriteController.php (`icd10`)
- routes/api.php: 5 lookup lines + 1 icd10 line, all minimal edits next to the existing lines
- GrantClinicalPlatformPermissionsSeeder.php, ClinicPermissionDependencies.php

Changed (shared, all additive):
- Modules/Core/database/seeders/RolePermissionSeeder.php: catalogue list + `CLINICAL_PLATFORM_ADMIN_GRANTS`
- Modules/Core/database/seeders/StandardRolesSeeder.php: doctor gets add+manage, nurse gets add
- Modules/Core/app/Support/PermissionCatalog.php: an `add` verb, a `clinic.lookups` resource, 2 label overrides
- Modules/Core/app/Services/CompanyProvisioner.php: one line in REFERENCE_SEEDERS
- config/moonstack.php: one seeder line in both the installer and the updater lists

## Seed (Bug 1)
- `complaint`: 8 categories (`cc_cat_*`) and 46 complaints (`cc_*`).
- `exam.finding`: 8 regions with the exact codes `exam_general … exam_skin` and 5–8 findings each (`ef_<region>_*`), «طبيعي» first.
- `specialty` = **NULL** (core). The general card's `specialty=general` matches `NULL OR 'general'`, and NULL is the core convention (`ClinicalType::General->fieldScope()` is null). Side effect: GYN/dental visits also see the general complaints and regions next to their own.
- Create-only and keyed by code. The existence check ignores `deleted_at`, so a value a client deleted, hid or renamed is never brought back or overwritten. No new children are added under a category the client hid.
- Runs for companies that have `history_questions` rows.

## Run on dev (moonui3_dev_be)
- `migrate --force --path=…200001…` and `…200002…`: both DONE. I ran them by path so no other agent's pending migration ran.
- `db:seed --class=…GeneralClinicalCatalogSeeder --force`: 0 created (the migration had already seeded company 1).
- Checked with tinker: `list(1,'complaint',null,'general')` = 54, `list(1,'exam.finding',null,'general')` = 61, `complaint` for gyn_ob = 74 (20 GYN + 54 core).
- Grants on dev afterwards:
  - add + manage: owner, admin, doctor, طبيب, gyn_consultant, gyn_doctor, gyn_team_member
  - add only: nurse, gyn_nurse
- **The first bridge run also gave receptionist `add`**, because the dev receptionist role holds `clinic.home_meds.manage` (my first choice of nurse anchor). I removed that anchor from the seeder so nurses are granted by name only, and revoked `clinic.lookups.add` from receptionist on dev. That permission did not exist before my bridge, so I was only undoing my own grant.
- No dentist or dietitian role exists on this install. Dental/nutrition doctors are covered by the `clinic.encounters.sign` anchor.
- Also done: `optimize:clear`, `composer dump-autoload` (ea-php82 CLI), chown moonui3 on every touched file and on vendor/composer. The API answers 401 unauthenticated (it boots, no 500).

## Tests (all run on SQLite by me)
- **W1bCatalogManagementTest: 18 passed (194 assertions).** Covers the seed (counts, codes, «طبيعي» first, idempotent, no resurrect, hidden category, company scope), Bug 3 validation, per-parent dedupe, route permissions, PATCH rules plus cross-tenant 404, reorder, DELETE 204/409/restore, usage from answers JSON and alerts, `with_usage`, `include_inactive`, the bridge grants (custom-named doctor gets both, nurse/gyn_nurse add only, a receptionist holding home_meds gets nothing), catalog labels and dependencies, ICD favourites, chapter/group/custom browse and chapter counts.
- Guard and regression tests, all pass:
  - LookupCatalogTest 6
  - PermissionCatalogTest + PermissionLabelsTest 14
  - C1aPermissionsTest 8, CustomIcd10Test 4, DiagnosisTest 15, DoctorPresetPermsTest 3, FavoriteTest 5, P5PermissionsTest 4
  - PermPlatformRoutesTest 7, RolePresetClosureWithinCatalogTest 2, ReceptionPresetPatientPermsTest 2
  - SetupWizardFlowTest 21, DefaultAccountProvisioningTest 10, AdditiveRolePermissionsTest 5
- **ClinicRoleManagementTest: 3 failures that are not mine.** The saved roles now also carry `screen.*` permissions (screen-permission feature) that the tests' exact arrays don't expect. My keys do not appear in the diffs.

## Not verified / for others
- **FE (W1-FE-1):**
  - `clinical-picker-box` `canAddValues` is still hard-wired to `clinic.history.manage`. It should become `clinic.lookups.add`, plus a `canManage` that reads `clinic.lookups.manage`. Until then, doctors without history.manage won't see «＋». The BE route already accepts either permission.
  - DELETE now returns 204 / 409 rather than a JSON `mode`.
  - The roles-screen FE i18n has no `lookups` resource word. The BE catalog labels it, so `PermissionLabelsTest` passes.
- The MoonStack CHANGELOG `[Unreleased]` bullet is not written; that file belongs to the orchestrator. Suggested: «قوائم الاختيار في الكشف: شكاوى وعلامات فحص جاهزة + إضافة/تعديل/إخفاء/ترتيب/حذف القيم من جوه نافذة الاختيار · ICD-10 مفضلة وتصفح بالفصول».
- `restore` does not check whether the restored label now matches a live sibling (delete «كحة», add a new «كحة», then undo → two rows). The FE could refresh after an undo; not handled BE-side.
- Not exercised live with an authenticated user (only through feature tests).
- `with_usage` runs one answers query per value, and only for lookup keys some question uses (or when the mirror table exists). That is fine for picker-sized lists but not tuned for very large catalogs.

## Deviations / decisions
1. Route prefix `clinical-lookups` instead of `clinic/lookups`.
2. Specialty validation uses `ClinicalType::values()` rather than `ClinicalTypeRegistry::types()`. The registry only knows general and gyn_ob, and nutrition/dental rows already exist.
3. "Category row" = a root row (`parent_id IS NULL`) with the same company and lookup_key. This keeps the catalog one level deep.
4. DELETE follows the addendum (204 / 409 + restore), not the brief's "auto-deactivate".
5. Seeded rows are core (NULL specialty), as described above.
6. Nurse `add` is granted by role name (nurse, gyn_nurse) rather than an anchor, because reception holds every nurse permission on real installs. Custom-named nurse roles on client installs therefore don't get `add` automatically.
7. I added a guaranteed-rail migration that seeds the general catalog on existing installs, besides the updater list (which is best-effort).
