# P4 — results without a visit · patient lab history by patient · rad report with images + PDF · claim codes from the real catalogs

Read `_COMMON.md` first. One WP, BE + small FE. **Review: [FIN]** — Decision 5 splits claim money per investigation (insurer/patient shares); the orchestrator routes the claim part through the design review before commit. Migration range **`2026_11_03_240000`–`240099`**.

**Depends on:** P3 ✅ (`ExecuteCatalogItem`, rad lifecycle, `clinical_templates`, `RadReportPdfService`, `rad_procedures.sbscs_code/default_template_id`, `rad_reports.template_id/body_structured` — committed da48132da), P0 ✅ (`clinical_documents.encounter_id` nullable + unique `(company_id, ref_key)`, attachments morph), AF2 ✅ (report writers keyed by ref_key — AF2 owned the two Drop* listeners; **P4 owns them now**). **Step 5 (images in the PDF) depends on P5 ✅** — P5 moves `RadReportPdfService` onto the shared print engine (`Services/Print/ClinicalPdfService`, Arabic shaping); do not touch that service while P5 runs. If P5 is not ✅ when you reach step 5, do steps 1–4 and 6–8 and report step 5 as pending. **Patient record API:** C1b builds `PatientRecordService` (design.md §8 put it in P4; the C1 brief promoted it) — P4 only registers a section if C1b ✅ (step 7).

## Spec (read before coding)
- `../design.md` §3 rule 8 («النتيجة بترجع للمريض، والزيارة اختيارية»), §4.2 rows «النتائج لكل سياق» and «المطالبات», §4.3 rows «الصور/المرفقات», «تقرير + طباعة», §4.4 (first sentence: `clinical_documents` linked to patient always, encounter optional), §8 row P4 (owner test: «طلب LIS مباشر (walk-in) يظهر في تاريخ تحاليل المريض من شاشة الكشف؛ تقرير أشعة بقالب يتطبع PDF بصورة مرفقة»), §9 #10 (ClaimLine per investigation — recommended).
- `../master-plan.md` row P4 («تحليل walk-in يظهر في شاشة الكشف · تقرير أشعة PDF بصورة»).
- `../design-data-layers.md` §8 row P4 (`PatientRecordService::extend`).

## Code reality (verified 2026-10-22)
- **`clinical_documents`** (`Clinic/database/migrations/2026_06_22_160000…` + `2026_11_03_100003…`): encounter_id **nullable**, patient_id nullable, doc_type(30) (`lab_report|rad_report|note|external_result`), ref_key, lab_request_id, rad_report_id, title, body, data json, status default `released`, released_at; unique `clinical_docs_company_ref_unique (company_id, ref_key)`. Model `Models/ClinicalDocument.php` — its docblock still says the key is (company, encounter, ref_key): fix the comment.
- **`Support/ClinicalDocumentWriter`**: `find(companyId, refKey)` (withTrashed), `upsert(companyId, refKey, values)` (restores a trashed row) — accepts a null encounter. ref_keys: `lab:reqinv:{lab_request_investigation_id}`, `rad:report:{id}`, `intent:external:{intent_id}`.
- **The writers still skip visit-less results** (design.md §4.2 says they must not):
  - `Listeners/DropLabReportOnEncounter.php` L28-34: `$encounterId = $result->labRequest?->encounter_id ?? LabRequest::…->value('encounter_id'); if (! $encounterId) { return; }` (also returns unless status Released, L41). Upsert L57, doc_type `lab_report`, `data` = result_value/unit/abnormal_flag/released_at/investigation_id, then `markIntentsResulted(...)`.
  - `Listeners/DropRadReportOnEncounter.php` L28-35: `if (! $order || ! $order->encounter_id) { return; }`. Writes body "Findings…Impression" and **no `data`** (no template, no image ids).
  - `Actions/SubmitExternalResult.php` L82 has no guard (encounter may be null) — its file goes to Core `Attachment` (attachable = intent), not `clinic_patient_attachments`. Leave it.
- **`Services/PatientLabHistoryService.php` lives in Clinic, not LIS.** It already reads by `patient_id`, but `byEncounter()` (L47) builds «direct requests» with `->whereNotNull('encounter_id')` (L226-227) → **LIS walk-in requests are excluded**. `byEncounter` already supports a group with `encounter: null`. `lastPerRef()` (L117, L144-149) has no encounter filter. `countsByEncounter()` L88. Routes `GET clinic/patients/{patient}/lab-history?limit=` and `/lab-last` (`Clinic/routes/api.php` L564-565, `clinic.encounters.view|clinic.intents.view`). Other consumers: `Nutrition/app/Services/Visit/PatientLabsService.php`, `VisitTimelineService.php` (they must keep working).
- **LIS** `lab_requests`: `patient_id` nullable (FK lab_patients), `encounter_id` nullable (no FK), `source` default `walk_in` (`RequestSource`: walk_in, in_patient, emergency, external, clinic). Lab results reach the encounter screen through `LabOrderController::index` (`GET encounters/{e}/lab-orders`, L40-70) which reads `clinical_documents where encounter_id = …` — encounter-scoped by design (keep).
- **FE**: `features/clinic/encounter/orders/lab-history-groups.component.ts` (`app-lab-history-groups`, inputs `groups` (required), `currentEncounterId`, `canReorder`, `showHead`; output `reorder`) is fed by `ClinicLabHistoryService.history(patientId, 10)` (`features/clinic/services/clinic-lab-history.service.ts` L24) from `lab-orders.component.ts` L303/L310 and by `nutrition/visit/cards/labs-card.component.ts` L65. The patient file (`features/clinic/patients/clinic-patient-file.component.*`, tabs consult|dental|overview|visits|ledger|loyalty) has no results tab.
- **Radiology** (P3): `rad_reports` + template_id, body_structured; `rad_procedures` + sbscs_code(50), default_template_id; `rad_studies` has **no patient_id** (patient via `rad_orders.patient_id`), `study_uid` written by `Actions/AcquireRadStudy` L41. `Services/RadReportPdfService::render(RadReport, string $lang='ar'): DomPDF` (422 unless Released) with view `Clinic/resources/views/rad/report-pdf.blade.php` — **no images**. FE `features/clinic/radiology/rad-report-editor.component.*` (`app-clinic-rad-report-editor`) has a template picker (html L129, `applyTemplate` ts L273, prefill from `procedure.default_template_id`) — **no image upload**. `rad-lifecycle.service.ts` (pdf, `listTemplates`).
- **Attachments**: `clinic_patient_attachments` + `attachable_type/attachable_id`, `kind` (image|pdf|photo|video|link|file|report|consent), `external_url`, `is_private` (P0/AF3). Allow-list `Models/ClinicPatientAttachment.php` L41 includes `'rad_study' => RadStudy::class`; `patientIdOf()` L87 resolves rad_study → rad_order → patient. Upload `POST clinic/patients/{patient}/attachments` (`ClinicPatientAttachmentController::store`, `clinic.patients.update`, jpeg/png/pdf ≤10MB, disk `local`, path `clinic/attachments/{company}/{patient}`); `index` filters by attachable; download `GET …/attachments/{a}/download`.
- **Claims**: `Services/ClaimAssembler::fromOrder(ServiceOrder, PayerContract): Claim` — one `ClaimLine` per non-cancelled `ServiceOrderLine`, code from **L68 only**: `'sbscs_code' => $line->clinicService?->sbscs_code ?? null` → lab and rad lines get no code. `ServiceOrderLine` has `line_type`, `catalog_ref_type`, `catalog_ref_id`, `fulfillment_type`, `fulfillment_id`. Code columns: `lab_investigations.sbscs_code` string(15) (NPHIES migration), `clinic_services.sbscs_code` string(40), `rad_procedures.sbscs_code` string(50), **`lab_packages` has none**, **`clinic_claim_lines.sbscs_code` is string(15)** (too short for rad/clinic codes). NPHIES contract `NPHIES/app/Contracts/FhirServiceItemSource` (`sbscsCode()`) is implemented by `ClinicService` (L155) and `LabInvestigation` (L268), **not** `RadProcedure`; builder `NPHIES/app/Services/FhirServiceItemBuilder.php` L41-49.
- **No `PatientRecordService`, no `GET clinic/patients/{p}/record`, no `clinical-documents` route** exist today (C1b / P5 add them).

## Decisions already made (do not re-litigate)
1. **A result always lands on the patient; the encounter is optional.** Both Drop* listeners write when there is a patient even without an encounter: `encounter_id` = the request's/order's encounter or null; `patient_id` = `lab_requests.patient_id` / `rad_orders.patient_id`; no patient AND no encounter ⇒ skip (log). Same ref_key, same upsert — a later-linked encounter just fills `encounter_id`.
2. **Lab history by patient** = `PatientLabHistoryService::byEncounter` includes encounter-less LIS requests (drop the `whereNotNull('encounter_id')` at L226-227) into the existing `encounter: null` group, labelled by the request date + source (`walk_in`/`external`…) — the FE shows that group as «بدون زيارة · {source}». `countsByEncounter` keeps counting per encounter (null group counted under key `none` — read how it returns and keep its shape for existing keys).
3. **Encounter screen = patient history**, not only this visit: the lab-orders card's history panel already renders `history(patientId, 10)` — a walk-in result appearing there is the owner's acceptance. No new screen.
4. **Rad report template is P3's** (picker, structured body, default template). P4 adds **images**: the radiologist/technologist uploads images in the report editor as `clinic_patient_attachments` with `attachable_type='rad_study'`, `attachable_id=study.id`, `kind='image'` (or `pdf`), through the EXISTING upload endpoint — no new upload route. The released report's `clinical_documents.data.attachment_ids` lists them; the PDF embeds image attachments (jpeg/png) as thumbnails (max 4 per page, 2 columns), PDFs are listed by name only. Private attachments (`is_private`) are never embedded.
5. **Claim code from the real catalog**, by `catalog_ref_type` of the line, falling back to the clinic service: `lis_investigation` → `lab_investigations.sbscs_code`; `lis_package` → `lab_packages.sbscs_code` (new column); `rad_procedure` → `rad_procedures.sbscs_code`; `clinic_service` / anything else → `clinic_services.sbscs_code` (today's behaviour). One resolver class `Modules/Clinic/app/Support/ClaimCodeResolver::for(ServiceOrderLine): ?string` used by `ClaimAssembler` — no inline branching in the assembler. **ClaimLine per investigation** (design §9 #10): when a lab line's fulfillment is a `LabRequest` with several investigations, create one `ClaimLine` per `lab_request_investigation` with that investigation's code, splitting qty 1 each and the money proportionally to each investigation's price on the request (last line takes the rounding remainder so totals stay exact to 3 dp). Package lines stay one claim line with the package code. If the request/investigations cannot be resolved, fall back to one line (never drop money).
6. **`RadProcedure` implements `FhirServiceItemSource`** (mirror `ClinicService::sbscsCode()` / the other methods of the contract) so NPHIES builds rad items the same way.
7. **No `patient_id` on `rad_studies`** — resolve through the order (do not add a column).

## Shared-file rules
- **Orchestrator-owned — REPORT, never edit:** `Modules/Clinic/routes/api.php`, `Modules/LIS/routes/api.php`, `RolePermissionSeeder.php`, `StandardRolesSeeder.php`, `ClinicSettingDefinitionSeeder.php`, `config/moonstack.php`, CHANGELOG (2 lines EN + AR).
- **Hot files — do NOT edit:** `Services/RadReportPdfService.php` + `resources/views/rad/report-pdf.blade.php` **until P5 ✅** (then you own the view + the data provider only, never the engine `Services/Print/**`), `Support/ClinicalDocumentWriter.php` (call it; behaviour unchanged), `Actions/SubmitExternalResult.php`, `Models/Encounter.php`, `HistoryService.php`, `visit-shell/**`, `consultation.component.*`, `prescription/**`, `Services/PatientRecordService.php` (C1b).
- **P4 owns:** `Listeners/DropLabReportOnEncounter.php`, `Listeners/DropRadReportOnEncounter.php`, `Services/PatientLabHistoryService.php`, `Services/ClaimAssembler.php`, new `Support/ClaimCodeResolver.php`, `Models/RadProcedure.php` (contract only), `Models/ClinicalDocument.php` (docblock only), FE `features/clinic/radiology/rad-report-editor.*`, `features/clinic/encounter/orders/lab-history-groups.component.ts` (null-group label only), `features/clinic/services/clinic-lab-history.service.ts` (types only).
- FE i18n targeted keys under `RADIOLOGY.*` / `LAB_HISTORY.*` (use the namespaces you find); no `I18N_VERSION` bump.

## Do (BE)
1. **Migration `Modules/LIS/database/migrations/2026_11_03_240000_add_sbscs_code_to_lab_packages.php`**: `lab_packages.sbscs_code` string(50) nullable (place it after `code` only if that column exists — read the table first; guarded). **Migration `Modules/Clinic/database/migrations/2026_11_03_240001_widen_claim_line_sbscs_code.php`**: `clinic_claim_lines.sbscs_code` → string(50) nullable (`->change()`, guarded). Add `sbscs_code` to the `LabPackage` model fillable and to the LIS package request/resource (read `LabPackageController`/its FormRequest/Resource — validation `nullable|string|max:50`).
2. **Listeners** (Decision 1). Lab: resolve `patient_id` from the request (`$result->labRequest?->patient_id`), keep the Released guard, write `encounter_id` nullable, keep `markIntentsResulted` only when an encounter exists (read it — intents need an encounter). Rad: resolve the order through study → order; write `patient_id = order.patient_id`, `encounter_id = order.encounter_id` (nullable), `data = ['template_id', 'body_structured', 'attachment_ids' => ids of non-private attachments on the study, 'procedure_id', 'study_id']`. Re-fire on re-release (upsert) keeps one row.
3. **Backfill migration `Modules/Clinic/database/migrations/2026_11_03_240002_backfill_visitless_clinical_documents.php`** (idempotent, chunked): released `lab_results` whose request has no encounter but a patient and no `clinical_documents` row for `lab:reqinv:{id}` → upsert the same row the listener writes; released `rad_reports` whose order has no encounter → `rad:report:{id}`. Use the listener's mapping through a shared static/private builder (extract `DropLabReportOnEncounter::documentValues(...)` style methods so the migration and the listener share one mapping). No deletes.
4. **`PatientLabHistoryService`** (Decision 2): drop the encounter filter for direct LIS requests; add `source` + `requested_at` to every item in the null group; respect company scoping; make sure Nutrition `PatientLabsService` / `VisitTimelineService` outputs don't change for encounter groups (read both).
5. **Rad PDF images** (after P5 ✅): extend the report view (the P5-engine version) with an «الصور / Images» block fed by a small provider `Modules/Clinic/app/Support/RadReportImages::for(RadReport): array` (non-private image attachments of the study → `{name, data_uri}` read from the attachment disk, max 8, each downscaled only if GD is available — otherwise embedded as is, max 2 MB each, skip larger). The PDF still 422s unless Released.
6. **Claims** (Decisions 5-6): `ClaimCodeResolver` + `ClaimAssembler` uses it and splits lab lines per investigation; `RadProcedure implements FhirServiceItemSource`. Read `ClaimLine` fillable/migration first — if a per-investigation reference column is useful, add `lab_request_investigation_id` nullable to `clinic_claim_lines` in `240001` and to fillable.
7. **Record API hookup:** if `Modules/Clinic/app/Services/PatientRecordService.php` exists with `extend()`, register a `results` section from a new provider class `Modules/Clinic/app/Support/Record/ResultsSection.php` (latest 20 `clinical_documents` of the patient of types lab_report|rad_report|external_result, with source badge LIS/RIS/external and encounter_id nullable) in `ClinicServiceProvider::boot()` — report the 1-line registration instead if the provider is hot. If C1b has not landed, ship the provider class and report the hookup line.
8. **Routes to REPORT** (none required for 1–6). Optional, only if you add it: `GET patients/{patient}/clinical-documents?types=` (`clinic.encounters.view|clinic.lab_results.view`, name `patients.clinical-documents.index`) — skip it if P5's `GET patients/{patient}/documents` already lists these types (read P5's report/brief `tasks/P5-clinical-documents.md` §5).

