# Scout A — Clinic (HIS) settings: storage & read map (BE)

Scope: `/home/moonui/moon-erp-be` (`Modules/Core`, `Modules/Clinic`, cross-BE greps).
READ-ONLY scout, no edits made.

## 1. How settings are stored & read (Core)

### Storage — two tables, definition-driven

**`settings` table** — the VALUE store, one row per resolved scope tuple.
- Migration (current shape): `Modules/Core/database/migrations/2026_02_20_000002_update_settings_table_for_scoped_keys.php:11-115`
  (rewrote the original `2026_02_10_000001_create_settings_table.php:11-23` which used `group`+`key`).
- Columns: `id, company_id, setting_key(100), branch_id?, user_id?, value(text), updated_by?, timestamps`.
- Unique index `settings_scoped_unique` on `(setting_key, company_id, branch_id, user_id)` — `2026_02_20_000002...php:110-114`.
- Model: `Modules/Core/app/Models/Setting.php:8-69`. `$fillable = ['company_id','setting_key','branch_id','user_id','value','updated_by']` (line 12-19). `belongsTo` to `SettingDefinition` via `setting_key` (line 32-35), plus `company()`, `branch()`, `user()`, `updatedByUser()`.

**`setting_definitions` table** — the SCHEMA/registry: one row per known key, module, type, default, allowed values, scope, labels (bilingual), display grouping, visibility.
- Migration: `Modules/Core/database/migrations/2026_02_20_000001_create_setting_definitions_table.php:11-32`. Columns: `setting_key(100, unique), module(50), value_type(20), default_value(text), allowed_values(json), scope(20, default 'company'), label_ar/en, description_ar/en, display_group, display_order, is_visible, requires_restart`.
- Model: `Modules/Core/app/Models/SettingDefinition.php:10-92`. Casts `allowed_values`→array, `value_type`→`SettingValueType` enum, `scope`→`SettingScope` enum (line 31-41). `getLocalizedLabel()/getLocalizedDescription()` (line 54-73). Scopes `forModule()` (79-82), `visible()` (88-91).

### Company scoping / keying

- Every value row carries `company_id` (hard scope, FK cascade-delete — `2026_02_10_000001...php:13`).
- Within a company, further scoping is optional via `branch_id` and `user_id` columns — NULL means "not scoped to that level".
- **Fallback chain** implemented in `SettingsService::get()` (`Modules/Core/app/Services/SettingsService.php:18-57`): tries `user+branch` → `user only` → `branch only` → `company-level (branch=null,user=null)` → `definition.default_value` → `null`. This is the single resolution algorithm; nothing else re-implements it.
- `SettingDefinition.scope` (enum `Global|Company|Branch|User`, `Modules/Core/app/Enums/SettingScope.php:5-11`) is **metadata only** (drives which scope the settings UI offers) — it is NOT enforced by `get()`/`set()`; a definition marked `scope=user` can still be set at company level by any caller that omits `$userId`. No code was found enforcing scope against the definition.

### Reading/writing helper — `SettingsService` (the "setting()" equivalent)

There is **no global `setting('key')` Blade/helper function** — grep for `function setting(` across the whole BE only finds two unrelated private methods in `Modules/Production` (`CostOfQualityService.php:445`, `ProductionReportService.php:1053`), not a Core helper. The canonical way to read/write is DI-injecting `Modules\Core\Services\SettingsService` (`Modules/Core/app/Services/SettingsService.php`):
- `get(string $key, int $companyId, ?int $branchId=null, ?int $userId=null): mixed` — resolution chain above + type-casts via `castValue()` using the definition's `value_type` (line 184-201). Returns `null` if neither a row nor a definition exists.
- `getBool(string $key, int $companyId, bool $default=false): bool` — explicit-default boolean read (line 64-69), used for `owner.*` flags.
- `set(string $key, mixed $value, int $companyId, ?int $branchId=null, ?int $userId=null): void` — `updateOrCreate` on `settings` (line 100-111), flushes the per-company cache (`flushCache`, line 206-209).
- `getByModule()` / `getAllWithDefinitions()` — module-scoped bulk reads (line 119-155), used by the generic Settings UI (`SettingController::index/definitions`).
- 5-minute cache per company: `Cache::remember("settings.company.{$companyId}", 300, …)` (line 162-171); `set()` invalidates it.

