From cefa73dbaefbd479a8e5ceec1af7b53a069ba2f8 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Fri, 4 Sep 2026 19:34:44 +0530 Subject: [PATCH] fix(abdm): add comprehensive Composition XHTML narrative, resource narratives, and section displays for PHR display --- .../AbstractModuleFhirGenerator.php | 19 ++ .../Generators/DischargeFhirGenerator.php | 167 ++++++++++++++++-- tests/unit/Abdm/FhirGeneratorsTest.php | 8 + 3 files changed, 180 insertions(+), 14 deletions(-) diff --git a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php index 9f84c54..4c6124e 100644 --- a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php @@ -57,6 +57,10 @@ abstract class AbstractModuleFhirGenerator 'meta' => ['profile' => [ 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient', ]], + 'text' => [ + 'status' => 'generated', + 'div' => '

Patient: ' . htmlspecialchars((string) ($source['patient']['name'] ?? 'Patient')) . ' (' . htmlspecialchars(ucfirst($gender)) . ')

', + ], 'identifier' => [ [ 'system' => 'https://hms.local/patient-id', @@ -256,12 +260,18 @@ abstract class AbstractModuleFhirGenerator $locationList[] = $locItem; } + $patientName = trim((string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')); + $encounterResource = [ 'resourceType' => 'Encounter', 'id' => 'encounter-' . $id, 'meta' => ['profile' => [ 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter', ]], + 'text' => [ + 'status' => 'generated', + 'div' => '

Encounter: ' . htmlspecialchars($classDisplay) . ' ' . htmlspecialchars($id) . '

', + ], 'status' => 'finished', 'class' => [ 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode', @@ -270,6 +280,7 @@ abstract class AbstractModuleFhirGenerator ], 'subject' => [ 'reference' => 'urn:uuid:patient-' . (string) ($source['patient']['id'] ?? ''), + 'display' => $patientName !== '' ? $patientName : 'Patient', ], 'period' => [ 'start' => $start, @@ -316,6 +327,10 @@ abstract class AbstractModuleFhirGenerator 'meta' => ['profile' => [ 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner', ]], + 'text' => [ + 'status' => 'generated', + 'div' => '

Practitioner: ' . htmlspecialchars($name) . '

', + ], 'identifier' => [[ 'system' => 'https://doctor.ndhm.gov.in', 'value' => $hprId, @@ -347,6 +362,10 @@ abstract class AbstractModuleFhirGenerator 'meta' => ['profile' => [ 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization', ]], + 'text' => [ + 'status' => 'generated', + 'div' => '

Organization: ' . htmlspecialchars($name) . '

', + ], 'name' => $name, 'identifier' => [[ 'system' => 'https://facility.ndhm.gov.in', diff --git a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php index 5108e1b..724b680 100644 --- a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php @@ -132,6 +132,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'Condition', 'id' => $group['prefix'] . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . htmlspecialchars($group['category_display']) . ': ' . htmlspecialchars($text) . '

', + ], 'clinicalStatus' => ['coding' => [[ 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active', @@ -148,7 +152,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'display' => $group['category_display'], ]]]], 'code' => ['coding' => $coding, 'text' => $text], - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'recordedDate' => (string) ($cond['recorded_at'] ?? $timestamp), ]); @@ -180,12 +184,16 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'Procedure', 'id' => 'discharge-procedure-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Procedure']], + 'text' => [ + 'status' => 'generated', + 'div' => '

Procedure: ' . htmlspecialchars($text) . '

', + ], 'status' => 'completed', 'code' => [ 'coding' => $coding, 'text' => $text, ], - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'performedDateTime' => (string) ($proc['performed_at'] ?? $timestamp), ]); @@ -225,9 +233,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'MedicationRequest', 'id' => 'discharge-medication-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/MedicationRequest']], + 'text' => [ + 'status' => 'generated', + 'div' => '

Medication: ' . htmlspecialchars($name) . ($dosageText !== '' ? (' - ' . htmlspecialchars($dosageText)) : '') . '

', + ], 'status' => 'active', 'intent' => 'order', - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'authoredOn' => (string) ($med['authored_on'] ?? $timestamp), 'requester' => is_array($practitioner) ? [ @@ -288,6 +300,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'Observation', 'id' => 'discharge-observation-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . htmlspecialchars($text) . ': ' . htmlspecialchars($value) . '

', + ], 'status' => 'final', 'category' => [[ 'text' => $obsCategoryDisplay, @@ -301,7 +317,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'coding' => $coding, 'text' => $text, ], - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'effectiveDateTime' => (string) ($obs['effective_at'] ?? $timestamp), ]; @@ -436,12 +452,16 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'Observation', 'id' => $observationId, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']], + 'text' => [ + 'status' => 'generated', + 'div' => '

Investigation: ' . htmlspecialchars($text) . ($resultValue !== '' ? (': ' . htmlspecialchars($resultValue)) : '') . '

', + ], 'status' => 'final', 'code' => [ 'coding' => $coding, 'text' => $text, ], - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'effectiveDateTime' => (string) ($inv['reported_at'] ?? $inv['authored_on'] ?? $timestamp), ]; @@ -462,6 +482,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'DiagnosticReport', 'id' => 'discharge-investigation-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DiagnosticReportLab']], + 'text' => [ + 'status' => 'generated', + 'div' => '

Diagnostic Report: ' . htmlspecialchars($text) . '

', + ], 'status' => 'final', 'category' => [['coding' => [[ 'system' => 'http://snomed.info/sct', @@ -472,10 +496,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'coding' => $coding, 'text' => $text, ], - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'issued' => (string) ($inv['reported_at'] ?? $inv['authored_on'] ?? $timestamp), - 'result' => [['reference' => 'urn:uuid:' . $observationId]], + 'result' => [['reference' => 'urn:uuid:' . $observationId, 'display' => $text]], ]); } @@ -489,6 +513,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'AllergyIntolerance', 'id' => 'discharge-allergy-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/AllergyIntolerance']], + 'text' => [ + 'status' => 'generated', + 'div' => '

Allergy: ' . htmlspecialchars($text) . '

', + ], 'clinicalStatus' => [ 'coding' => [[ 'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical', @@ -507,7 +535,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'code' => [ 'text' => $text, ], - 'patient' => ['reference' => $patientRef], + 'patient' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'recordedDate' => (string) ($alg['recorded_at'] ?? $timestamp), 'note' => isset($alg['note']) && trim((string) $alg['note']) !== '' ? [['text' => trim((string) $alg['note'])]] @@ -526,11 +554,15 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'CarePlan', 'id' => 'discharge-careplan-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/CarePlan']], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . htmlspecialchars($title !== '' ? $title : 'Discharge Advice') . ': ' . htmlspecialchars($description) . '

', + ], 'status' => 'active', 'intent' => 'plan', 'title' => $title !== '' ? $title : 'Discharge Advice', 'description' => $description, - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'created' => (string) ($cp['created_at'] ?? $timestamp), ]); @@ -565,6 +597,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'resourceType' => 'DocumentReference', 'id' => 'discharge-document-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']], + 'text' => [ + 'status' => 'generated', + 'div' => '

Attached Document: ' . htmlspecialchars($docTitle) . '

', + ], 'status' => 'current', 'docStatus' => 'final', 'type' => ['coding' => [[ @@ -572,13 +608,18 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'code' => $snomedCode, 'display' => $docTitle, ]], 'text' => $docTitle], - 'subject' => ['reference' => $patientRef], + 'subject' => [ + 'reference' => $patientRef, + 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient'), + ], 'date' => (string) ($document['created_at'] ?? $timestamp), 'author' => is_array($practitioner) ? [[ 'reference' => 'urn:uuid:' . (string) $practitioner['id'], + 'display' => (string) ($practitioner['name'][0]['text'] ?? ''), ]] : null, 'custodian' => is_array($organization) ? [ 'reference' => 'urn:uuid:' . (string) $organization['id'], + 'display' => (string) ($organization['name'] ?? ''), ] : null, 'description' => $docTitle, 'content' => [[ @@ -646,7 +687,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $sectionDefinitions[] = ['title' => 'Investigations', 'code' => '721981007', 'display' => 'Diagnostic studies report', 'prefix' => 'discharge-investigation-', 'items' => (array) ($source['investigations'] ?? [])]; $sectionDefinitions[] = ['title' => 'Allergies', 'code' => '722446000', 'display' => 'Allergy record', 'prefix' => 'discharge-allergy-', 'items' => (array) ($source['allergies'] ?? [])]; $sectionDefinitions[] = ['title' => 'Care plan', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-careplan-', 'items' => (array) ($source['care_plans'] ?? [])]; - $sectionDefinitions[] = ['title' => 'Document reference', 'code' => '373942005', 'display' => 'Discharge summary', 'prefix' => 'discharge-document-', 'items' => (array) ($source['documents'] ?? [])]; + $sectionDefinitions[] = ['title' => 'Document reference', 'code' => '373942005', 'display' => 'Discharge summary', 'prefix' => 'discharge-document-', 'items' => $documentsList]; $sections = []; foreach ($sectionDefinitions as $definition) { @@ -654,7 +695,14 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $narrativeParts = []; $items = isset($definition['items_indexed']) ? $definition['items_indexed'] : (array) ($definition['items'] ?? []); foreach ($items as $idx => $item) { - $references[] = ['reference' => 'urn:uuid:' . $definition['prefix'] . $recordId . '-' . $idx]; + $itemDisplay = $this->cleanPlainText((string) ($item['name'] ?? $item['text'] ?? $item['title'] ?? $definition['title'])); + if ($itemDisplay === '') { + $itemDisplay = $definition['title']; + } + $references[] = [ + 'reference' => 'urn:uuid:' . $definition['prefix'] . $recordId . '-' . $idx, + 'display' => $itemDisplay, + ]; $narrative = $this->sectionItemNarrative((array) $item); if ($narrative !== '') { $narrativeParts[] = $narrative; @@ -683,9 +731,40 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ] : null, ]; } - if ($sections !== []) { - $builder->updateComposition(['section' => $sections]); + + $patientNameClean = trim((string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')); + if ($patientNameClean === '' || strcasecmp($patientNameClean, 'NA') === 0) { + $patientNameClean = 'Patient'; } + $patientGenderClean = trim((string) ($source['patient']['gender'] ?? '')); + $patientDobClean = trim((string) ($source['patient']['dob'] ?? '')); + $drNameClean = is_array($practitioner) ? (string) ($practitioner['name'][0]['text'] ?? 'Dr. Physician') : 'Dr. Physician'; + $hospitalNameClean = is_array($organization) ? (string) ($organization['name'] ?? 'Hospital') : 'Hospital'; + + $fullNarrativeHtml = $this->buildFullCompositionNarrativeHtml( + $source, + 'IPD Discharge Summary', + $patientNameClean, + $patientId, + $patientGenderClean, + $patientDobClean, + $drNameClean, + $hospitalNameClean, + $sections + ); + + $compositionFinalUpdate = [ + 'language' => 'en-IN', + 'confidentiality' => 'N', + 'text' => [ + 'status' => 'generated', + 'div' => $fullNarrativeHtml, + ], + ]; + if ($sections !== []) { + $compositionFinalUpdate['section'] = $sections; + } + $builder->updateComposition($compositionFinalUpdate); $bundle = $builder->toBundle(); $validation = $this->validator->validate($bundle, 'discharge', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]); @@ -1267,4 +1346,64 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'created_at' => (string) ($source['completed_at'] ?? date(DATE_ATOM)), ]; } + + /** + * Build top-level human-readable XHTML narrative for Composition.text.div + * + * @param array $source + * @param array> $sections + */ + private function buildFullCompositionNarrativeHtml( + array $source, + string $title, + string $patientName, + string $patientId, + string $gender, + string $dob, + string $drName, + string $hospitalName, + array $sections + ): string { + $ipdNo = (string) ($source['encounter']['ipd_no'] ?? $source['record_id'] ?? ''); + $admitDate = (string) ($source['encounter']['start'] ?? $source['admission_date'] ?? ''); + $dischargeDate = (string) ($source['encounter']['end'] ?? $source['discharge_date'] ?? $source['completed_at'] ?? ''); + $locDisplay = (string) ($source['encounter']['location_display'] ?? ''); + $abhaId = (string) ($source['patient']['abha_id'] ?? ''); + + $html = '
'; + $html .= '
'; + $html .= '

' . htmlspecialchars($hospitalName) . '

'; + $html .= '
' . htmlspecialchars($title) . '
'; + $html .= '
'; + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + if ($locDisplay !== '' || $drName !== '') { + $html .= ''; + } + $html .= '
Patient: ' . htmlspecialchars($patientName) . 'IPD No: ' . htmlspecialchars($ipdNo) . '
Gender / DOB: ' . htmlspecialchars(ucfirst($gender)) . ($dob !== '' ? (' / ' . htmlspecialchars($dob)) : '') . 'ABHA Number: ' . htmlspecialchars($abhaId !== '' ? $abhaId : 'N/A') . '
Admission Date: ' . htmlspecialchars($admitDate) . 'Discharge Date: ' . htmlspecialchars($dischargeDate) . '
Location / Bed: ' . htmlspecialchars($locDisplay !== '' ? $locDisplay : 'General Ward') . 'Doctor: ' . htmlspecialchars($drName) . '
'; + + foreach ($sections as $sec) { + $secTitle = (string) ($sec['title'] ?? ''); + $secDiv = (string) ($sec['text']['div'] ?? ''); + if ($secTitle === '' || $secDiv === '') { + continue; + } + if (preg_match('/]*>(.*)<\/div>/is', $secDiv, $m)) { + $inner = trim($m[1]); + } else { + $inner = $secDiv; + } + $html .= '
'; + $html .= '
' . htmlspecialchars($secTitle) . '
'; + $html .= $inner; + $html .= '
'; + } + + $html .= '
'; + + return $html; + } } diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index 448d330..0ced269 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -348,6 +348,14 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame('2026-09-01T04:53:00+05:30', $encounterRes['location'][0]['period']['start'] ?? null); $this->assertSame('2026-09-04T04:58:00+05:30', $encounterRes['location'][0]['period']['end'] ?? null); + // Verify Composition narrative text, language, and confidentiality + $this->assertSame('en-IN', $composition['language'] ?? null); + $this->assertSame('N', $composition['confidentiality'] ?? null); + $this->assertSame('generated', $composition['text']['status'] ?? null); + $this->assertStringContainsString('xmlns="http://www.w3.org/1999/xhtml"', $composition['text']['div'] ?? ''); + $this->assertStringContainsString('JANVI BISHT', $composition['text']['div'] ?? ''); + $this->assertStringContainsString('DevSoft Tech', $composition['text']['div'] ?? ''); + // Verify all references resolve $fullUrls = array_column($bundle['entry'], 'fullUrl'); $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES);