# م3 Scout A — BE Patient Model + Registration (Clinic/HIS)

Repo root: `/home/moonui/moon-erp-be`

## 1. Patient model identity + creation

**The clinic patient IS the shared LIS `LabPatient`, accessed through a read-only shim.**

- Physical table: `lab_patients` (single store, no clinic-owned patients table).
- `Modules/Clinic/app/Models/Patient.php:21-23` — `class Patient extends BaseModel implements FhirPatientSource` with `protected $table = 'lab_patients';`.
- `Modules/Clinic/app/Models/Patient.php:30-35` — doc comment + `protected $guarded = ['*'];`: **"Read-only shim — clinic never mass-assigns patients in P0 (create/edit stays in LIS)."** Every column guarded so the clinic side literally cannot write patient columns through this model.
- Canonical writable model: `Modules/LIS/app/Models/LabPatient.php:15` (`class LabPatient extends BaseModel implements FhirPatientSource`, same `lab_patients` table).

**Full column list** (from `Modules/LIS/database/migrations/2026_03_01_000006_create_lab_patients_table.php:11-37` base + all later `lab_patients` alter migrations found):

| Column | Added in | Notes |
|---|---|---|
| id, company_id | base `2026_03_01_000006` | |
| mrn | base | `unique()`, string, this IS the file/record number |
| name, name_ar | base | name_ar originally required, later made nullable (`2026_05_25_120100_make_name_ar_nullable_on_lis_tables.php`) |
| name_en | `2026_03_26_000004_add_name_en_to_lis_models.php` | |
| date_of_birth | base | nullable date |
| gender | base | nullable, cast to `Gender` enum |
| phone | base | nullable |
| phone_country_code | `2026_05_28_130000_add_phone_country_code_to_lab_patients.php` | default `+966`, seeds a `countries` dial-code table |
| email | base | nullable |
| blood_group | base | nullable, cast to `BloodGroup` enum |
| national_id | base | nullable in DB, but **required** by FormRequest (see §2/§5) |
| national_id_type | `Modules/NPHIES/database/migrations/2026_04_02_200001_add_saudi_id_fields_to_lab_patients.php:13` | nullable, cast via `NphiesIdType` enum in request rules |
| passport_country | same NPHIES migration:14 | nullable, 3 chars |
| nationality | `2026_05_25_140000_add_nationality_and_unique_national_id_to_lab_patients.php` | saudi/non_saudi selector, cast to `Nationality` enum, required by FormRequest |
| insurance_info | base | JSON, array-cast |
| medical_history | base | JSON column but validated as a max:65535 string in the FormRequest; Clinic's `Patient` model array-casts it and "degrades to null on non-JSON content" (`Patient.php:45-46`) |
| address, address_ar | base | text, nullable |
| partner_id | base | FK → `business_partners`, nullOnDelete |
| portal_token, portal_token_expires_at | `2026_03_02_300001_add_portal_token_to_lab_patients_table.php` | session-style portal auth token |
| portal_link_token | `2026_05_22_000001_add_portal_link_token_to_lab_patients_table.php` | permanent QR/WhatsApp link token, auto-generated on `creating()` (`LabPatient.php:62-66`) |
| insurance_contract_id | (present in fillable/requests; FK → `lab_insurance_contracts`) | |
| external_lab_id | `2026_05_23_000002_add_external_lab_and_tax_status_to_lab_patients_table.php:19` | NULL = internal/walk-in patient; non-null = B2B external-lab-owned patient |
| tax_status | same migration:23 | `resident`/`saudi` VAT classification |
| ext_scope_key | `2026_06_11_100000_add_ext_scope_key_partition_guard_to_lab_patients.php` | plain column mirroring `COALESCE(external_lab_id,0)`, model-maintained in `saving()` hook (`LabPatient.php:72-74`), backs a DB-level uniqueness guard |
| is_active | base | boolean |
| created_by, updated_by | base | FK → users |
| timestamps + soft-deletes | base | |

**Creation from the clinic side: there is none.** Clinic's own `PatientController` (`Modules/Clinic/app/Http/Controllers/PatientController.php`) only has `index` (typeahead search), `show`, and `header` (sticky summary) — no `store`. Routes confirm this: `Modules/Clinic/routes/api.php:74-76` registers only `GET patients`, `GET patients/{patient}`, `GET patients/{patient}/header`, with a comment "WP-02 Patient shim (read-only in P0; create/edit stay in LIS)".

**Patient creation actually happens via LIS**: `Modules/LIS/app/Http/Controllers/LabPatientController.php:94-163` (`store()`), endpoint `POST /lis/patients` (permission `lis.patients.create`). Notable behavior:
- Auto-generates `mrn` via `SequenceService` if not supplied by caller (`LabPatientController.php:104-106`), with self-healing retry-on-collision loop (lines 139-158).
- Auto-creates a `BusinessPartner` if `partner_id` not given (lines 110-123), linking billing identity.
- `update()` (lines 187-223) and `destroy()` (230-245) also live only in LIS; both hard-block writes when `external_lab_id !== null` ("managed exclusively through the external lab portal").

