# W1-BE-A report: patient read fixes, inpatient stay data, server flow state (BE)

Date: 2026-10-23 · Branch hazemdev3 · Nothing committed · No new permissions.

## Main finding (I9): orders on an admission encounter do not reach the stay bill
I did not change the ordering mode, because the brief's condition for changing it is false.

- When a patient is admitted, `AdmissionService::start()` calls `EncounterService::open(..., false)`. That always dispatches `EncounterStarted`, and the `OpenServiceOrderForEncounter` listener then creates an **empty open ServiceOrder** for the encounter. After that, `Folio::open()` creates the real stay bill (a second order, with a higher id).
- `EncounterServiceOrder::findForEncounter()` picks the lowest id (`->first()`). It is used by `DecideClinicalIntent` and by the immediate lab/rad paths. So every lab/rad/service order on an admission encounter goes to the **stray order, not the stay bill**. This happens in **both** ordering modes.
- I reproduced it in a test: a "decide here" intent's line had `service_order_id = 1` while the folio was `2`.
- Dev MySQL shows the same thing for 7 of 8 admissions (tinker, verbatim):
  ```
  1 enc=20 folio=69 orders=[{"id":68,"status":"open","patient_due":"0.000"},{"id":69,"status":"closed","patient_due":"2300.000"}]
  2 enc=21 folio=70 orders=[{"id":70,"status":"closed","patient_due":"3095.000"}]
  3 enc=22 folio=72 orders=[{"id":71,...0.000},{"id":72,...1800.000}]
  4 enc=23 folio=74 orders=[{"id":73,...},{"id":74,...}]
  5 enc=24 folio=76 ...  7 enc=25 folio=78 ...  8 enc=26 folio=80 ...  9 enc=27 folio=82 ...
  ```
- The `beginConsult` flag on `EncounterStarted` does not help. It only controls the appointment transition; the listener creates the order anyway.
- Two readers pick different orders:
  - `EncounterResource::billingOrderId()` sorts `orderByDesc('id')`, so `service_order_id` in the rail points at the **folio** (correct by accident).
  - `ServiceOrderController::encounterOrder` (`GET encounters/{e}/order`) uses `->first()` on open orders, so it returns the **stray, empty** order.
- Effect on my work: `stay-bill` reads `admission.service_order_id` (the folio), so it will **not** show lab/rad lines until this routing is fixed.
- Suggested fix (shared layer, [FIN], needs approval, not done):
  - Either `Folio::open()` adopts the encounter's existing empty open order instead of creating a second one,
  - or `EncounterServiceOrder::findForEncounter()` prefers `hospital_admissions.service_order_id` for an admission encounter,
  - or the listener skips admission-kind encounters.
  - Plus a data repair on dev for the 7 stray empty orders (ids 68, 71, 73, 75, 77, 79, 81, which look like `patient_due = 0`).
  - Once that is fixed, forcing `ordering_mode='intent'` for inpatient/day_case is a small change: `OrderingMode::forEncounter()` plus the resource line. I wrote and tested it, then reverted it.

## What was done
1. **B1 insurance.** `PatientReadService::insurance()` returns `{company_name, contract_id, contract_name, coverage_percent}` from the first `insurance_info` contract.
   - The contract row is looked up within the patient's company.
   - `company_name` is the contract's insurance company (`insurance_company_ar` in Arabic), else the contract name, else the stored `contract_name`.
   - `coverage_percent` is set only for `coverage_type=percentage`.
   - It is `null` when the patient is not insured. Both headers read `summary.insurance.company_name`, so the FE needs no change.
2. **B2 latest results.** `last_labs` rows now include `flag` (H/L/HH/LL/A, null for normal), `unit` and `date` (released, else approved, as ISO).
3. **#4 Rh negative.** `PatientFactsReader::rhNegativeAlert()` adds a derived alert `{kind:'custom', code:'rh_negative', label 'Rh سالب'/'Rh negative', severity info, source 'blood'}`.
   - It is skipped when any stored or derived alert already has that code (a dismissed stored one stays dismissed).
   - `isRhNegative()` accepts `-`, `−`, `neg` and `negative`.
   - The label uses a locale check in code rather than the lang files, to stay out of W1-BE-B's files.