### HTTP surface — `Modules/Core/app/Http/Controllers/SettingController.php`
- `GET /settings` (list, optional `?module=`) — `index()` line 42-64, gated `permission:core.settings.view`.
- `GET /settings/definitions` — `definitions()` line 73-87, same permission.
- `GET /settings/{key}` — `show()` line 96-109 (resolves via `SettingsService::get`).
- `PUT/PATCH /settings` — `update()` line 121-146, gated `permission:core.settings.manage`, body validated by `UpdateSettingRequest`.
- `POST /settings/ensure-default-accounts` — `ensureDefaultAccounts()` line 159-169, calls `SettingDefinitionSeeder::seedDefaultAccountSettings($companyId)` to auto-wire/repair GL account settings (find-or-create detail leaf accounts) — gated `core.settings.manage`.
- Confirms owner's memory note: settings endpoints are gated on `core.settings.view` / `core.settings.manage`, not a bespoke permission (`SettingController.php:25-31`).

### How Clinic reads settings

Clinic has **no dedicated `ClinicSettings` service/config class** and **no clinic-scoped settings cache/file** — every Clinic consumer injects `Modules\Core\Services\SettingsService` directly and calls `->get('clinic.xxx', $companyId)`. Confirmed call sites (all in `Modules/Clinic/app`, non-test):
- `Actions/CollectReceptionReceipt.php:37-38` → `clinic.ar_account_id`, `clinic.revenue_account_id`
- `Actions/RefundServiceOrderLine.php:117-118` → same two keys
- `Actions/MarkArrived.php:58` → `clinic.allow_overbooking`
- `Actions/BookAppointment.php:73` → `clinic.allow_overbooking`
- `Listeners/PostReceiptJournalEntry.php:24-25` → `clinic.ar_account_id`, `clinic.revenue_account_id`
- `Listeners/ApplyAdjudicationToLedger.php:68` → `clinic.ar_account_id`
- `Console/RemediateStrandedReceipts.php:114-115` → `clinic.ar_account_id`, `clinic.revenue_account_id`
- `Support/OrderingMode.php:21,30` → `clinic.ordering_mode` (single-place wrapper, see below)
- `Services/ClinicDashboardService.php:142` → `clinic.dashboard_top_services_count` (**not defined anywhere** — see §2)
- `Http/Requests/DoctorScheduleSlotRequest.php:58` → `his.mode` (**different prefix, not defined anywhere** — see §2)
- `Providers/ClinicServiceProvider.php:50-56` → `nphies.enabled` (defined, but under `module=nphies`, not `clinic`)

The only "service wrapper" pattern in Clinic is `Modules/Clinic/app/Support/OrderingMode.php:1-45` — a static helper around ONE key (`clinic.ordering_mode`) with `for(int $companyId)` (resolve, default `intent`) and `assertImmediateOrderingEnabled()` (409-gate). This is the precedent/pattern to follow for any new mode switch (lab_mode/rad_mode/scheduling_mode etc. could each get an analogous `Support/XxxMode.php`), but it is NOT a general clinic-settings facade — every other key is read ad hoc via raw `SettingsService->get('clinic.xxx', …)` calls scattered across Actions/Listeners.

## 2. Status of each م2-requested setting