## 2. م3-target fields: exist vs missing

| Target field | Status | Where |
|---|---|---|
| National ID (NID) | **EXISTS**, required | column `national_id` (base migration); made **required** (not just nullable) in `StoreLabPatientRequest.php:40-52` / `UpdateLabPatientRequest.php:40-50`; DB-level partial-uniqueness enforced via `ext_scope_key` composite index |
| Auto date-of-birth-from-NID | **MISSING** — no code found | Searched `Modules/Clinic`, `Modules/LIS`, `app/` for any Saudi-ID→DOB derivation (`computeBirthDate`, `deriveBirthDate`, hijri parsing, etc.) — zero hits. `date_of_birth` is a plain free-entry nullable date field; nothing auto-derives it from `national_id`. |
| File / record number | **EXISTS** — this is `mrn` (Medical Record Number) | base migration `lab_patients.mrn`, unique, auto-generated via `SequenceService` (`LabPatientController.php:104-106`) or user-suppliable |
| Spouse/partner fields (حزمة الزوج/الزوجة — gyna) | **MISSING** — no code found | Searched for `spouse`, `husband`, `wife`, `partner_name`, "زوج" across `Modules/Clinic` + `Modules/LIS` (excluding unrelated `BusinessPartner`/`partner_id` hits) — zero matches. No spouse/gyna bundle columns or relations exist on `lab_patients` or elsewhere. |
| Draft-that-resumes (registration wizard draft/resume) | **MISSING** — no code found | Searched for `draft` across `Modules/Clinic` — only hits are `ClaimStatus::Draft` (insurance claim workflow) and journal-entry "Draft" status in `RemediateStrandedReceipts.php`; none relate to a patient-registration draft/resume mechanism. Patient `store`/`update` in LIS are plain synchronous create/update — no partial-save or resume-later concept. |

## 3. Per-company patient-form-field config

**MISSING.** Searched for `patient_form_fields`, `form_fields`, `field_config`, `patientFormConfig` across `Modules/Clinic` and `Modules/LIS` — zero hits.

There IS a general company-settings mechanism (`Modules/Core/Models/SettingDefinition`, seeded per-module e.g. `Modules/Clinic/database/seeders/ClinicSettingDefinitionSeeder.php`), but its ~20 `clinic.*` keys are entirely about **operations/financial/print/mode** toggles (`clinic.ordering_mode`, `clinic.lab_mode`, `clinic.scheduling_mode`, `clinic.print.*`, etc.) — nothing about which patient fields to show/hide/require per company. No such config table or key exists today; this would need to be built for م3 if the owner wants per-company field toggling (e.g. hide/require national_id, show gyna bundle, etc.).

## 4. Live duplicate check on registration

**No live/async duplicate-check endpoint exists.** Searched routes for `duplicate|check-national|check-phone|check-name` in both `Modules/LIS/routes/api.php` and `Modules/Clinic/routes/api.php` — the only `duplicate` route found is `POST investigations/{investigation}/duplicate` (`Modules/LIS/routes/api.php:160-161`), which clones an LIS test/investigation definition — unrelated to patients.

