# D4 — fold the specialties onto the shared core: nursing rounds on observations (NEWS2 computed) · InBody check · hospital diagnosis in one table · optional triage for emergency

Read `_COMMON.md` first. One WP (BE + small FE), four independent parts **D4.1–D4.4**; do them in order, each with its own tests. Migration range **`2026_11_03_260000`–`260099`**.

**Depends on:** D1 ✅ (`clinical_observations`, `ObservationService`, the inpatient definitions rr/on_oxygen/consciousness/pain/intake_ml/output_ml/news2 — D1 is 🔄; D4 edits `QueueController` and reads `ObservationService`, both D1 files, so wait for D1 ✅). P2a ✅ recommended (both edit `Modules/Hospital/app/Services/NursingService.php` — **never run D4 and P2a at the same time**; if P2a is running, do D4.3/D4.4 first and D4.1 after P2a ✅).

## Spec (read before coding)
- `../design-data-layers.md` §3.3 (schema, «الـ set … الـ NEWS2 يتحسب على الـ set», table fates rows **`hospital_vital_rounds`** — «يفضل ريليس واحد ثم يتهاجر … هو observation set بـ `encounter_id` = encounter الإقامة؛ شاشة التمريض تعرض الـ sets كصفوف. NEWS2 = computed» — and **`nutrition_body_compositions`**), §8 row D4 (owner test: «جولة تمريض تظهر في trend المريض بعد الخروج؛ ER: مريض triage أحمر يطلع أول الطابور»), §9 #9 (`encounters.kind=emergency` + triage station as a setting, off by default) and #10.
- `../design.md` §4.7 rows «تشخيص/شكوى» (`encounter_diagnoses` is the entity; the hospital uses it on the admission encounter; `admissions.diagnosis` stays as «سبب الدخول» only) and «علامات حيوية» (`hospital_vital_rounds` definitions/danger ranges from the engine).
- `../master-plan.md` row D4.