4. **#45 signer.** Added `Encounter::signer()` (User). EncounterResource now returns `signer: {id,name}|null` and `signed_by_name`. `EncounterController::show` eager-loads `signer:id,name`.
5. **I8/#9 stay header.** `GET clinic/encounters/{id}/stay` (permission `clinic.encounters.view`, same as encounter show) returns:
   - `{admission_id, admission_no, kind, status, in_bed, ward, room, bed, admitted_at, hours, day_n, expected_nights, expected_discharge_on, attending_doctor:{id,name}}`
   - plus discharge fields (`discharge_ordered_at, discharged_at, discharge_type, follow_up_on`), so the discharge card summary also works without Hospital permissions.
   - `day_n` uses the same calculation as `AdmissionReader::row()` and the FE (`days + 1`).
   - It returns 404 when there is no admission or the encounter belongs to another company. It is guarded with `class_exists` in case the Hospital module is absent.
6. **#35 stay bill.** `GET clinic/encounters/{id}/stay-bill` returns `{admission_id, service_order_id, currency, total, paid, deposit, balance, lines:[{id,label,qty,amount,group,date}] (latest 8), lines_count, can_open_bill}`.
   - Permission: `hospital.billing.manage|hospital.admissions.manage|hospital.beds.view|clinic.payments.view|clinic.orders.view`. `clinic.orders.view` is included because `GET encounters/{e}/order` already shows the same order's money to that permission. Without one of these the user gets 403.
   - `can_open_bill` is true when the user can open the Hospital bill (`hospital.beds.view|admissions.manage|billing.manage`).
   - `currency` comes from `CompanyCurrency::code`.
7. **#20 server flow state.**
   - Migration `2026_11_20_100001_add_shell_state_to_encounters` adds `encounters.shell_state` (JSON, nullable). `Encounter` gets it in fillable plus an `array` cast.
   - `GET …/shell-state` (permission `clinic.encounters.view`) returns `{done, active, updated_by, updated_by_name, updated_at}`.
   - `PUT …/shell-state` (permission `clinic.encounters.update|clinic.vitals.record|clinic.history.record`, so a nurse can mark «تم») takes:
     - `done`: required (present) array, at most 60 items, each ≤40 chars matching `^[A-Za-z0-9_]+$`, de-duplicated;
     - `active`: nullable, same rule.
   - PUT on a signed or cancelled visit returns 422 (`assertEditable`). PUT replaces the whole state.
   - It is saved with `timestamps=false` and `saveQuietly`, so the visit's `updated_at` does not change and no activity is logged.
8. **I9.** Not changed; see the main finding above.

**Access audit.** I added `api.clinic.encounters.stay`, `.stay-bill` and `.shell-state.show` to `RouteMap` as `['view','encounter',true]`. They are patient-bearing GETs, and `FinRouteMapCoverageTest` requires every such GET to be mapped. This also enforces the patient data scope on them.

## Decisions / deviations
- **Bill money is computed without writes.** It reuses `AdmissionReader::row()['money']`, the same arithmetic as the Hospital admissions list and board.
  - I did not use `SettlementService::preview()`: it goes through `Folio::summary()`, which calls `ServiceOrder::recalculateBillingStatus()` and that saves.
  - I did not call `NightService::catchUp()`: it posts night charges.
  - Side effect: the stay bill lags until the night scheduler runs or someone opens a Hospital screen. On dev, admission 3 (day 13) shows 1 night = 1800.
  - Decision for the orchestrator: if you want the card current, calling `catchUp` (idempotent, and the Hospital list GET already does it) is a one-line change, but it is a write.
- `balance` = what the patient still owes on the bill (`total − paid`) before the deposit is applied; `deposit` is reported separately.
- The Rh alert uses `kind: custom`, not `condition`, so it cannot collide with the condition-code dedup.
- `LabPatientFactory` gives a random `blood_group`, so about a quarter of factory patients will now get an Rh info alert. My tests fix the blood group to `A+` unless a test needs something else. I found no existing test that asserts an exact, unfiltered alerts array (the D2b `alerts[0]` check still passes because info alerts sort last), but future tests could trip on this.