| Setting | SettingDefinition row (seeded)? | Actually READ in code? | Verdict |
|---|---|---|---|
| **lab_mode / rad_mode** (integrated-LIS vs standalone) | ❌ No `clinic.lab_mode` / `clinic.rad_mode` / any `*_mode` key for this concept anywhere. Grep for `lab_mode`/`rad_mode` across the whole BE (`.php`, excl. vendor) returns **zero hits**. | ❌ No | **MISSING** — does not exist in any form. |
| **pharmacy_source** (meds ± purchasing) | ❌ No key. Grep `pharmacy_source` whole-BE: zero hits. Clinic module has `Prescription`/`PrescriptionItem` models (`Modules/Clinic/app/Models/Prescription.php`, `.../PrescriptionItem.php`) and `IssuePrescription` action, but nothing modeling a purchasing/sourcing mode. | ❌ No | **MISSING**. |
| **ordering_mode** (intent\|immediate) | ✅ `clinic.ordering_mode`, seeded at `Modules/Core/database/seeders/SettingDefinitionSeeder.php:1826-1841` (enum, default `intent`, allowed `['intent','immediate']`, display_group `clinic_general`). | ✅ Yes, via `Modules/Clinic/app/Support/OrderingMode.php:30` (`for()`), gating `LabOrderController.php:29`, `RadOrderController.php:70`, exposed on `EncounterResource.php:112`. Full Pest coverage (`Modules/Clinic/tests/Feature/OrderingModeTest.php`). | **EXISTS & WIRED** (the one fully-built example). |
| **scheduling_mode** (slots\|open) | ❌ No `clinic.scheduling_mode` key. Grep whole-BE `scheduling_mode`: zero hits. **However** a *related but differently-named* concept exists: `his.mode` (values `single_clinic`/`center` via `Modules\Clinic\Enums\ClinicMode` at `Modules/Clinic/app/Enums/ClinicMode.php:5-8`), read in `Modules/Clinic/app/Http/Requests/DoctorScheduleSlotRequest.php:58` to decide whether a `room_id` is required + checked for room-overlap (lines 59-80ish). This key has **no `SettingDefinition` row** — grep for `'his.mode'`/`his\.mode` in the seeder returns nothing; only test setup writes it directly (`Modules/Clinic/tests/Feature/AvailabilityTest.php:49-54,144,235,256`). | ⚠️ `his.mode` (not `scheduling_mode`) is read, undefined. `scheduling_mode` (slots\|open, the memory's actual wording) does not exist under that name at all. | **MISSING under the requested name**; a same-shaped but undefined/unseeded sibling key (`his.mode`, single_clinic\|center) exists and is READ with a hardcoded fallback default (`?? ClinicMode::SINGLE_CLINIC`). |
| **default_payment_method** | ❌ No key. Grep whole-BE `default_payment_method`: zero hits. | ❌ No | **MISSING**. |
| **allow_credit_balance** | ❌ No key. Grep whole-BE `allow_credit_balance`/`credit_balance`: zero hits related to a setting (only unrelated account/ledger code). | ❌ No | **MISSING**. |
| **return window** (نافذة الإعادة، re-visit window in days) | ❌ No key. Grep for `return_window`, `revisit_window`, `re[-_]?visit.*window` whole-BE: zero hits (only unrelated `window_days` params in Production's cost-anomaly AI service, nothing Clinic). | ❌ No | **MISSING**. |
| **print/format definitions** (logo/margins/fonts/show-diagnosis/per-doctor signature) | ❌ No `clinic.*` print/report keys exist. Closest sibling concept in Core is `core.document_settings` (JSON, `SettingDefinitionSeeder.php:29-44`, `is_visible=false`) but it is **defined and never read anywhere** (grep for `document_settings` whole-BE returns only its own definition line). LIS has a **fully-built precedent** to copy: `Modules/LIS/database/seeders/LabSettingDefinitionSeeder.php` (871 lines) with `lis.report_header/footer/template/show_qr/show_signature/show_previous/show_printed_by/footer_font_size/header_text/footer_text/label_lang` (lines 167,182,467,482,497,512,527,572,587,602,617). Clinic has no equivalent seeder file at all. | ❌ No | **MISSING** — and no dedicated Clinic seeder file exists to hold it (see §4). |
| **prescription settings** | ❌ No `clinic.prescription*` settings key. There IS a `clinic.prescriptions.print` **permission** (`Modules/Core/database/seeders/RolePermissionSeeder.php:1137`, route `Modules/Clinic/routes/api.php:285`, `PrescriptionController::print`) but that's an authorization gate, not a configurable setting (no format/default-duration/refill-policy config found). | ❌ No | **MISSING**. |
| **clinical fields settings** (history/vitals dynamic fields config) | ⚠️ Split: **History** has a real per-company dynamic config bank — `history_questions` table (`Modules/Clinic/database/migrations/2026_06_22_140001_create_history_questions_table.php:11-27`: `company_id, code, label_ar, label_en, input_type, section, department_id, is_active, sort_order`), CRUD via `HistoryQuestionController` (routes `Modules/Clinic/routes/api.php:230-235`), model `Modules/Clinic/app/Models/HistoryQuestion.php`. This is genuinely dynamic/configurable (not a `settings` row, but a first-class table). **Vitals is NOT dynamic** — `VitalsService::upsert()` (`Modules/Clinic/app/Services/VitalsService.php:33-73`) and `UpdateVitalsRequest::ALLOWED_KEYS` (`Modules/Clinic/app/Http/Requests/UpdateVitalsRequest.php:27-36`) hardcode exactly 8 fixed columns (`weight, height, bp_systolic, bp_diastolic, pulse, random_blood_glucose, temperature, spo2`) — any other key 422s (`after()` validator, lines 61-80). No config-driven vitals fields exist. | ✅ History: yes (its own table, not the `settings` system). ❌ Vitals: fixed schema, not configurable at all. | **PARTIAL** — history questions are a real (non-`settings`-table) config bank; vitals fields are hardcoded and would need new work to become configurable. |

### Two more undefined-but-read keys found (not on the م2 list, but relevant to "definition discipline")
- `clinic.dashboard_top_services_count` — read at `Modules/Clinic/app/Services/ClinicDashboardService.php:142` with an inline `?? 5` fallback; **no `SettingDefinition` row** in the seeder (grep confirms zero).
- `his.mode` — see scheduling_mode row above; same pattern (read with inline fallback, no definition row).

Both of these currently work only because `SettingsService::get()` degrades gracefully to `null` when no definition/row exists, and callers `?? default` in application code — i.e. the definition registry is **not authoritative**; it's possible to read (and even `set()`, since `set()` only validates `allowed_values` when a definition exists — see §3) keys that were never registered.

## 3. Fail-fast / validation on settings SAVE

**Very little exists, and what exists is generic, not clinic-specific.**

- `UpdateSettingRequest` (`Modules/Core/app/Http/Requests/UpdateSettingRequest.php:17-29`) validates only: `setting_key` is a required string ≤100 chars and NOT matching `/^owner\./i` (line 24); `value` is merely `required` (any type, line 25); `branch_id`/`user_id` must reference existing rows if present (lines 26-27). **No type-checking against the definition's `value_type`, no range/format validation at the request layer.**
- `SettingsService::set()` (`Modules/Core/app/Services/SettingsService.php:74-114`) is the only place with any domain check:
  - `owner.*` namespace guard (lines 82-86) — 403 unless super-admin. Not relevant to clinic settings.
  - **Enum guard** (lines 90-96): if a `SettingDefinition` exists AND has non-null `allowed_values`, the incoming value must be `in_array(..., true)` or it throws `InvalidArgumentException`. This is the **only** value-correctness check in the entire save path.
  - No check that an `integer`-typed "account id" setting (e.g. `clinic.ar_account_id`, `clinic.revenue_account_id`) actually points to an existing/postable/non-header GL account — that verification only happens *separately*, on-demand, via `SettingController::ensureDefaultAccounts()` → `SettingDefinitionSeeder::seedDefaultAccountSettings()` (`SettingController.php:159-169`), which is an idempotent auto-heal/wire helper, not a save-time guard. A caller can `PUT /settings` a `clinic.ar_account_id` of `0`, a non-existent id, or an id belonging to a header/control account and it will save without error.
  - No negative-number / percentage-range check anywhere in `SettingsService` (no such logic exists in the file at all).
  - If NO `SettingDefinition` row exists for the key (e.g. `his.mode`, `clinic.dashboard_top_services_count`), `set()` skips the enum check entirely (short-circuited by `if ($definition && ...)`, line 90) — **any string can be written to an undefined key with zero validation.**

**Conclusion: there is no fail-fast validation on clinic settings save beyond "if the key happens to have a definition with `allowed_values`, the value must be one of them."** No empty-GL-account rejection, no negative-percentage rejection, no clinic-specific validation of any kind at save time.

## 4. Settings SEEDER / definition registry for Clinic

- **No Clinic-specific `SettingDefinitionSeeder`/`ClinicSettingDefinitionSeeder` file exists.** `Modules/Clinic/database/seeders/` contains only: `ClinicDatabaseSeeder.php` (orchestrator, calls the 4 below), `DoctorGradeSeeder.php`, `HistoryAnswerOptionSeeder.php`, `Icd10StarterSeeder.php`, `PresentHistoryQuestionSeeder.php` — none register `SettingDefinition` rows.
- The 4 existing `clinic.*` definitions live inline inside the **generic cross-module** `Modules/Core/database/seeders/SettingDefinitionSeeder.php:1775-1841` (under a `// ── Clinic (HIS) ──` comment block), alongside every other module's settings (sales, purchases, inventory, accounting, HRM, LIS, NPHIES, POS…) in one 2470-line file. Definitions: `clinic.ar_account_id` (1776-1791), `clinic.revenue_account_id` (1792-1807), `clinic.allow_overbooking` (1809-1824), `clinic.ordering_mode` (1826-1841). Two of the four are also registered as auto-wireable GL accounts in `seedDefaultAccountSettings()`'s `$codeMap`/`$childAccounts` maps at the bottom of the same file (lines 2259-2260, 2297).
- **Precedent that DOES exist and could be mirrored:** `Modules/LIS/database/seeders/LabSettingDefinitionSeeder.php` — a **module-owned, 871-line, dedicated** `SettingDefinition` seeder (same `updateOrCreate` pattern, `run()`/`getDefinitions()` shape identical to Core's, lines 8-24) covering lab identity, report/print formatting, barcode, etc. Clinic has nothing analogous; every `clinic.*` key currently has to be hand-added to the shared Core file instead of living in its own module-owned seeder.

## Gaps summary (for the caller's convenience)

- **Fully built & wired:** `clinic.ordering_mode` only.
- **Defined but narrow-scope (GL wiring, not م2 concerns):** `clinic.ar_account_id`, `clinic.revenue_account_id`, `clinic.allow_overbooking`.
- **Read but never defined (silent, unvalidated, fallback-only):** `his.mode` (closest existing analogue to `scheduling_mode`, values `single_clinic|center` not `slots|open`), `clinic.dashboard_top_services_count`.
- **Completely missing (no code, no definition, zero grep hits):** `lab_mode`, `rad_mode`, `pharmacy_source`, `scheduling_mode` (under that exact name), `default_payment_method`, `allow_credit_balance`, return/re-visit window, print/format definitions (logo/margins/fonts/show-diagnosis/per-doctor signature), prescription settings.
- **Partially real via a different mechanism (not the `settings` table):** clinical fields — `history_questions` table is a genuine dynamic config bank; vitals fields are hardcoded (8 fixed columns), not configurable at all.
- **Save-time validation:** effectively none beyond an enum `allowed_values` check that only fires if a definition row exists; no GL-account existence/postability check, no negative/range checks, at the `SettingsService::set()` / `UpdateSettingRequest` layer.
- **No Clinic-owned settings seeder** exists; LIS's `LabSettingDefinitionSeeder.php` is the pattern to copy if one is created for Clinic (would also let `clinic.*` definitions move out of the shared Core seeder file).