## Code reality (verified 2026-10-22)
- **Observations (D1):** `Modules/Clinic/app/Services/ObservationService.php` — `record(patientId, ?encounterId, array codeValues, source='manual', ?userId, ?CarbonInterface observedAt): string setId` (always a NEW set — exactly one nursing round), `writeSet(setId, …)` (update a known set in place; null value soft-deletes that code), `latestFor(Encounter|int, ?codes)`, `previousFor`, `prefillFor`, `series(patientId, code, ?from, ?to)`, `definitions(companyId, codes)`; sources `ClinicalObservation::SOURCES` (manual|device|import|portal|carried). Route `GET clinic/patients/{patient}/observations/series?code=` (`ObservationController`).
- **Core vitals codes** (`VitalsService::VITALS_CODES`, backfill `2026_11_03_140001` L24-27): `weight, height, bmi, bp_systolic, bp_diastolic, pulse, random_blood_glucose, temperature, spo2` (note: D1's brief said `bp_sys` — the real code is `bp_systolic`). **Inpatient definitions** (`Modules/Clinic/database/seeders/ClinicObservationDefinitionSeeder.php`, run by migration `140002`, only for companies that already have the core `weight` vitals question): `rr`, `on_oxygen` (boolean), `consciousness` (single_select **A/V/P/U**, danger V/P/U), `pain`, `intake_ml`, `output_ml`, `news2` (`input_type=computed`, `is_computed`, `computed_key='news2'`, normal_max 4, danger_max 6) — all core with `show_if {k:'encounter.kind', in:['inpatient','emergency']}`.
- **`news2` is NOT registered** in `Modules/Clinic/app/Support/ComputedRegistry.php` (keys: bmi, bsa, age, edd, ga_days, ga, map) although the definition points at it. The real formula lives in `Modules/Hospital/app/Services/NursingService::news(array $v): ?int` (static, L144-186): rr ≤8:3, ≤11:1, ≤20:0, ≤24:2, else 3 · spo2 ≤91:3, ≤93:2, ≤95:1, else 0 · on_oxygen +2 (not counted as a measured part) · bp_sys ≤90:3, ≤100:2, ≤110:1, ≤219:0, else 3 · pulse ≤40:3, ≤50:1, ≤90:0, ≤110:1, ≤130:2, else 3 · consciousness `A`→0 else 3 · temp ≤35:3, ≤36:1, ≤38:0, ≤39:1, else 2 · null if fewer than 3 measured parts.
- **`hospital_vital_rounds`** (`Modules/Hospital/database/migrations/2026_10_12_100005…` L65-86): company_id, admission_id, taken_at, temp decimal(4,1), pulse, bp_sys, bp_dia, rr, spo2, on_oxygen bool, consciousness string(2), pain, glucose, intake_ml, output_ml, news, notes(255), taken_by. Model `Hospital/app/Models/VitalRound.php`. Writer `NursingService::vitals(Admission, array, User): VitalRound` (L189; drops nulls, `vitals_empty` 422, `news = self::news(...)`); readers `vitalsList(Admission, limit 60)` (L202), `station()` (L231, last NEWS per bed, sorted by NEWS desc). Validation `NursingController` L70-73 accepts consciousness **A,C,V,P,U** (ACVPU — `C` = new confusion) while D1's definition options are AVPU only.
- **Column → code map** (the round is an observation set): `temp→temperature`, `pulse→pulse`, `bp_sys→bp_systolic`, `bp_dia→bp_diastolic`, `rr→rr`, `spo2→spo2`, `on_oxygen→on_oxygen`, `consciousness→consciousness`, `pain→pain`, `glucose→random_blood_glucose`, `intake_ml→intake_ml`, `output_ml→output_ml`, `news→news2`. `notes` has no definition (keep it on the round row).
- **InBody already writes observations (D1 did it):** `Modules/Clinic/app/Support/BodyCompositionObservations` (MAP weight/height/bmi/waist_cm→nut_waist/hip_cm→nut_hip, set id `bc`+id, hooks in `Modules/Nutrition/app/Models/BodyComposition.php` L50-51), and `PatientNutritionMetrics` reads it (L405). Nutrition definitions `nut_fat_pct`, `nut_muscle_kg`, `nut_visceral_fat`, `nut_bmr` exist but are not mirrored.
- **Diagnosis:** `encounter_diagnoses` (`Modules/Clinic/database/migrations/2026_06_22_150001…`): id, company_id, encounter_id, code(20) nullable, description NOT NULL, is_primary, recorded_by, timestamps, softDeletes — **no `stage`/type column**. Model fillable L28. `DiagnosisController::store` requires a code when `diagnosisService->isInsured($enc)`. Routes `encounters/{encounter}/diagnoses` (Clinic api L497-500). Hospital: `hospital_admissions.diagnosis` string(500) free text (+ `reason`), set in `AdmissionService::admit` (L77) / `AdmissionController` L79/L103, copied from `hospital_surgeries.diagnosis_pre` by `SurgeryService` L181, shown by `AdmissionReader` L92; `hospital_preauths.diagnosis`, `hospital_surgeries.diagnosis_pre/post` also free text. No Hospital code uses `encounter_diagnoses`.
- **Emergency/triage:** `Modules/Clinic/app/Enums/EncounterKind` already has `emergency` (docblock: «routes through triage»); `Admission::SOURCES` has `er`. **No triage table/column/service exists.** The live queue: `Modules/Clinic/app/Http/Controllers/QueueController.php` `ordered()` L65-71 = `orderByDesc('urgent') → queue_position → queue_no → id` on `appointments` (`appointments.urgent` exists, migration `2026_06_22_110001…`); `has_vitals` reads observations (D1).
- FE: `features/hospital/nursing/nursing-panel.component.*` vitals form (`saveVitals()` → `POST hospital/admissions/{id}/vitals`), NEWS badge `newsClass` (≥7 red, ≥5 amber), trend of last 12 rounds via `bar()`; `station.component.ts`. Clinic vitals panel `features/clinic/encounter/vitals-panel/**` (D1 — trend dialog/sparkline).

## Decisions already made (do not re-litigate)
1. **A nursing round = one observation set** (`ObservationService::record(patientId, admission.encounter_id, codeValues, 'manual', userId, taken_at)`), `encounter_id` = the admission encounter. `hospital_vital_rounds` keeps being written for ONE release (dual-write) behind `hospital.vitals_on_observations` (boolean, default true) and gets `set_id` string(26) nullable so a round row and its set are linked. Readers (`vitalsList`, `station`, chart) read observations when the flag is on, and the round row only for `notes`.
2. **NEWS2 has ONE implementation**: `ComputedRegistry` key `news2` taking observation codes (`rr, spo2, on_oxygen, bp_systolic, pulse, consciousness, temperature`), same bands as `NursingService::news()`; `NursingService::news()` becomes a thin adapter mapping its column names to codes and calling the registry (keep the static signature — tests and the station use it). The computed value is **stamped into the same set** as code `news2` (source of the set, `value_num` = score) on write.
3. **ACVPU:** add option `C` («تشوش جديد» / «New confusion») to the core `consciousness` definition (data migration, idempotent, per company; `danger_values` += `C`), and NEWS2 scores any non-`A` as 3 (already true).
4. **Missing definitions** (a company without core vitals ⇒ no inpatient definitions): before writing, `ObservationService::definitions()` filters unknown codes; if `rr`/`news2` are missing for the company, run `ClinicObservationDefinitionSeeder::seedCompany($companyId)` once (it is create-only) — never throw at the bedside; if still missing, write the round row only and log.
5. **Backfill** `hospital_vital_rounds` → observations: one set per round (`source='import'`, `observed_at=taken_at`, `encounter_id` from the admission; skip admissions without encounter), store the set id on the round; idempotent (skip rounds that already have `set_id`).
6. **InBody:** nothing mandatory — D1 already mirrors weight/height/bmi/waist/hip. Extend `BodyCompositionObservations::MAP` with the four extra nutrition measures **only** if `nutrition_body_compositions` has matching columns (read the table: fat %, muscle mass, visceral fat, BMR) → `nut_fat_pct`, `nut_muscle_kg`, `nut_visceral_fat`, `nut_bmr`; add a test. Do not touch Nutrition files.
7. **Hospital diagnosis in one table:** `encounter_diagnoses.stage` string(16) nullable (`admitting` | `working` | `discharge` | `pre_op` | `post_op`; null = an ordinary visit diagnosis). The admission's diagnosis is an `encounter_diagnoses` row `stage=admitting` on the admission encounter (code nullable — free text allowed for non-insured; for insured admissions the existing `DiagnosisController` rule applies only to doctor entries through that controller, not to the import). `hospital_admissions.diagnosis` stays (= «سبب الدخول» text for the bill/reports) and is kept in sync one way: admit/update writes both; the admission reader shows the `encounter_diagnoses` rows when present. Surgery pre/post diagnoses: `SurgeryService` additionally writes `stage=pre_op` / `post_op` rows on the surgery's admission encounter when one exists (keep the string columns).
8. **Triage (optional, off by default):** setting `clinic.triage.enabled` (boolean, default false). `appointments` + `triage_level` unsignedTinyInteger nullable (1 = red/immediate … 5 = blue/non-urgent, Manchester-style 5 levels), `triage_color` string(8) nullable (derived, stored for display), `triaged_at`, `triaged_by`, `triage_note` string(255) nullable. When enabled, `QueueController::ordered()` sorts `triage_level IS NULL, triage_level ASC` **before** `urgent` — only for rows that have a level; untriaged rows keep today's order after them. The triage measurement set (vitals) is a normal observation set on the encounter (no special table). Emergency encounters: when the appointment's department/visit is flagged emergency (read how `EncounterKind::Emergency` is assigned — if nothing assigns it yet, a triaged appointment whose level is set opens its encounter with `kind=emergency` through `EncounterService::open` — **do not edit `Encounter.php`**).

## Shared-file rules
- **Orchestrator-owned — REPORT, never edit:** `Modules/Clinic/routes/api.php`, `Modules/Hospital/routes/api.php`, `RolePermissionSeeder.php`, `StandardRolesSeeder.php`, `ClinicSettingDefinitionSeeder.php`, `HospitalSettingDefinitionSeeder.php`, `config/moonstack.php`, CHANGELOG (2 lines EN + AR).
- **Hot — do NOT edit:** `Services/ObservationService.php` (call it; if you need a method, report it), `VitalsService.php`, `Models/Encounter.php`, `HistoryService.php`, `EncounterResource.php`, `visit-shell/**`, `consultation.component.*`, `features/clinic/encounter/vitals-panel/**`, Nutrition module files, `PrescriptionRules`/prescription files.
- **D4 owns:** `Modules/Clinic/app/Support/ComputedRegistry.php` (add `news2` only), `Support/BodyCompositionObservations.php` (MAP only, Decision 6), `Http/Controllers/QueueController.php` (`ordered()` + selecting the triage columns — D1 ✅ first), `Models/EncounterDiagnosis.php` (fillable/cast `stage`), `Http/Resources/*Diagnosis*Resource.php` (expose `stage`), `Services/DiagnosisService.php` / `DiagnosisController.php` (accept optional `stage`), `Models/Appointment.php` (fillable/casts), queue resources (expose triage), new `Http/Controllers/TriageController.php`; Hospital `NursingService.php` (vitals/news/vitalsList/station only), `AdmissionService.php` (diagnosis write only), `AdmissionReader.php`, `SurgeryService.php` (diagnosis rows only), `HospitalSettings.php`; FE `features/hospital/nursing/**`, the reception queue component (find it under `features/clinic/`), no shell files.
- FE i18n targeted keys under `HOSPITAL.VITAL.*`, `CLINIC_QUEUE.TRIAGE.*` (use the namespace the queue screen uses); no `I18N_VERSION` bump.

## Do

### D4.1 — rounds on observations + NEWS2
1. **Migration `Modules/Hospital/database/migrations/2026_11_03_260000_add_set_id_to_hospital_vital_rounds.php`**: `set_id` string(26) nullable indexed (guarded).
2. `ComputedRegistry::register('news2', …)` (Decision 2) + unit test with the band edges (rr 8/9/11/12/20/21/24/25, spo2 91/92/93/94/95/96, bp 90/91/100/101/110/111/219/220, pulse 40/41/50/51/90/91/110/111/130/131, temp 35/35.1/36/36.1/38/38.1/39/39.1, oxygen, consciousness, <3 parts ⇒ null). `NursingService::news()` delegates.
3. `NursingService::vitals()`: flag on ⇒ map columns → codes (Code reality map), `record(...)` with `observedAt = taken_at`, compute `news2` from the set values via the registry and write it into the same set (`writeSet($setId, …, ['news2' => $score])` — `ObservationService::split()` L424-434 already treats `computed` as numeric; if `definitions()` filters computed definitions out, report the one-line relaxation needed instead of editing the service), then the round row (with `set_id`, `news`). One transaction.
4. Readers: `vitalsList()` returns the same JSON keys as today (FE contract) built from the sets of the admission encounter (+ round `notes`/`taken_by` when linked); `station()` last NEWS from the latest `news2` observation of the encounter.
5. **Data migrations** `…260001_backfill_vital_rounds_to_observations.php` (Decision 5, chunked, portable SQL) and `Modules/Clinic/database/migrations/2026_11_03_260002_add_acvpu_confusion_option.php` (Decision 3).
6. Setting `hospital.vitals_on_observations` (REPORT row; add to `HospitalSettings::for()`).
7. FE nursing panel: consciousness select adds `C`; the vitals trend opens the shared series dialog for one code when a cell is clicked IF D1 exposed it as a standalone component (read `features/clinic/encounter/vitals-panel/` — reuse, don't copy; if it is not reusable, report and keep today's bars). Owner acceptance «جولة تمريض تظهر في trend المريض بعد الخروج» is satisfied by the series endpoint — verify by test.

### D4.2 — InBody (Decision 6) — verify + optional MAP extension, one test.

### D4.3 — hospital diagnosis in one table
1. **Migration `Modules/Clinic/database/migrations/2026_11_03_260003_add_stage_to_encounter_diagnoses.php`**: `stage` string(16) nullable + idx (encounter_id, stage) (guarded).
2. **Data migration `…260004_backfill_admission_diagnoses.php`**: for each `hospital_admissions` with non-empty `diagnosis` and an `encounter_id` and no `encounter_diagnoses` row `stage=admitting` on it → insert one (`description` = diagnosis, `code` null, `is_primary` = true unless the encounter already has a primary, `recorded_by` = admission created_by). Idempotent.
3. `AdmissionService::admit()` / update path / `SurgeryService` (Decision 7) — write through `DiagnosisService` if it has a create method (read it); else `EncounterDiagnosis::create` with the company scope.
4. Diagnosis API accepts/returns `stage` (`nullable|in:admitting,working,discharge,pre_op,post_op`); FE: the admission file shows the admission's diagnoses (read-only list with stage chips) in its header area — one small block, no new tab; editing stays in the clinic diagnosis panel on the admission encounter (link exists at admission-file html L58).

### D4.4 — optional triage
1. **Migration `Modules/Clinic/database/migrations/2026_11_03_260005_add_triage_columns_to_appointments.php`** (Decision 8).
2. `TriageController::store(Request, int $appointment)` body `{level 1..5, note?, vitals?: {code: value}}` → sets the columns (+ `triage_color` from a const map red/orange/yellow/green/blue), records the vitals as an observation set on the appointment's encounter when one exists (else stores nothing for vitals and returns a hint), permission `clinic.triage.record`; 422 when `clinic.triage.enabled` is false.
3. `QueueController::ordered()` + queue resource fields `triage_level`, `triage_color`, `triaged_at` (Decision 8).
4. FE (reception queue screen — find the component that calls `GET clinic/queue`): a coloured triage dot per row and a «فرز» action (dialog: 5 coloured level buttons + note + the 5 vitals that matter: bp, pulse, spo2, temp, rr) shown only when the setting is on and the user has the permission. **Fable consult (one, focused):** screenshot the queue screen and ask only for the triage dialog + row marker. No shell card here — report a card def `{id:'triage', kind:'ctx', sizes:['S','M']}` for S3 to add.
5. REPORT: setting `clinic.triage.enabled` (boolean, false, group of the queue settings), permission `clinic.triage.record` (nurse, reception), route `POST appointments/{appointment}/triage` → `TriageController@store` (`permission:clinic.triage.record`, name `appointments.triage`).

## Tests
- `Modules/Hospital/tests/Feature/D4VitalRoundsObservationsTest.php` (prefix `d4h_`): a round writes one set with the mapped codes + `news2` stamped; `vitalsList` returns the same keys as before; station sorts by NEWS from observations; flag off ⇒ only the round row; backfill links old rounds once; the encounter's `series?code=bp_systolic` includes the round after discharge; company without definitions ⇒ seeded then written; ACVPU `C` accepted and scored 3.
- `Modules/Clinic/tests/Unit/D4News2Test.php` (band edges, Decision 2).
- `Modules/Clinic/tests/Feature/D4DiagnosisStageTest.php` (prefix `d4d_`): admit writes the admitting row; backfill idempotent; diagnosis API round-trips `stage`; insured rule unchanged for doctor entries.
- `Modules/Clinic/tests/Feature/D4TriageTest.php` (prefix `d4t_`): disabled ⇒ 422; level 1 appointment sorts before an `urgent` untriaged one when enabled, not when disabled; triage vitals create a set on the encounter.
- Run also `HospitalNursingTest.php`, `HospitalAdmissionTest.php`, `HospitalSurgeryTest.php`, `D1ObservationsTest.php`, any `*Queue*Test.php`, `*Diagnosis*Test.php`, Nutrition `*BodyComposition*Test.php` — keep green.

## Out of scope
Dropping `hospital_vital_rounds` (next release), migrating `hospital_nursing_notes` to `clinical_notes` (later), `medical_history` json read-only switch (D2 owns facts), the shell inpatient card (S3), a full ER module.

## Report
Files, test output, route lines, setting rows (`hospital.vitals_on_observations`, `clinic.triage.enabled`), permissions, the triage card def for S3, any `ObservationService` method you needed, CHANGELOG 2 lines (EN + AR), anything not done.