## Files
New:
- `Modules/Clinic/app/Services/EncounterStayReader.php`
- `Modules/Clinic/app/Http/Controllers/EncounterStayController.php`
- `Modules/Clinic/app/Http/Controllers/EncounterShellStateController.php`
- `Modules/Clinic/database/migrations/2026_11_20_100001_add_shell_state_to_encounters.php`
- `Modules/Clinic/tests/Feature/W1BeaVisitShellDataTest.php`

Edited (shared files kept to minimal edits):
- `Modules/Clinic/app/Services/PatientReadService.php` (insurance + labs)
- `Modules/Clinic/app/Services/Facts/PatientFactsReader.php` (Rh)
- `Modules/Clinic/app/Models/Encounter.php` (fillable/cast `shell_state`, `signer()`)
- `Modules/Clinic/app/Http/Resources/EncounterResource.php` (signer keys only)
- `Modules/Clinic/app/Http/Controllers/EncounterController.php` (show eager-loads `signer:id,name`; one line)
- `Modules/Clinic/routes/api.php` (two `use` lines plus one block of 4 routes after `encounters.amendments`)
- `Modules/Clinic/app/Support/AccessAudit/RouteMap.php` (3 entries)

`OrderingMode.php` ended unchanged. All files were chowned to moonui3; `dump-autoload` and `optimize:clear` were run with the ea-php82 CLI.

## Migrations run on dev
`artisan migrate --force --path=Modules/Clinic/database/migrations/2026_11_20_100001_add_shell_state_to_encounters.php` → DONE. `Schema::hasColumn('encounters','shell_state')` = true on `moonui3_dev_be`.

## Tests (SQLite, Pest), run with results
- `Modules/Clinic/tests/Feature/W1BeaVisitShellDataTest.php`: **12 passed (134 assertions).** Covers B1 (3 shapes plus a cross-company contract), B2, #4 (both cases plus dedup and spellings), #45, I8 (no Hospital permission, while the Hospital route is 403 for the same user; 404/403 cases), #35 (money, lines, `can_open_bill`, no writes to nights/order row/lines, 403/404), #20 (round-trip across users, `updated_at` unchanged, 422 validation, 403, signed → 422).
- Regression check on the code I changed, run once: `FinRouteMapCoverageTest`, `PatientSummaryTest`, `D2bHeaderSnapshotTest`: **28 passed (308 assertions).**
- On live MySQL (dev, tinker, read-only): `stay()` and `bill()` for encounter 22 / admission 3 returned the expected shapes (currency EGP, ward «جراحة رجالي», bed 204, day_n 13); the admission and order `updated_at` values were unchanged afterwards. The dev patient has no insurance, labs or Rh-negative group, so B1/B2/#4 could only be checked by tests on MySQL.

## Not verified
- I did not call any endpoint over HTTP against the live dev host. I did not PUT shell-state on dev, to avoid writing to real visits.
- No FE work was done. The FE still needs to: read `insurance.company_name` (already the case), use `flag`/`date`, render the Rh chip, show `signed_by_name`, switch `inpatient-stay.service.ts` to `clinic/encounters/{id}/stay`, add the stay-bill card that hides on 403, and replace localStorage with shell-state.

## Proposed CHANGELOG `[Unreleased]` bullets (for the orchestrator; I did not edit the shared file)
- AR: «رأس الزيارة: اسم شركة التأمين يظهر صح بدل "نقدي"، وآخر النتائج بتتلوّن حسب المرتفع/المنخفض مع الوحدة والتاريخ، وتنبيه "Rh سالب" لفصيلة الدم السالبة، واسم الطبيب اللي وقّع الزيارة.» / EN: "Visit header: the insurer name shows instead of "Cash", latest results are coloured by high/low with unit and date, an "Rh negative" alert for negative blood groups, and the signer's name on signed visits."
- AR: «زيارة الإقامة: شريحة السرير/الجناح/اليوم والطبيب المعالج تظهر لكل اللي يقدر يفتح الزيارة، وكارت "حساب الإقامة" للي عنده صلاحية الحسابات.» / EN: "Inpatient visit: the bed/ward/day chip and the attending doctor show for anyone who can open the visit, and a "Stay bill" card for users with billing access."
- AR: «حالة خطوات الزيارة (تم/الحالية) بتتحفظ على الزيارة نفسها فتظهر على كل الأجهزة.» / EN: "The visit's step state (done/current) is saved on the visit, so it shows on every PC."