What DOES exist:
- **Submit-time uniqueness on `national_id` only** (not name/phone): `StoreLabPatientRequest.php:40-52` and `UpdateLabPatientRequest.php:40-50` apply `Rule::unique('lab_patients','national_id')` scoped to `company_id` + `external_lab_id IS NULL` + `deleted_at IS NULL` — returns a 422 with a friendly message (`national_id.unique` → `lis::lis.national_id_already_registered`, both files' `messages()`).
- DB-level enforcement backs this up via the composite unique index `lab_patients_company_extscope_nid_unique (company_id, ext_scope_key, national_id)` (`Modules/LIS/database/migrations/2026_06_11_100000_add_ext_scope_key_partition_guard_to_lab_patients.php`), closing an app-layer-only race.
- Name/phone are **not** unique-constrained anywhere — only indexed for search (`$table->index(['phone'])` in the base migration) and searchable via `LabPatientController::index()`'s `search` param (`LabPatientController.php:60-69`) and `PatientController::index()`'s `q` param (`Modules/Clinic/app/Http/Controllers/PatientController.php:33-42`) — this is a manual typeahead search, not an automatic duplicate warning.
- `LabPatientController::recentInvestigations()` (lines 372-397) is a **different** kind of duplicate check — it warns about a patient having a recent (default 24h) investigation of the same type (test-duplication guard), not a patient-record duplicate check.
- A `merge()` endpoint exists (lines 285-367) to manually merge two patient records post-hoc, implying duplicates DO occur in practice and are cleaned up after the fact rather than prevented live at entry.

## 5. Validation rules on patient create/update

From `Modules/LIS/app/Http/Requests/StoreLabPatientRequest.php:26-64` (create) and `UpdateLabPatientRequest.php:26-63` (update):

- `mrn`: nullable/sometimes, string, max:50, unique per company (soft-delete-aware)
- `name`: required (create) / sometimes (update), string, max:255
- `name_ar`, `name_en`: nullable, string, max:255
- `date_of_birth`: nullable, date, **must be `before:today`**
- `gender`: nullable, must be a valid `Gender` enum value
- `phone`: nullable, string, max:20
- `phone_country_code`: nullable, string, max:8
- `email`: nullable, valid email, max:255
- `blood_group`: nullable, must be a valid `BloodGroup` enum value
- `national_id`: **required**, string, max:20, unique(company_id, internal-scope) — no format/regex validation (no Saudi-ID-format/checksum check found)
- `national_id_type`: nullable, must be a valid `NphiesIdType` enum
- `nationality`: **required**, must be a valid `Nationality` enum (saudi/non_saudi)
- `passport_country`: nullable, string, max:3
- `insurance_info`: nullable, array
- `medical_history`: nullable, string, max:65535
- `address`, `address_ar`: nullable, string, max:1000
- `partner_id`: nullable, integer, must exist in `business_partners`
- `insurance_contract_id`: nullable, integer, must exist in `lab_insurance_contracts`
- `is_active`: sometimes, boolean

No NID format/checksum validation exists (e.g. no 10-digit Saudi-ID regex or check-digit validation) — just a generic string max-length rule.

## 6. Doctors entity — clinic-side CRUD state (م1 deferred debt)

**Clinic's `DoctorController` is READ + IDENTITY-LINK ONLY — no create, no full edit, no delete.**

`Modules/Clinic/app/Http/Controllers/DoctorController.php` methods:
- `index()` (lines 19-47) — paginated list with search + department filter, over the shared `Modules\LIS\Models\LabDoctor`.
- `show()` (lines 49-55) — single doctor.
- `updateIdentity()` (lines 61-93) — **the only write method**: sets `employee_id` (HRM link), `grade_id` (clinic-specific `DoctorGrade`), and `department_id` only. Does NOT touch name, specialization, license_number, phone, email, code, or `is_active`.
- No `store()` method exists on this controller at all.
- No `destroy()` method exists on this controller at all.

Routes confirm this (`Modules/Clinic/routes/api.php:79-81`):
```
GET  doctors                          → index
GET  doctors/{doctor}                 → show
PUT  doctors/{doctor}/identity         → updateIdentity
```
No `POST doctors` or `DELETE doctors/{doctor}` route registered in the Clinic module.

**Full doctor CRUD lives only in LIS**, via `Modules\LIS\Http\Controllers\LabDoctorController` — `index/store/show/update/destroy` (confirmed via `grep -n "public function"`) plus `nextCode`/`byPhone`, wired through `Route::apiResource('doctors', LabDoctorController::class)` in `Modules/LIS/routes/api.php:215-216`. This is the shared `LabDoctor` model/table (`lab_doctors`, base migration `Modules/LIS/database/migrations/2026_03_01_000007_create_lab_doctors_table.php`: id, company_id, name, name_ar, code[unique], specialization, specialization_ar, license_number, phone, email, is_internal, department_id, is_active, created_by/updated_by, timestamps, soft-deletes — plus later-added commission fields, `specialty_id`, `show_on_report`, `employee_id`).

**Conclusion — the م1-deferred debt is exactly this gap**: to add or fully edit a doctor from the clinic UI, staff must currently go through LIS's doctor screens/endpoints (`/lis/doctors`); the Clinic module only lets you search/view doctors and attach the HRM-employee + typed-grade + department "identity" layer on top of an LIS-created doctor. There is no clinic-native doctor create/full-edit/delete flow.

## Related files map (for م3 planning)

- `Modules/Clinic/app/Models/Patient.php` — read-only shim model
- `Modules/Clinic/app/Http/Controllers/PatientController.php` — read-only clinic patient endpoints
- `Modules/Clinic/app/Http/Resources/PatientResource.php` — clinic-facing patient JSON shape
- `Modules/Clinic/app/Services/PatientReadService.php` — sticky header summary builder
- `Modules/Clinic/app/Models/PatientHistoryAnswer.php` + `PatientHistoryController.php` — clinic-owned family/past-medical/past-surgical history (separate from the shim, clinic-native, this part IS write-capable from clinic side)
- `Modules/Clinic/app/Models/PatientLedgerEntry.php` + `PatientLedgerController.php` / `PatientLedgerService.php` — clinic-owned AR ledger per patient
- `Modules/LIS/app/Models/LabPatient.php` — canonical writable patient model
- `Modules/LIS/app/Http/Controllers/LabPatientController.php` — canonical create/update/delete/merge endpoints
- `Modules/LIS/app/Http/Requests/StoreLabPatientRequest.php`, `UpdateLabPatientRequest.php` — validation rules
- `Modules/Clinic/app/Http/Controllers/DoctorController.php` vs `Modules/LIS/app/Http/Controllers/LabDoctorController.php` — the doctor CRUD split
- `Modules/Clinic/database/seeders/ClinicSettingDefinitionSeeder.php` — full current company-settings surface (no patient-form-field keys)
