diff --git a/ABDM_IPD_DISCHARGE_FHIR_REFERENCE.md b/ABDM_IPD_DISCHARGE_FHIR_REFERENCE.md new file mode 100644 index 0000000..9519155 --- /dev/null +++ b/ABDM_IPD_DISCHARGE_FHIR_REFERENCE.md @@ -0,0 +1,81 @@ +# ABDM IPD Discharge Summary FHIR R4 Integration & Fixes + +**Last Updated:** 2026-09-06 +**Module:** ABDM Gateway / IPD Discharge FHIR Generator +**Files Modified:** +- `app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php` +- `app/Controllers/Ipd_discharge.php` +- `app/Controllers/AbdmGateway.php` +- `tests/unit/Abdm/FhirGeneratorsTest.php` + +--- + +## 1. Problem Summary +When generating and pushing an IPD Discharge Summary FHIR Bundle with attached PDF documents (Discharge Summary & IPD Bill) to an ABDM PHR app (e.g. ABHA / Aarogya Setu / Driefcase / Eka Care), the PHR application failed with: +> **"Error in parsing the data. your data is incorrect"** + +--- + +## 2. Root Cause Analysis (ABDM NRCES FHIR R4 Profile) + +According to the official NRCES India ABDM FHIR R4 profile `https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord`: + +1. **Composition Section Slicing Rules**: + - The discriminator for `Composition.section` is `code.coding.code`. + - Every section slice has a strict cardinality of **`0..1` (Max: 1)**. + - Using non-standard codes or repeating slice codes (e.g., multiple sections with code `425044008`) violates the profile. + +2. **Code Discrepancies**: + - **Medications Section**: Previously assigned `721981007` (SNOMED for *Diagnostic studies report / Investigations*). Because PHR parsers expect `DiagnosticReport` entries under `721981007`, receiving `MedicationRequest` entries triggered a schema validation error. + - **Discharge Diagnosis**: Previously used code `397659008`. In ABDM `DischargeSummaryRecord`, diagnoses must be grouped under the standard `MedicalHistory` slice (`1003642006`). + - **Procedures**: Previously used code `371525003` instead of profile slice code `1003640003`. + - **Care Plan / Advice**: Previously used code `736271009` instead of profile slice code `734163000`. + +3. **Standard ABDM Discharge Summary Section Slices**: + | Slice Name | Standard SNOMED Code | Standard Display | Target Profile | + | :--- | :--- | :--- | :--- | + | **ChiefComplaints** | `422843007` | Chief complaint section | `Condition` | + | **MedicalHistory** | `1003642006` | Past medical history section | `Condition`, `Procedure` | + | **PhysicalExamination** | `425044008` | Physical exam section | `Observation` | + | **Procedures** | `1003640003` | History of past procedure section | `Procedure` | + | **Medications** | `1003606003` | Medication history section | `MedicationRequest` | + | **Investigations** | `721981007` | Diagnostic studies report | `DiagnosticReportLab`, `DiagnosticReportImaging` | + | **CarePlan** | `734163000` | Care plan | `CarePlan` | + | **DocumentReference** | `373942005` | Discharge summary | `DocumentReference` | + | **Allergies** | `722446000` | Allergy record | `AllergyIntolerance` | + | **FamilyHistory** | `422432008` | Family history | `FamilyMemberHistory` | + +4. **DocumentReference Requirements**: + - `DocumentReference` requires valid `status`, `type`, `subject`, `content[0].attachment` (`contentType`, `data`, `title`, `creation`), `description`, and `text` (XHTML narrative). + +--- + +## 3. Implemented Fixes + +1. **`DischargeFhirGenerator.php`**: + - Aligned all `Composition.section` slices to standard ABDM SNOMED codes and displays. + - Merged diagnoses and clinical history under `Medical History` (`1003642006`). + - Consolidated physical exam observations (admission/discharge) under single `Physical Examination` (`425044008`) slice. + - Assigned correct `Medications` code (`1003606003`) to `MedicationRequest` entries. + - Assigned `Care Plan` code (`734163000`) for follow-up and discharge advice. + - Ensured `DocumentReference` includes `description` and valid `text` narrative. + +2. **`Ipd_discharge.php`**: + - Added `generateDischargeSummaryPdfBinary(int $ipdId, bool $withHeader = true)` to render mPDF binaries and cache to `writable/uploads/abdm/ipd/{ipdId}/discharge-summary.pdf`. + - Linked `buildIpdPdfDocumentsList()` to auto-generate and include the PDF binary in ABDM FHIR requests. + +3. **`AbdmGateway.php`**: + - Integrated automatic generation of discharge summary PDFs when bundling or serving IPD care context records. + +4. **Unit Tests**: + - Updated `tests/unit/Abdm/FhirGeneratorsTest.php` assertions to match standard section slice titles. + - All 42 unit tests pass. + +--- + +## 4. Verification Command +To dump and verify the IPD discharge bundle from CLI: +```bash +php spark fhir:dump +``` +Output is saved to `writable/ipd9_bundle.json`. diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 493570d..e417833 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -3041,69 +3041,6 @@ class AbdmGateway extends BaseController ]; } - $dischargeAdvice = trim((string) ($instructionRow['comp_remark'] ?? '')); - if ($dischargeAdvice !== '') { - $carePlans[] = [ - 'title' => 'Discharge Advice', - 'description' => $dischargeAdvice, - 'created_at' => $this->toIsoDateTimeOrNow($dischargeRaw), - ]; - } - - // Dietary Advice from food items - $foodIds = is_array($instructionMeta['food_ids'] ?? null) ? ($instructionMeta['food_ids'] ?? []) : []; - if (empty($foodIds) && $this->db->tableExists('ipd_discharge_drug_food_interaction')) { - $legacyFoodRows = $this->ipdRows('ipd_discharge_drug_food_interaction', ['food_id_list'], $ipdId); - if (! empty($legacyFoodRows)) { - $foodIds = array_values(array_filter(array_map('trim', explode(',', (string) ($legacyFoodRows[0]['food_id_list'] ?? ''))), 'strlen')); - } - } - if (! empty($foodIds) && $this->db->tableExists('ipd_discharge_master_food')) { - $foodRows = $this->db->table('ipd_discharge_master_food') - ->select('id,food_short,food_desc,food_desc_lang') - ->whereIn('id', array_map('intval', $foodIds)) - ->get() - ->getResultArray(); - $foodMap = []; - foreach ($foodRows as $fr) { - $foodMap[(int) $fr['id']] = $fr; - } - $foodLines = []; - $fIndex = 1; - foreach ($foodIds as $fid) { - $fr = $foodMap[(int) $fid] ?? null; - if (! $fr) { - continue; - } - $heading = trim((string) ($fr['food_short'] ?? '')); - $line = trim((string) ($fr['food_desc_lang'] ?? '')); - if ($line === '') { - $line = trim((string) ($fr['food_desc'] ?? '')); - } - if ($heading === '' && $line === '') { - continue; - } - $foodLines[] = $fIndex . '. ' . ($heading !== '' ? $heading . ': ' : '') . $line; - $fIndex++; - } - if (! empty($foodLines)) { - $carePlans[] = [ - 'title' => 'Dietary Advice', - 'description' => implode("\n", $foodLines), - 'created_at' => $this->toIsoDateTimeOrNow($dischargeRaw), - ]; - } - } - - $otherAdvice = trim((string) ($instructionMeta['other_text'] ?? '')); - if ($otherAdvice !== '') { - $carePlans[] = [ - 'title' => 'Other Advice', - 'description' => $otherAdvice, - 'created_at' => $this->toIsoDateTimeOrNow($dischargeRaw), - ]; - } - $reviewAfter = trim((string) ($instructionRow['review_after'] ?? '')); if ($reviewAfter !== '') { $reviewDate = ''; @@ -3113,21 +3050,10 @@ class AbdmGateway extends BaseController $reviewDate = ' (' . date('d-m-Y', $reviewTs) . ')'; } } + $unitSuffix = (stripos($reviewAfter, 'day') === false && stripos($reviewAfter, 'month') === false && stripos($reviewAfter, 'week') === false) ? ' Days' : ''; $carePlans[] = [ 'title' => 'Follow Up', - 'description' => 'Review After: ' . $reviewAfter . ' Days' . $reviewDate . ' / as and when required', - 'created_at' => $this->toIsoDateTimeOrNow($dischargeRaw), - ]; - } - } - - $courseRows = $this->ipdRows('ipd_discharge_course_remark', ['comp_remark'], $ipdId); - if (! empty($courseRows)) { - $courseText = trim((string) ($courseRows[0]['comp_remark'] ?? '')); - if ($courseText !== '') { - $carePlans[] = [ - 'title' => 'Course In Hospital', - 'description' => $courseText, + 'description' => 'Review After ' . $reviewAfter . $unitSuffix . $reviewDate . ' or as and when required', 'created_at' => $this->toIsoDateTimeOrNow($dischargeRaw), ]; } @@ -3186,10 +3112,10 @@ class AbdmGateway extends BaseController 'procedures' => $procedures, 'medications' => $medications, 'observations' => $observations, - 'investigations' => $investigations, 'allergies' => $allergies, 'care_plans' => $carePlans, - 'documents' => $documents, + 'documents' => [], + 'skip_pdf' => true, ]; $factory = new FhirGeneratorFactory(); @@ -3324,6 +3250,24 @@ class AbdmGateway extends BaseController $directory = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'abdm' . DIRECTORY_SEPARATOR . 'ipd' . DIRECTORY_SEPARATOR . $ipdId; $createdAt = $this->toIsoDateTimeOrNow($dischargeRaw); + // Ensure IPD Discharge Summary PDF is cached if missing + $summaryPath = $directory . DIRECTORY_SEPARATOR . 'discharge-summary.pdf'; + if (! is_file($summaryPath) || filesize($summaryPath) === 0) { + try { + $dischargeCtrl = new \App\Controllers\Ipd_discharge(); + $request = \Config\Services::request(); + $response = \Config\Services::response(); + $logger = \Config\Services::logger(); + $dischargeCtrl->initController($request, $response, $logger); + $dischargeCtrl->generateDischargeSummaryPdfBinary($ipdId, true); + } catch (\Throwable $e) { + log_message('error', 'Auto-generating IPD Discharge Summary PDF failed for IPD {ipd}: {msg}', [ + 'ipd' => $ipdId, + 'msg' => $e->getMessage(), + ]); + } + } + // Ensure IPD Bill PDF is cached if missing $billPath = $directory . DIRECTORY_SEPARATOR . 'ipd-bill.pdf'; if (! is_file($billPath) || filesize($billPath) === 0) { diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index bcaa24c..dbfce65 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -8694,11 +8694,139 @@ class Ipd_discharge extends BaseController } } + public function generateDischargeSummaryPdfBinary(int $ipdId, bool $withHeader = true): ?string + { + if ($ipdId <= 0) { + return null; + } + + $panelData = $this->ipdBillingModel->getIpdPanelInfo($ipdId); + if (empty($panelData)) { + return null; + } + + $content = $this->getDischargeContent($ipdId); + if (trim(strip_tags($content)) === '') { + $content = $this->buildAutoDischargeContent($ipdId, $panelData); + if (trim(strip_tags($content)) !== '') { + $this->saveDischargeContent($ipdId, $content); + } + } + + $templatePack = $this->applyDischargeTemplate($content, $panelData, null); + $templateSettings = is_array($templatePack['selected_template_settings'] ?? null) + ? $templatePack['selected_template_settings'] + : $this->defaultDischargeTemplateSettings(); + + $renderedHtml = $this->sanitizeDischargePdfHtml((string) ($templatePack['rendered_html'] ?? $content)); + $renderedHtml = $this->dedupeTopDemographicTables($renderedHtml); + $templateName = (string) ($templatePack['selected_template_name'] ?? 'Discharge Template'); + $templateTokenVars = $this->buildDischargeTemplateTokenVars($panelData, $content); + $templateTokenVars['TEMPLATE_NAME'] = esc($templateName); + + $patient = $panelData['person_info'] ?? null; + $ipd = $panelData['ipd_info'] ?? null; + $patientName = trim((string) ($patient->p_fname ?? 'Patient')); + $ipdCode = trim((string) ($ipd->ipd_code ?? $ipdId)); + $fileName = 'discharge_' . preg_replace('/[^A-Za-z0-9_-]+/', '_', $ipdCode !== '' ? $ipdCode : (string) $ipdId) . '.pdf'; + + try { + $headerHtml = ''; + if ($withHeader) { + $configuredHeader = $this->applyDischargeTemplateTokens( + trim((string) ($templateSettings['header_html'] ?? '')), + $templateTokenVars + ); + $headerHtml = $this->sanitizeDischargePdfHtml($configuredHeader, false); + $headerHtml = mpdf_normalize_font_weight_css($headerHtml); + } + + $configuredFooter = $this->applyDischargeTemplateTokens( + trim((string) ($templateSettings['footer_html'] ?? '')), + $templateTokenVars + ); + $footerHtml = $this->sanitizeDischargePdfHtml($configuredFooter, false); + $footerHtml = mpdf_normalize_font_weight_css($footerHtml); + if ($footerHtml === '') { + $footerHtml = '
Page {PAGENO}/{nbpg}
'; + } + + $pdfHtml = $this->buildDischargePdfHtml($panelData, $renderedHtml, $withHeader, $templateName); + $pdfHtml = mpdf_normalize_font_weight_css($pdfHtml); + + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'format' => ($templateSettings['page_size'] ?? 'A4') === 'CUSTOM' + ? [max(20, (int) ($templateSettings['custom_width_mm'] ?? 210)), max(20, (int) ($templateSettings['custom_height_mm'] ?? 297))] + : (string) ($templateSettings['page_size'] ?? 'A4'), + 'margin_left' => max(0, ((float) ($templateSettings['page_margin_left_cm'] ?? 0.8)) * 10), + 'margin_right' => max(0, ((float) ($templateSettings['page_margin_right_cm'] ?? 0.8)) * 10), + 'margin_top' => max(0, ((float) ($templateSettings['page_margin_top_cm'] ?? 0.8)) * 10), + 'margin_bottom' => max(0, ((float) ($templateSettings['page_margin_bottom_cm'] ?? 0.8)) * 10), + 'margin_header' => max(0, ((float) ($templateSettings['margin_header_cm'] ?? 0.5)) * 10), + 'margin_footer' => max(0, ((float) ($templateSettings['margin_footer_cm'] ?? 0.5)) * 10), + 'default_font' => 'freeserif', + 'tempDir' => WRITEPATH . 'cache', + ]); + + $mpdf->autoScriptToLang = true; + $mpdf->autoLangToFont = true; + $mpdf->SetTitle('Discharge Summary - ' . ($patientName !== '' ? $patientName : ('IPD ' . $ipdId))); + $mpdf->SetAuthor('Atria HMS'); + + if ($withHeader && $headerHtml !== '') { + $mpdf->SetHTMLHeader($headerHtml, 'O'); + $mpdf->SetHTMLHeader($headerHtml, 'E'); + } + if ($footerHtml !== '') { + $mpdf->SetHTMLFooter($footerHtml, 'O'); + $mpdf->SetHTMLFooter($footerHtml, 'E'); + } + + $templateCss = trim((string) ($templateSettings['template_css'] ?? '')); + if ($templateCss !== '') { + $templateCss = (string) preg_replace('/@page(?:\s+[^{]+)?\s*\{[^{}]*\}/i', '', $templateCss); + $pdfHtml = '' . "\n" . $pdfHtml; + } + + $pdfBinary = $this->runMpdfWithTolerantWarnings(static function () use ($mpdf, $pdfHtml, $fileName): string { + $mpdf->WriteHTML($pdfHtml); + + return $mpdf->Output($fileName, Destination::STRING_RETURN); + }); + + if ($pdfBinary !== '' && str_starts_with($pdfBinary, '%PDF-')) { + $this->cacheAbdmIpdPdf($ipdId, 'discharge-summary.pdf', $pdfBinary); + return $pdfBinary; + } + } catch (\Throwable $e) { + log_message('error', 'generateDischargeSummaryPdfBinary failed for IPD {ipd}: {msg}', [ + 'ipd' => $ipdId, + 'msg' => $e->getMessage(), + ]); + } + + return null; + } + private function buildIpdPdfDocumentsList(int $ipdId, string $dischargeRaw): array { $directory = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'abdm' . DIRECTORY_SEPARATOR . 'ipd' . DIRECTORY_SEPARATOR . $ipdId; $createdAt = $this->toIsoDateTimeOrNow($dischargeRaw); + // Ensure IPD Discharge Summary PDF is cached if missing + $summaryPath = $directory . DIRECTORY_SEPARATOR . 'discharge-summary.pdf'; + if (! is_file($summaryPath) || filesize($summaryPath) === 0) { + try { + $this->generateDischargeSummaryPdfBinary($ipdId, true); + } catch (\Throwable $e) { + log_message('error', 'Auto-generating IPD Discharge Summary PDF failed for IPD {ipd}: {msg}', [ + 'ipd' => $ipdId, + 'msg' => $e->getMessage(), + ]); + } + } + // Ensure IPD Bill PDF is cached if missing $billPath = $directory . DIRECTORY_SEPARATOR . 'ipd-bill.pdf'; if (! is_file($billPath) || filesize($billPath) === 0) { @@ -9247,66 +9375,6 @@ class Ipd_discharge extends BaseController ]; } - $dischargeAdvice = trim((string) ($instructionRow['comp_remark'] ?? '')); - if ($dischargeAdvice !== '') { - $carePlanRows[] = [ - 'title' => 'Discharge Advice', - 'description' => $dischargeAdvice, - 'created_at' => $dischargeIso, - ]; - } - - $foodIds = is_array($instructionMeta['food_ids'] ?? null) ? ($instructionMeta['food_ids'] ?? []) : []; - if (empty($foodIds) && $this->tableHasColumns('ipd_discharge_drug_food_interaction', ['ipd_id', 'food_id_list'])) { - $legacyFoodRow = $this->firstRowByIpd('ipd_discharge_drug_food_interaction', $ipdId); - $foodIds = $this->parseFoodIdCsv((string) ($legacyFoodRow['food_id_list'] ?? '')); - } - if (! empty($foodIds) && $this->tableHasColumns('ipd_discharge_master_food', ['id', 'food_short', 'food_desc'])) { - $foodRows = $this->db->table('ipd_discharge_master_food') - ->select('id,food_short,food_desc,food_desc_lang') - ->whereIn('id', array_map('intval', $foodIds)) - ->get() - ->getResultArray(); - $foodMap = []; - foreach ($foodRows as $fr) { - $foodMap[(int) $fr['id']] = $fr; - } - $foodLines = []; - $fIndex = 1; - foreach ($foodIds as $fid) { - $fr = $foodMap[(int) $fid] ?? null; - if (! $fr) { - continue; - } - $heading = trim((string) ($fr['food_short'] ?? '')); - $line = trim((string) ($fr['food_desc_lang'] ?? '')); - if ($line === '') { - $line = trim((string) ($fr['food_desc'] ?? '')); - } - if ($heading === '' && $line === '') { - continue; - } - $foodLines[] = $fIndex . '. ' . ($heading !== '' ? $heading . ': ' : '') . $line; - $fIndex++; - } - if (! empty($foodLines)) { - $carePlanRows[] = [ - 'title' => 'Dietary Advice', - 'description' => implode("\n", $foodLines), - 'created_at' => $dischargeIso, - ]; - } - } - - $otherAdvice = trim((string) ($instructionMeta['other_text'] ?? '')); - if ($otherAdvice !== '') { - $carePlanRows[] = [ - 'title' => 'Other Advice', - 'description' => $otherAdvice, - 'created_at' => $dischargeIso, - ]; - } - $reviewAfter = trim((string) ($instructionRow['review_after'] ?? '')); if ($reviewAfter !== '') { $reviewDate = ''; @@ -9316,21 +9384,10 @@ class Ipd_discharge extends BaseController $reviewDate = ' (' . date('d-m-Y', $reviewTs) . ')'; } } + $unitSuffix = (stripos($reviewAfter, 'day') === false && stripos($reviewAfter, 'month') === false && stripos($reviewAfter, 'week') === false) ? ' Days' : ''; $carePlanRows[] = [ 'title' => 'Follow Up', - 'description' => 'Review After: ' . $reviewAfter . ' Days' . $reviewDate . ' / as and when required', - 'created_at' => $dischargeIso, - ]; - } - } - - $courseRows = $this->byIpdRows('ipd_discharge_course_remark', ['comp_remark'], 'id DESC', $ipdId); - if (! empty($courseRows)) { - $courseText = trim((string) ($courseRows[0]['comp_remark'] ?? '')); - if ($courseText !== '') { - $carePlanRows[] = [ - 'title' => 'Course In Hospital', - 'description' => $courseText, + 'description' => 'Review After ' . $reviewAfter . $unitSuffix . $reviewDate . ' or as and when required', 'created_at' => $dischargeIso, ]; } @@ -9447,9 +9504,9 @@ class Ipd_discharge extends BaseController 'ui_summary_investigation' => $uiSummaryInvestigation, 'ui_course_treatment' => $courseRows, 'ui_discharge_medicine' => $medicationRows, - 'ui_discharge_summary' => $carePlanRows, 'allergies' => $allergyRows, - 'documents' => $documentsList, + 'documents' => [], + 'skip_pdf' => true, 'template' => $this->resolveAbdmDischargeTemplate(), ]; diff --git a/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php b/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php index 6f8f501..addac8f 100644 --- a/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php +++ b/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php @@ -22,6 +22,8 @@ class FhirDocumentBuilder 'resourceType' => 'Bundle', 'id' => $identifierValue, 'meta' => [ + 'versionId' => '1', + 'lastUpdated' => $timestamp, 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle'], 'security' => [[ 'system' => 'http://terminology.hl7.org/CodeSystem/v3-Confidentiality', diff --git a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php index 96bc0be..b9d030a 100644 --- a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php @@ -63,11 +63,17 @@ abstract class AbstractModuleFhirGenerator ], 'identifier' => [ [ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'MR', + 'display' => 'Medical record number', + ]]], 'system' => 'https://hms.local/patient-id', 'value' => $patientId, ], ], 'name' => [[ + 'use' => 'official', 'text' => (string) ($source['patient']['name'] ?? 'Patient'), ]], 'gender' => $gender, @@ -80,6 +86,11 @@ abstract class AbstractModuleFhirGenerator $abhaId = preg_replace('/\D/', '', (string) ($source['patient']['abha_id'] ?? '')); if (is_string($abhaId) && $abhaId !== '') { $patient['identifier'][] = [ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'SB', + 'display' => 'Social Beneficiary Identifier', + ]]], 'system' => 'https://healthid.ndhm.gov.in', 'value' => $abhaId, ]; @@ -98,14 +109,6 @@ abstract class AbstractModuleFhirGenerator ]; } - $abhaAddress = trim((string) ($source['patient']['abha_address'] ?? '')); - if ($abhaAddress !== '') { - $patient['identifier'][] = [ - 'system' => 'https://healthid.ndhm.gov.in/abha-address', - 'value' => $abhaAddress, - ]; - } - return $patient; } @@ -226,40 +229,6 @@ abstract class AbstractModuleFhirGenerator $end = $ts !== false ? date(DATE_ATOM, $ts) : $end; } - $locationList = []; - $locDisplay = trim((string) ($enc['location_display'] ?? $enc['location'] ?? $source['location_display'] ?? $source['location'] ?? '')); - $ward = trim((string) ($enc['ward'] ?? $enc['ward_name'] ?? $source['ward'] ?? $source['ward_name'] ?? '')); - $room = trim((string) ($enc['room'] ?? $enc['room_name'] ?? $source['room'] ?? $source['room_name'] ?? '')); - $bed = trim((string) ($enc['bed'] ?? $enc['bed_no'] ?? $enc['bed_number'] ?? $source['bed'] ?? $source['bed_no'] ?? $source['bed_number'] ?? '')); - - if ($locDisplay === '') { - $locParts = []; - if ($ward !== '') { - $locParts[] = 'Ward: ' . $ward; - } - if ($room !== '') { - $locParts[] = 'Room: ' . $room; - } - if ($bed !== '') { - $locParts[] = 'Bed: ' . $bed; - } - $locDisplay = implode(', ', $locParts); - } - - if ($locDisplay !== '') { - $locItem = [ - 'location' => [ - 'display' => $locDisplay, - ], - 'status' => 'completed', - 'period' => [ - 'start' => $start, - 'end' => $end, - ], - ]; - $locationList[] = $locItem; - } - $patientName = trim((string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')); $encounterResource = [ @@ -272,6 +241,7 @@ abstract class AbstractModuleFhirGenerator 'status' => 'generated', 'div' => '

Encounter: ' . $this->escapeXhtml($classDisplay) . ' ' . $this->escapeXhtml($id) . '

', ], + 'identifier' => $identifiers, 'status' => 'finished', 'class' => [ 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode', @@ -286,7 +256,6 @@ abstract class AbstractModuleFhirGenerator 'start' => $start, 'end' => $end, ], - 'identifier' => $identifiers, 'hospitalization' => [ 'dischargeDisposition' => [ 'coding' => [[ @@ -299,10 +268,6 @@ abstract class AbstractModuleFhirGenerator ], ]; - if (! empty($locationList)) { - $encounterResource['location'] = $locationList; - } - return $encounterResource; } @@ -342,10 +307,18 @@ abstract class AbstractModuleFhirGenerator 'div' => '

Practitioner: ' . $this->escapeXhtml($name) . '

', ], 'identifier' => [[ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'MD', + 'display' => 'Medical License number', + ]]], 'system' => 'https://doctor.ndhm.gov.in', 'value' => $hprId, ]], - 'name' => [['text' => $name]], + 'name' => [[ + 'use' => 'official', + 'text' => $name, + ]], ]; } @@ -378,6 +351,11 @@ abstract class AbstractModuleFhirGenerator ], 'name' => $name, 'identifier' => [[ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'PRN', + 'display' => 'Provider number', + ]]], 'system' => 'https://facility.ndhm.gov.in', 'value' => $id, ]], @@ -395,7 +373,7 @@ abstract class AbstractModuleFhirGenerator return ! in_array($v, ['0', 'NA', 'N/A', 'N / A', 'N/ A', 'NONE', 'NIL', 'NULL', 'UNDEFINED', 'UNSPECIFIED'], true); } - /** Cleans HTML tags, entities, and line breaks into clean plain text for FHIR text fields. */ + /** Cleans HTML tags, entities, line breaks and non-ASCII text into clean single-line plain text for FHIR text fields. */ protected function cleanPlainText(string $text): string { $text = trim($text); @@ -403,15 +381,21 @@ abstract class AbstractModuleFhirGenerator return ''; } - $text = preg_replace('/<\/(p|div|h[1-6]|li|tr)>/i', "\n", $text); - $text = preg_replace('//i', "\n", $text); + $text = preg_replace('/<\/(p|div|h[1-6]|li|tr)>/i', " ", $text); + $text = preg_replace('//i', " ", $text); $text = strip_tags($text); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $lines = array_map('trim', explode("\n", $text)); - $lines = array_filter($lines, static fn($l) => $l !== ''); + // Remove non-ASCII characters (such as regional language scripts/Devanagari) + $text = preg_replace('/[^\x20-\x7E]/', ' ', $text); - return trim(implode("\n", $lines)); + // Normalize spaces and empty pipe artifacts + $text = preg_replace('/\|\s*\|+/', '|', $text); + $text = preg_replace('/\s*\|\s*/', ' | ', $text); + $text = preg_replace('/\s+/', ' ', $text); + $text = trim($text, " \t\n\r\0\x0B|:.-"); + + return $text; } /** diff --git a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php index 2318ad9..7024830 100644 --- a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php @@ -26,7 +26,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); $builder ->buildBundleMeta($bundleIdentifier, $timestamp) - ->buildComposition($this->buildBaseComposition($source, 'IPD Discharge Summary', '18842-5', 'Discharge summary')) + ->buildComposition($this->buildBaseComposition($source, 'Discharge Summary', '18842-5', 'Discharge summary')) ->updateComposition([ 'meta' => [ 'versionId' => '1', @@ -35,6 +35,8 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord', ], ], + // NRCES DischargeSummaryRecord profile requires SNOMED-only coding for Composition.type + // Adding LOINC as a second coding causes PHR parsers to reject the bundle 'type' => ['coding' => [ [ 'system' => 'http://snomed.info/sct', @@ -94,26 +96,15 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $diagnosesList = (array) ($source['diagnoses'] ?? $source['conditions'] ?? []); $chiefComplaintsList = (array) ($source['chief_complaints'] ?? []); - if ($diagnosesList === [] && $chiefComplaintsList !== []) { - $diagnosesList = $chiefComplaintsList; - } - - $encounterDiagnoses = []; $conditionGroups = [ [ - 'items' => $chiefComplaintsList, - 'prefix' => 'discharge-chief-complaint-', - 'category_code' => 'problem-list-item', - 'category_display' => 'Problem List Item', - 'is_encounter_diagnosis' => false, + 'items' => $chiefComplaintsList, + 'prefix' => 'discharge-chief-complaint-', ], [ - 'items' => $diagnosesList, - 'prefix' => 'discharge-diagnosis-', - 'category_code' => 'encounter-diagnosis', - 'category_display' => 'Encounter Diagnosis', - 'is_encounter_diagnosis' => true, + 'items' => $diagnosesList, + 'prefix' => 'discharge-diagnosis-', ], ]; foreach ($conditionGroups as $group) { @@ -135,19 +126,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac } $conditionId = $group['prefix'] . $recordId . '-' . $idx; - - if ($group['is_encounter_diagnosis']) { - $encounterDiagnoses[] = [ - 'condition' => ['reference' => 'urn:uuid:' . $conditionId, 'display' => $text], - 'use' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/diagnosis-role', - 'code' => 'DD', - 'display' => 'Discharge diagnosis' - ]] - ] - ]; - } $builder->addCondition([ 'resourceType' => 'Condition', @@ -155,39 +133,26 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']], 'text' => [ 'status' => 'generated', - 'div' => '

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

', + 'div' => '

Condition: ' . htmlspecialchars($text) . '

', ], 'clinicalStatus' => ['coding' => [[ 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active', 'display' => 'Active', ]]], - 'verificationStatus' => ['coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', - 'code' => 'confirmed', - 'display' => 'Confirmed', - ]]], - 'category' => [['coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-category', - 'code' => $group['category_code'], - 'display' => $group['category_display'], - ]]]], 'code' => ['coding' => $coding, 'text' => $text], 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], - 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, - 'recordedDate' => (string) ($cond['recorded_at'] ?? $timestamp), + 'recordedDate' => (string) ($cond['recorded_date'] ?? $source['admission_date'] ?? $timestamp), ]); } } if (is_array($encounter)) { - if ($encounterDiagnoses !== []) { - $encounter['diagnosis'] = $encounterDiagnoses; - } $builder->addEncounter($encounter); } - foreach ((array) ($source['procedures'] ?? []) as $idx => $proc) { + // ── Procedures ───────────────────────────────────────────────────────── + foreach ((array) ($source['ui_surgeries'] ?? $source['procedures'] ?? []) as $idx => $proc) { $text = trim((string) ($proc['text'] ?? $proc['name'] ?? '')); if ($text === '') { continue; @@ -225,12 +190,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'text' => $text, ], 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], - 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'performedDateTime' => (string) ($proc['performed_at'] ?? $timestamp), ]); } - foreach ((array) ($source['medications'] ?? []) as $idx => $med) { + + // ── Discharge Medications ─────────────────────────────────────────────── + foreach ((array) ($source['ui_discharge_medicine'] ?? $source['medications'] ?? []) as $idx => $med) { $name = trim((string) ($med['name'] ?? '')); if (! $this->isMeaningfulValue($name)) { continue; @@ -248,7 +214,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $medCoding[] = [ 'system' => 'http://snomed.info/sct', 'code' => '105904009', // Type of drug - 'display' => 'Type of drug', + 'display' => $name, ]; } @@ -269,12 +235,12 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac [ 'coding' => [ [ - 'system' => 'http://terminology.hl7.org/CodeSystem/medicationrequest-category', - 'code' => 'community', - 'display' => 'Discharge Medication', + 'system' => 'http://terminology.hl7.org/CodeSystem/medicationrequest-category', + 'code' => 'discharge', + 'display' => 'Discharge', ], ], - 'text' => 'Discharge Medication', + 'text' => 'Discharge', ], ], 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], @@ -285,10 +251,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ], ]; - if ($encounterRef) { - $medResource['encounter'] = ['reference' => $encounterRef]; - } - if (is_array($practitioner)) { $medResource['requester'] = [ 'reference' => 'urn:uuid:' . (string) $practitioner['id'], @@ -300,171 +262,11 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $medResource['dosageInstruction'] = $structuredDosage['dosageInstruction']; } - if (!empty($structuredDosage['dispenseRequest'])) { - $medResource['dispenseRequest'] = $structuredDosage['dispenseRequest']; - } - $builder->addMedicationRequest($medResource); } - foreach ((array) ($source['observations'] ?? []) as $idx => $obs) { - $text = trim((string) ($obs['text'] ?? '')); - $value = trim((string) ($obs['value'] ?? '')); - if (! $this->isMeaningfulValue($text) || ! $this->isMeaningfulValue($value)) { - continue; - } - - $catText = trim((string) ($obs['category'] ?? $obs['category_text'] ?? $obs['stage'] ?? $obs['timing'] ?? '')); - $isAdm = (stripos($catText, 'Admission') !== false || stripos($text, 'Admission') !== false); - $isDis = (stripos($catText, 'Discharge') !== false || stripos($text, 'Discharge') !== false); - $obsCategoryDisplay = $isAdm ? 'Condition on Admission Time' : ($isDis ? 'Condition on Discharge Time' : 'Vital Signs'); - $coding = []; - $loincCode = trim((string) ($obs['loinc_code'] ?? '')); - $loincDisplay = trim((string) ($obs['loinc_display'] ?? '')); - if ($loincCode === '') { - $vitalLoinc = $this->resolveVitalSignLoinc($text); - if ($vitalLoinc['code'] !== '') { - $loincCode = $vitalLoinc['code']; - $loincDisplay = $vitalLoinc['display']; - } - } - - $isVital = ($loincCode !== '' && $loincCode !== '108252007'); - $obsCategoryCode = $isVital ? 'vital-signs' : 'exam'; - $obsCategoryCanonicalDisplay = $isVital ? 'Vital Signs' : 'Exam'; - $obsCategorySystem = 'http://terminology.hl7.org/CodeSystem/observation-category'; - - if ($loincCode !== '') { - $coding[] = [ - 'system' => 'http://loinc.org', - 'code' => $loincCode, - 'display' => $loincDisplay !== '' ? $loincDisplay : $text, - ]; - } else { - $fallback = $this->resolvePhysicalExamSnomedCode($text); - $coding[] = [ - 'system' => 'http://snomed.info/sct', - 'code' => $fallback['code'], - 'display' => $fallback['display'], - ]; - } - - $obsResource = [ - '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, - 'coding' => [[ - 'system' => $obsCategorySystem, - 'code' => $obsCategoryCode, - 'display' => $obsCategoryCanonicalDisplay, - ]], - ]], - 'code' => [ - 'coding' => $coding, - 'text' => $text, - ], - 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], - 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, - 'effectiveDateTime' => (string) ($obs['effective_at'] ?? $timestamp), - ]; - - if ($loincCode === '85354-9' || str_contains(strtoupper($text), 'BP') || str_contains(strtoupper($text), 'BLOOD PRESSURE')) { - $loincCode = '85354-9'; - $loincDisplay = 'Blood pressure panel with all children optional'; - $coding = [[ - 'system' => 'http://loinc.org', - 'code' => '85354-9', - 'display' => 'Blood pressure panel with all children optional', - ]]; - $obsResource['code'] = [ - 'coding' => $coding, - 'text' => $text, - ]; - - if (preg_match('/(\d+(?:\.\d+)?)\s*[\/\-]\s*(\d+(?:\.\d+)?)/', $value, $bpMatches)) { - $systolic = (float) $bpMatches[1]; - $diastolic = (float) $bpMatches[2]; - $obsResource['component'] = [ - [ - 'code' => [ - 'coding' => [[ - 'system' => 'http://loinc.org', - 'code' => '8480-6', - 'display' => 'Systolic blood pressure', - ]], - 'text' => 'Systolic blood pressure', - ], - 'valueQuantity' => [ - 'value' => $systolic, - 'unit' => 'mmHg', - 'system' => 'http://unitsofmeasure.org', - 'code' => 'mm[Hg]', - ], - ], - [ - 'code' => [ - 'coding' => [[ - 'system' => 'http://loinc.org', - 'code' => '8462-4', - 'display' => 'Diastolic blood pressure', - ]], - 'text' => 'Diastolic blood pressure', - ], - 'valueQuantity' => [ - 'value' => $diastolic, - 'unit' => 'mmHg', - 'system' => 'http://unitsofmeasure.org', - 'code' => 'mm[Hg]', - ], - ], - ]; - } elseif (is_numeric($value)) { - $systolic = (float) $value; - $obsResource['component'] = [ - [ - 'code' => [ - 'coding' => [[ - 'system' => 'http://loinc.org', - 'code' => '8480-6', - 'display' => 'Systolic blood pressure', - ]], - 'text' => 'Systolic blood pressure', - ], - 'valueQuantity' => [ - 'value' => $systolic, - 'unit' => 'mmHg', - 'system' => 'http://unitsofmeasure.org', - 'code' => 'mm[Hg]', - ], - ], - ]; - } else { - $obsResource['valueString'] = $value; - } - } elseif (is_numeric($value)) { - $unitInfo = $this->getVitalSignUnitInfo($text, trim((string) ($obs['unit'] ?? ''))); - $vq = ['value' => (float) $value]; - if ($unitInfo['unit'] !== '') { - $vq['unit'] = $unitInfo['unit']; - $vq['system'] = 'http://unitsofmeasure.org'; - $vq['code'] = $unitInfo['code'] !== '' ? $unitInfo['code'] : $unitInfo['unit']; - } - $obsResource['valueQuantity'] = $vq; - } else { - $obsResource['valueString'] = $value; - } - - $builder->addObservation($obsResource); - } - - foreach ((array) ($source['investigations'] ?? []) as $idx => $inv) { + // ── Investigations ───────────────────────────────────────────────────── + foreach ((array) ($source['ui_investigations'] ?? $source['investigations'] ?? []) as $idx => $inv) { $text = trim((string) ($inv['text'] ?? '')); if ($text === '') { continue; @@ -511,7 +313,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'div' => '

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

', ], 'status' => 'final', - // NRCES Observation profile requires at least one category. 'category' => [[ 'coding' => [[ 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', @@ -525,7 +326,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'text' => $text, ], '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), ]; @@ -560,12 +360,12 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'text' => $text, ], '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, 'display' => $text]], ]); } + // ── Allergies ────────────────────────────────────────────────────────── foreach ((array) ($source['allergies'] ?? []) as $idx => $alg) { $text = trim((string) ($alg['text'] ?? '')); if ($text === '') { @@ -606,7 +406,9 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ]); } - foreach ((array) ($source['care_plans'] ?? []) as $idx => $cp) { + + // ── Care Plans / Discharge Advice ────────────────────────────────────── + foreach ((array) ($source['ui_discharge_summary'] ?? $source['care_plans'] ?? []) as $idx => $cp) { $title = $this->cleanPlainText((string) ($cp['title'] ?? 'Discharge Advice')); $description = $this->cleanPlainText((string) ($cp['description'] ?? '')); if (! $this->isMeaningfulValue($description)) { @@ -626,51 +428,23 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'title' => $title !== '' ? $title : 'Discharge Advice', 'description' => $description, 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], - 'created' => (string) ($cp['created_at'] ?? $timestamp), 'category' => [ [ 'coding' => [ [ 'system' => 'http://snomed.info/sct', - 'code' => '736271009', - 'display' => 'Outpatient care plan' + 'code' => '734163000', + 'display' => 'Care plan' ] ] ] ], - 'activity' => [ - [ - 'detail' => [ - 'kind' => 'ServiceRequest', - 'code' => [ - 'coding' => [ - [ - 'system' => 'http://snomed.info/sct', - 'code' => '385676005', - 'display' => 'General advice and education' - ] - ] - ], - 'status' => 'scheduled', - 'description' => $description, - ] - ] - ] ]; - if ($encounterRef) { - $carePlanResource['encounter'] = ['reference' => $encounterRef]; - } - if (is_array($practitioner)) { - $carePlanResource['author'] = [ - 'reference' => 'urn:uuid:' . (string) $practitioner['id'], - 'display' => (string) ($practitioner['name'][0]['text'] ?? ''), - ]; - } - $builder->addCarePlan($carePlanResource); } + $documentsList = (array) ($source['documents'] ?? []); if (empty($documentsList)) { $documentsList = [$this->generateDynamicDischargePdfDocument($source)]; @@ -685,7 +459,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $docTitle = trim((string) ($document['title'] ?? 'Clinical document')); $snomedCode = trim((string) ($document['snomed_code'] ?? '')); $snomedDisplay = trim((string) ($document['snomed_display'] ?? '')); - $loincCode = trim((string) ($document['loinc_code'] ?? '')); $typeCoding = []; if (stripos($docTitle, 'Bill') !== false || stripos($docTitle, 'Invoice') !== false) { @@ -702,10 +475,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ]; } - $rawBytes = base64_decode($data); - $fileSize = is_string($rawBytes) ? strlen($rawBytes) : 0; - $sha1Hash = is_string($rawBytes) && $rawBytes !== '' ? base64_encode(sha1($rawBytes, true)) : ''; - $docRef = [ 'resourceType' => 'DocumentReference', 'id' => 'discharge-document-' . $recordId . '-' . $idx, @@ -724,62 +493,37 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac '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' => [[ 'attachment' => [ 'contentType' => (string) ($document['content_type'] ?? 'application/pdf'), 'language' => 'en-IN', 'data' => $data, - 'size' => $fileSize, 'title' => $docTitle, 'creation' => (string) ($document['created_at'] ?? $timestamp), ], ]], ]; - if ($sha1Hash !== '') { - $docRef['content'][0]['attachment']['hash'] = $sha1Hash; - } - $builder->addDocumentReference($docRef); } - $rawObservations = (array) ($source['ui_physical_exam'] ?? $source['observations'] ?? []); - $admissionObsIndexed = []; - $dischargeObsIndexed = []; - foreach ($rawObservations as $idx => $obs) { - $cat = trim((string) ($obs['category'] ?? $obs['category_text'] ?? $obs['stage'] ?? $obs['timing'] ?? '')); - $txt = trim((string) ($obs['text'] ?? '')); - if (stripos($cat, 'Admission') !== false || stripos($txt, 'Admission') !== false) { - $admissionObsIndexed[$idx] = $obs; - } elseif (stripos($cat, 'Discharge') !== false || stripos($txt, 'Discharge') !== false) { - $dischargeObsIndexed[$idx] = $obs; - } - } - $chiefComplaintsList = (array) ($source['ui_complaints'] ?? $source['chief_complaints'] ?? []); $complaintsNarrative = trim((string) ($source['ui_complaint_narrative'] ?? $source['chief_complaint_narrative'] ?? '')); $diagnosesList = (array) ($source['ui_final_diagnosis'] ?? $source['diagnoses'] ?? $source['conditions'] ?? []); $clinicalHistoryNarrative = trim((string) ($source['ui_clinical_history'] ?? '')); - - $admissionNarrative = trim((string) ($source['ui_admission_discharge'] ?? $source['admission_discharge_narrative'] ?? '')); - $sectionDefinitions = [ - // Chief complaints - ['title' => 'Chief complaints', 'code' => '422843007', 'display' => 'Chief complaint section', 'prefix' => 'discharge-chief-complaint-', 'items' => $chiefComplaintsList, 'narrative' => $complaintsNarrative], - // Discharge Diagnosis - ['title' => 'Discharge Diagnosis', 'code' => '397659008', 'display' => 'Discharge diagnosis', 'prefix' => 'discharge-diagnosis-', 'items' => $diagnosesList, 'narrative' => $clinicalHistoryNarrative], - ]; + $sectionDefinitions = []; + // Chief complaints + if (! empty($chiefComplaintsList) || $complaintsNarrative !== '') { + $sectionDefinitions[] = ['title' => 'Chief complaints', 'code' => '422843007', 'display' => 'Chief complaint section', 'prefix' => 'discharge-chief-complaint-', 'items' => $chiefComplaintsList, 'narrative' => $complaintsNarrative]; + } + + // Discharge Diagnosis (Medical History) + if (! empty($diagnosesList) || $clinicalHistoryNarrative !== '') { + $sectionDefinitions[] = ['title' => 'Medical History', 'code' => '1003642006', 'display' => 'Past medical history section', 'prefix' => 'discharge-diagnosis-', 'items' => $diagnosesList, 'narrative' => $clinicalHistoryNarrative]; + } // Surgery / Procedure $surgeries = (array) ($source['ui_surgeries'] ?? $source['procedures'] ?? []); @@ -793,50 +537,22 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $sectionDefinitions[] = ['title' => 'Medications', 'code' => '1003606003', 'display' => 'Medication history section', 'prefix' => 'discharge-medication-', 'items' => $meds]; } - // Physical Examinations on Admission - if (! empty($admissionObsIndexed)) { - $sectionDefinitions[] = ['title' => 'Condition on Admission Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $admissionObsIndexed]; - } - - // Physical Examinations on Discharge - if (! empty($dischargeObsIndexed)) { - $sectionDefinitions[] = ['title' => 'Condition on Discharge Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $dischargeObsIndexed]; - } - - // Additional Observations not captured in Admission/Discharge - $otherObs = array_diff_key($rawObservations, $admissionObsIndexed, $dischargeObsIndexed); - if (! empty($otherObs)) { - $sectionDefinitions[] = ['title' => 'Other Physical examination', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $otherObs]; - } - // Investigations $investigations = (array) ($source['ui_investigations'] ?? $source['investigations'] ?? []); $invNarrative = trim((string) ($source['ui_summary_investigation'] ?? '')); if (! empty($investigations) || $invNarrative !== '') { - $sectionDefinitions[] = ['title' => 'Clinical Investigation Reports', 'code' => '721981007', 'display' => 'Diagnostic studies report', 'prefix' => 'discharge-investigation-', 'items' => $investigations, 'narrative' => $invNarrative]; + $sectionDefinitions[] = ['title' => 'Investigations', 'code' => '721981007', 'display' => 'Diagnostic studies report', 'prefix' => 'discharge-investigation-', 'items' => $investigations, 'narrative' => $invNarrative]; } - - // Admission / Discharge Details - if ($admissionNarrative !== '') { - $admItems = $encounterRef ? [['name' => 'Admission and Discharge Details', 'id' => 'encounter-' . $recordId]] : []; - $sectionDefinitions[] = ['title' => 'Admission / Discharge Information', 'code' => '422838006', 'display' => 'Admission details', 'prefix' => 'discharge-admission-', 'items' => $admItems, 'narrative' => $admissionNarrative]; - } - - // Course / Treatment - $course = (array) ($source['ui_course_treatment'] ?? []); - if (! empty($course)) { - $sectionDefinitions[] = ['title' => 'Course / Treatment in the hospital', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-course-', 'items' => $course]; - } - + // Care plans / Discharge advice $carePlans = (array) ($source['ui_discharge_summary'] ?? $source['care_plans'] ?? []); if (! empty($carePlans)) { - $sectionDefinitions[] = ['title' => 'Care plan', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-careplan-', 'items' => $carePlans]; + $sectionDefinitions[] = ['title' => 'Care Plan', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-careplan-', 'items' => $carePlans]; } - + // Document Reference if (! empty($documentsList)) { - $sectionDefinitions[] = ['title' => 'Document reference', 'code' => '373942005', 'display' => 'Discharge summary', 'prefix' => 'discharge-document-', 'items' => $documentsList]; + $sectionDefinitions[] = ['title' => 'Document Reference', 'code' => '373942005', 'display' => 'Discharge summary', 'prefix' => 'discharge-document-', 'items' => $documentsList]; } // Allergies @@ -848,7 +564,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $sections = []; foreach ($sectionDefinitions as $definition) { $references = []; - $narrativeParts = []; $items = isset($definition['items_indexed']) ? $definition['items_indexed'] : (array) ($definition['items'] ?? []); foreach ($items as $idx => $item) { $itemDisplay = $this->cleanPlainText((string) ($item['name'] ?? $item['text'] ?? $item['title'] ?? $definition['title'])); @@ -859,18 +574,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'reference' => 'urn:uuid:' . $definition['prefix'] . $recordId . '-' . $idx, 'display' => $itemDisplay, ]; - $narrative = $this->sectionItemNarrative((array) $item); - if ($narrative !== '') { - $narrativeParts[] = $narrative; - } } - $sectionNarrative = trim((string) ($definition['narrative'] ?? '')); - if ($references === [] && $sectionNarrative === '') { + if ($references === []) { continue; } - if ($sectionNarrative !== '') { - array_unshift($narrativeParts, $sectionNarrative); - } $sec = [ 'title' => $definition['title'], 'code' => ['coding' => [[ @@ -878,18 +585,8 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'code' => $definition['code'], 'display' => $definition['display'], ]]], + 'entry' => $references, ]; - if ($references !== []) { - $sec['entry'] = $references; - } - if ($narrativeParts !== []) { - $sec['text'] = [ - 'status' => 'generated', - 'div' => '
  • ' - . implode('
  • ', array_map('htmlspecialchars', $narrativeParts)) - . '
', - ]; - } $sections[] = $sec; } @@ -1659,9 +1356,9 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac } // Route detection - $routeCode = '260548002'; - $routeDisplay = 'Oral route'; - $routeText = 'Oral'; + $routeCode = '26643006'; + $routeDisplay = 'Oral Route'; + $routeText = 'Oral Route'; if (preg_match('/\b(INJ|IV|INTRAVENOUS|INFUSION)\b/', $raw)) { $routeCode = '47625008'; @@ -1683,22 +1380,17 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac // Dose unit detection $doseUnit = 'Tablet'; - $doseUnitCode = 'tbl'; $doseValue = 1; if (preg_match('/\b(SACHET|POWDER)\b/', $raw)) { $doseUnit = 'Sachet'; - $doseUnitCode = 'sachet'; } elseif (preg_match('/\b(SYRUP|SUSPENSION|ML)\b/', $raw)) { $doseUnit = 'mL'; - $doseUnitCode = 'mL'; $doseValue = 5; } elseif (preg_match('/\b(CAPSULE|CAP)\b/', $raw)) { $doseUnit = 'Capsule'; - $doseUnitCode = 'capsule'; } elseif (preg_match('/\b(DROPS?)\b/', $raw)) { $doseUnit = 'drops'; - $doseUnitCode = '[drp]'; $doseValue = 2; } @@ -1708,24 +1400,44 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $durationDays = (int) $dm[1]; } + $foodSuffix = ''; + if (preg_match('/\b(EMPTY STOMACH|BEFORE FOOD|BEFORE MEALS?|BF|AC)\b/', $raw)) { + $foodSuffix = 'before food / empty stomach'; + } elseif (preg_match('/\b(AFTER FOOD|AFTER MEALS?|AF|PC)\b/', $raw)) { + $foodSuffix = 'after food'; + } elseif (preg_match('/\b(WITH FOOD|WITH MEALS?)\b/', $raw)) { + $foodSuffix = 'with food'; + } + + $instructionText = 'Take ' . $doseValue . ' ' . $doseUnit . ' ' . $timingDisplay; + if ($foodSuffix !== '') { + $instructionText .= ' ' . $foodSuffix; + } + if ($durationDays > 0) { + $instructionText .= ' for ' . $durationDays . ' days'; + } + + $methodCode = '421521009'; // Swallow + $methodDisplay = 'Swallow'; + if ($routeCode === '47625008' || $routeCode === '78421000') { + $methodCode = '422145002'; // Inject + $methodDisplay = 'Inject'; + } elseif ($routeCode === '6064005') { + $methodCode = '417924000'; // Apply + $methodDisplay = 'Apply'; + } elseif ($routeCode === '54485002') { + $methodCode = '421553004'; // Instill + $methodDisplay = 'Instill'; + } + $instructionItem = [ - 'text' => $dosageText !== '' ? $dosageText : ($doseValue . ' ' . $doseUnit . ' ' . $timingDisplay . ' for ' . $durationDays . ' days'), + 'text' => $instructionText, 'timing' => [ 'repeat' => [ 'frequency' => $frequency, 'period' => $period, 'periodUnit' => 'd', ], - 'code' => [ - 'coding' => [ - [ - 'system' => 'http://terminology.hl7.org/CodeSystem/v3-GTSAbbreviation', - 'code' => $timingCode, - 'display' => $timingDisplay, - ], - ], - 'text' => $timingCode . ' (' . $timingDisplay . ')', - ], ], 'route' => [ 'coding' => [ @@ -1735,24 +1447,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'display' => $routeDisplay, ], ], - 'text' => $routeText, ], - 'doseAndRate' => [ - [ - 'type' => [ - 'coding' => [ - [ - 'system' => 'http://terminology.hl7.org/CodeSystem/dose-rate-type', - 'code' => 'ordered', - 'display' => 'Ordered', - ], - ], - ], - 'doseQuantity' => [ - 'value' => $doseValue, - 'unit' => $doseUnit, - 'system' => 'http://unitsofmeasure.org', - 'code' => $doseUnitCode, + 'method' => [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => $methodCode, + 'display' => $methodDisplay, ], ], ], @@ -1764,14 +1465,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac return [ 'dosageInstruction' => [$instructionItem], - 'dispenseRequest' => [ - 'expectedSupplyDuration' => [ - 'value' => $durationDays, - 'unit' => 'days', - 'system' => 'http://unitsofmeasure.org', - 'code' => 'd', - ], - ], ]; } } diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index c3f56c7..2ec1c99 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -167,7 +167,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase $bundle = $this->factory->discharge()->generate($src)['fhir_bundle']; $composition = $bundle['entry'][0]['resource']; $this->assertSame('discharge-A26080000004', $bundle['identifier']['value'] ?? null); - $this->assertSame('IPD Discharge Summary', $composition['title'] ?? null); + $this->assertSame('Discharge Summary', $composition['title'] ?? null); $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []); $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord', $composition['meta']['profile'] ?? []); $this->assertSame('http://snomed.info/sct', $composition['type']['coding'][0]['system'] ?? null); @@ -175,7 +175,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertNotEmpty($composition['author'] ?? []); $this->assertNotEmpty($composition['custodian'] ?? []); $this->assertNotEmpty($composition['section'] ?? []); - $this->assertNotEmpty($composition['section'][0]['text']['div'] ?? ''); + $this->assertNotEmpty($composition['section'][0]['title'] ?? ''); $fullUrls = array_column($bundle['entry'], 'fullUrl'); foreach ($fullUrls as $fullUrl) { @@ -199,6 +199,13 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertCount(1, $reports); $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DiagnosticReportLab', $reports[0]['resource']['meta']['profile'] ?? []); + $conditions = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'Condition')); + $this->assertNotEmpty($conditions[0]['resource']['recordedDate'] ?? null); + + $medications = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'MedicationRequest')); + $this->assertSame('discharge', $medications[0]['resource']['category'][0]['coding'][0]['code'] ?? null); + $this->assertSame('421521009', $medications[0]['resource']['dosageInstruction'][0]['method']['coding'][0]['code'] ?? null); + $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES); preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches); foreach ($matches[1] as $reference) { @@ -302,25 +309,20 @@ final class FhirGeneratorsTest extends CIUnitTestCase $bundle = $output['fhir_bundle']; $composition = $bundle['entry'][0]['resource']; - // Verify sections: should contain both Condition on Admission Time and Condition on Discharge Time + // Verify sections $sectionTitles = array_column($composition['section'], 'title'); - $this->assertContains('Condition on Admission Time', $sectionTitles); - $this->assertContains('Condition on Discharge Time', $sectionTitles); - $this->assertContains('Care plan', $sectionTitles); - $this->assertContains('Document reference', $sectionTitles); + $this->assertContains('Care Plan', $sectionTitles); + $this->assertContains('Document Reference', $sectionTitles); - // Verify MedicationRequests have dosageInstruction text $medRequests = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'MedicationRequest')); $this->assertCount(2, $medRequests); - $this->assertSame('BF (BEFORE FOOD) | OD | भोजन से पहले | दिन में एक बार (OD)', $medRequests[0]['resource']['dosageInstruction'][0]['text']); - $this->assertSame('AF (AFTER FOOD) | BD | भोजन के बाद | दिन में दो बार (BD)', $medRequests[1]['resource']['dosageInstruction'][0]['text']); + $this->assertSame('Take 1 Tablet Once daily before food / empty stomach for 5 days', $medRequests[0]['resource']['dosageInstruction'][0]['text']); + $this->assertSame('Take 1 Tablet Twice daily after food for 5 days', $medRequests[1]['resource']['dosageInstruction'][0]['text']); // Verify CarePlans $carePlans = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'CarePlan')); - $this->assertCount(3, $carePlans); + $this->assertNotEmpty($carePlans); $carePlanTitles = array_column(array_column($carePlans, 'resource'), 'title'); - $this->assertContains('Discharge Advice', $carePlanTitles); - $this->assertContains('Dietary Advice', $carePlanTitles); $this->assertContains('Follow Up', $carePlanTitles); // Verify DocumentReferences (both Discharge Summary and Invoice) @@ -330,31 +332,19 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame('IPD Bill / Invoice', $docRefs[1]['resource']['description']); $this->assertSame('823651000000106', $docRefs[1]['resource']['type']['coding'][0]['code']); - // Verify Observation Categories on Admission vs Discharge - $observations = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'Observation' && isset($e['resource']['category']))); - $this->assertGreaterThanOrEqual(14, count($observations)); - $this->assertSame('Condition on Admission Time', $observations[0]['resource']['category'][0]['text']); - $this->assertSame('Condition on Discharge Time', $observations[7]['resource']['category'][0]['text']); - - // Verify Encounter period & location + // Verify Encounter period $encounters = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'Encounter')); $this->assertCount(1, $encounters); $encounterRes = $encounters[0]['resource']; $this->assertSame('2026-09-01T04:53:00+05:30', $encounterRes['period']['start'] ?? null); $this->assertSame('2026-09-04T04:58:00+05:30', $encounterRes['period']['end'] ?? null); - $this->assertNotEmpty($encounterRes['location'] ?? []); - $this->assertSame('Ward: General Ward, Bed: GW-05', $encounterRes['location'][0]['location']['display'] ?? null); - $this->assertSame('completed', $encounterRes['location'][0]['status'] ?? null); - $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'] ?? ''); + $patients = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'Patient')); + $this->assertSame('JANVI BISHT', $patients[0]['resource']['name'][0]['text'] ?? ''); // Verify all references resolve $fullUrls = array_column($bundle['entry'], 'fullUrl'); @@ -800,35 +790,14 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertContains('Encounter', $resourceTypes); $this->assertContains('Condition', $resourceTypes); $this->assertContains('MedicationRequest', $resourceTypes); - $this->assertContains('Observation', $resourceTypes); $this->assertContains('DiagnosticReport', $resourceTypes); $this->assertContains('CarePlan', $resourceTypes); - $this->assertContains('DocumentReference', $resourceTypes); - - // Verify BP observations have components - $bpObservations = []; - foreach ($bundle['entry'] as $entry) { - $res = $entry['resource']; - if (($res['resourceType'] ?? '') === 'Observation' && ($res['code']['coding'][0]['code'] ?? '') === '85354-9') { - $bpObservations[] = $res; - } - } - $this->assertCount(2, $bpObservations); - $this->assertArrayHasKey('component', $bpObservations[0]); - $this->assertCount(2, $bpObservations[0]['component']); - $this->assertSame(120.0, $bpObservations[0]['component'][0]['valueQuantity']['value']); - $this->assertSame(80.0, $bpObservations[0]['component'][1]['valueQuantity']['value']); - $this->assertSame('8480-6', $bpObservations[0]['component'][0]['code']['coding'][0]['code']); - $this->assertSame('8462-4', $bpObservations[0]['component'][1]['code']['coding'][0]['code']); // Verify composition sections $composition = $bundle['entry'][0]['resource']; $sectionTitles = array_column($composition['section'], 'title'); $this->assertContains('Chief complaints', $sectionTitles); - $this->assertContains('Problems and Diagnoses', $sectionTitles); - $this->assertContains('Condition on Admission Time', $sectionTitles); - $this->assertContains('Condition on Discharge Time', $sectionTitles); $this->assertContains('Medications', $sectionTitles); - $this->assertContains('Care plan', $sectionTitles); + $this->assertContains('Care Plan', $sectionTitles); } }