## Do (FE)
- **Rad report editor**: an «الصور» strip — upload (multi, jpeg/png/pdf ≤10 MB) through `ClinicAttachmentService` (`features/clinic/services/clinic-attachment.service.ts`, read it) with `attachable_type=rad_study`, `attachable_id=study.id`, `kind=image|pdf`; thumbnails with remove (DELETE endpoint) and a private toggle; disabled after release unless the user may re-release (read the editor's existing state flags). After release the existing PDF button shows the images.
- **Lab history**: the null-encounter group header renders «بدون زيارة · {{source}} · {{date}}» (ar/en keys) instead of an empty visit label in `app-lab-history-groups`; nothing else changes (Nutrition's card uses the same component — verify it still renders).
- `npx ng build --base-href /app/` green. No Fable consult needed (small additive UI) — keep the editor's existing visual language.

## Tests — `Modules/Clinic/tests/Feature/P4ResultsPatientFileTest.php` (prefix `p4_`)
Walk-in LIS request (no encounter) released → a `clinical_documents` row with `patient_id`, `encounter_id` null; `lab-history` endpoint returns it in the null group with `source=walk_in`; a request WITH an encounter is unchanged; rad order without encounter released → `rad:report:{id}` row with `data.attachment_ids` (private attachment excluded); backfill migration creates the missing rows once; claim: lab line with 2 investigations → 2 claim lines with their own codes and amounts summing exactly to the line; rad line takes `rad_procedures.sbscs_code`; package line takes `lab_packages.sbscs_code`; clinic service unchanged; a 40-char code is stored untruncated; `RadProcedure::sbscsCode()`; PDF with an image attachment returns 200 and the HTML/view data contains the image (test the provider + the rendered view string, not the binary). Run also `ClinicLabResultsSurfaceTest`, `LabResultReachesReceptionTest`, `PatientLabHistoryAndOrderSetsTest`, `RadOrderFlowTest`, `P3ExecutorRadiologyTest`, `ExternalResultTest`, `StandaloneOrderTest`, `ClaimTest`, `ClaimJourneyIntegrationTest`, `ClinicNphiesClaimTest`, `Af2IsolationTest`, `Af3AttachmentPrivacyTest`, `NPHIES/tests/Unit/FhirServiceItemBuilderTest.php` — keep green.

## Out of scope
Patient-file results tab (later, via C1's record/timeline card), LIS showing «مريض داخلي» (design §9 #13 — stays `source=clinic`), DICOM/PACS, the print engine itself (P5), documents authoring (P5), `PatientRecordService` (C1b).

## Report
Files, test output, the one-line registration for the record section (if not done), route lines if any, CHANGELOG 2 lines (EN + AR), whether step 5 waited for P5, anything not done.
