From fdd188df6ee3ca962964846fc11717cf1840a074 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sat, 17 Oct 2026 13:13:46 +0530 Subject: [PATCH] fix(abdm): generate physical exam observations, extract comorbidities, and trigger FHIR sync only on final discharge --- app/Controllers/AbdmGateway.php | 14 +- app/Controllers/Billing/Ipd.php | 10 ++ app/Controllers/Ipd_discharge.php | 25 +++- .../Generators/DischargeFhirGenerator.php | 122 +++++++++++++++++- tests/unit/Abdm/FhirGeneratorsTest.php | 2 + 5 files changed, 169 insertions(+), 4 deletions(-) diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index e15834e..86a585a 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -3466,8 +3466,20 @@ class AbdmGateway extends BaseController } } - // Add comorbidities from NABH + // Add comorbidities from NABH, with fallback to latest opd_prescription / history $coMorbRaw = trim((string) ($nabhMeta['co_morbidities'] ?? '')); + if ($coMorbRaw === '' && $patientId > 0 && $this->db && $this->db->tableExists('opd_prescription')) { + $latestPresc = $this->db->table('opd_prescription') + ->select('Prescriber_Remarks') + ->where('p_id', $patientId) + ->orderBy('id', 'DESC') + ->get(1)->getRowArray(); + if (! empty($latestPresc['Prescriber_Remarks'])) { + if (preg_match('/^\s*Co-Morbidities\s*:\s*(.+)$/im', (string) $latestPresc['Prescriber_Remarks'], $m)) { + $coMorbRaw = trim($m[1]); + } + } + } if ($coMorbRaw !== '') { $coMorbParts = preg_split('/[,;\n]+/', $coMorbRaw); foreach ($coMorbParts as $cPart) { diff --git a/app/Controllers/Billing/Ipd.php b/app/Controllers/Billing/Ipd.php index bb3f13d..0ebd3eb 100644 --- a/app/Controllers/Billing/Ipd.php +++ b/app/Controllers/Billing/Ipd.php @@ -1074,6 +1074,16 @@ class Ipd extends BaseController ->where('id', $caseId) ->update(['status' => 1]); } + + // Trigger complete, final FHIR Discharge Summary generation & ABDM sync + try { + $patientId = (int) ($currentIpd['p_id'] ?? 0); + $dischargeCtrl = new \App\Controllers\Ipd_discharge(); + $dischargeCtrl->initController($this->request, $this->response, service('logger')); + $dischargeCtrl->enqueueIpdDischargeSync($ipdId, $patientId, 'final_discharge'); + } catch (\Throwable $e) { + log_message('warning', '[updateDischargeStatus] ABDM discharge sync trigger failed: ' . $e->getMessage()); + } } return $this->buildDischargeProcessResponse($ipdId, 'Discharge status updated.'); diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index 09850fe..d4c17b2 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -9361,7 +9361,17 @@ class Ipd_discharge extends BaseController // Do not block discharge workflows if queue service is unavailable. } - // Also persist ABDM-compatible FHIR bundle in sync tables/outbox for M2 push. + // Only persist ABDM-compatible FHIR bundle if the patient is officially discharged + // (discarge_patient_status > 0 and valid discharge_date) or explicitly finalized. + // Intermediate draft saves while patient is still admitted must not create incomplete bundles. + $dischargeStatus = (int) ($ipdRow['discarge_patient_status'] ?? 0); + $dischargeDate = trim((string) ($ipdRow['discharge_date'] ?? '')); + $isDischarged = $dischargeStatus > 0 && $dischargeDate !== '' && $dischargeDate !== '0000-00-00'; + + if (! $isDischarged && ! in_array($action, ['final_discharge', 'force', 'auto_push'], true)) { + return; + } + // Fail-open: discharge save flow must never fail due to ABDM sync issues. try { $this->enqueueIpdDischargeFhirSync($ipdId, $patientId, $ipdRow, $patientRow); @@ -9478,8 +9488,19 @@ class Ipd_discharge extends BaseController } } - // Comorbidities from NABH metadata + // Comorbidities from NABH metadata with fallback to latest opd_prescription / history $coMorbRaw = trim((string) ($nabhMeta['co_morbidities'] ?? '')); + if ($coMorbRaw === '' && $patientId > 0 && $this->db->tableExists('opd_prescription')) { + $latestOpdRow = $this->db->table('opd_prescription') + ->where('p_id', $patientId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($latestOpdRow)) { + $hydratedNabh = $this->hydrateNabhFieldsFromOpdRow($latestOpdRow); + $coMorbRaw = trim((string) ($hydratedNabh['co_morbidities'] ?? '')); + } + } if ($coMorbRaw !== '') { $coMorbParts = preg_split('/[,;\n]+/', $coMorbRaw); foreach ($coMorbParts as $cPart) { diff --git a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php index 218e02e..59b4498 100644 --- a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php @@ -326,6 +326,114 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $builder->addMedicationRequest($medResource); } + // ── Physical Examination & Vital Signs ────────────────────────────── + $rawObservations = (array) ($source['ui_physical_exam'] ?? $source['observations'] ?? []); + $validObservations = []; + foreach ($rawObservations as $idx => $obs) { + if (! is_array($obs)) { + continue; + } + $obsText = trim((string) ($obs['text'] ?? $obs['name'] ?? '')); + $obsValue = trim((string) ($obs['value'] ?? '')); + if ($obsText === '' || ! $this->isMeaningfulValue($obsText)) { + continue; + } + if ($obsValue === '' || ! $this->isMeaningfulValue($obsValue)) { + continue; + } + $validObservations[$idx] = $obs; + } + + foreach ($validObservations as $idx => $obs) { + $obsText = trim((string) ($obs['text'] ?? $obs['name'] ?? '')); + $obsValue = trim((string) ($obs['value'] ?? '')); + $obsCategoryText = trim((string) ($obs['category'] ?? 'Physical Examination')); + $obsCategoryCode = trim((string) ($obs['category_code'] ?? '')); + $effectiveAt = (string) ($obs['effective_at'] ?? $obs['effective_date_time'] ?? $timestamp); + + $isVital = in_array(strtolower($obsCategoryCode), ['vital-signs', 'vitals'], true) + || preg_match('/^(pulse|heart rate|bp|blood pressure|respiration|respiratory rate|temperature|temp|spo2|oxygen)/i', $obsText); + + $catCode = $isVital ? 'vital-signs' : 'exam'; + $catDisplay = $isVital ? 'Vital Signs' : 'Exam'; + + $observationId = 'discharge-observation-' . $recordId . '-' . $idx; + + $coding = []; + $lowerText = strtolower($obsText); + if (str_contains($lowerText, 'pulse') || str_contains($lowerText, 'heart rate')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '8499008', 'display' => 'Pulse rate']; + } elseif (str_contains($lowerText, 'respiration') || str_contains($lowerText, 'respiratory')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '86290005', 'display' => 'Respiratory rate']; + } elseif (str_contains($lowerText, 'pallor')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '271782001', 'display' => 'Pallor']; + } elseif (str_contains($lowerText, 'jaundice') || str_contains($lowerText, 'icterus')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '18165001', 'display' => 'Jaundice']; + } elseif (str_contains($lowerText, 'cyanosis')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '3415004', 'display' => 'Cyanosis']; + } elseif (str_contains($lowerText, 'clubbing')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '30760006', 'display' => 'Clubbing']; + } elseif (str_contains($lowerText, 'edema') || str_contains($lowerText, 'oedema')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '267038008', 'display' => 'Edema']; + } elseif (str_contains($lowerText, 'jvp') || str_contains($lowerText, 'jugular')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '271649006', 'display' => 'Jugular venous pressure']; + } elseif (str_contains($lowerText, 'bp') || str_contains($lowerText, 'blood pressure')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '75367002', 'display' => 'Blood pressure']; + } elseif (str_contains($lowerText, 'temp')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '386725007', 'display' => 'Body temperature']; + } elseif (str_contains($lowerText, 'spo2') || str_contains($lowerText, 'oxygen saturation')) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '431314004', 'display' => 'Oxygen saturation']; + } else { + $resolved = $this->codingResolver->resolveSnomedForDiagnosisOrFinding((string) ($obs['code'] ?? ''), $obsText); + $coding = $this->withoutPlaceholderCoding((array) ($resolved['coding'] ?? [])); + if ($coding === []) { + $coding[] = ['system' => 'http://snomed.info/sct', 'code' => '404684003', 'display' => 'Clinical finding']; + } + } + + $obsResource = [ + 'resourceType' => 'Observation', + 'id' => $observationId, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . htmlspecialchars($obsText) . ' (' . htmlspecialchars($obsCategoryText) . '): ' . htmlspecialchars($obsValue) . '

', + ], + 'status' => 'final', + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => $catCode, + 'display' => $catDisplay, + ]], + 'text' => $obsCategoryText !== '' ? $obsCategoryText : $catDisplay, + ]], + 'code' => [ + 'coding' => $coding, + 'text' => $obsText, + ], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], + 'effectiveDateTime' => $effectiveAt, + ]; + + $unit = trim((string) ($obs['unit'] ?? '')); + if ($unit === '' && preg_match('/^([\d\.]+)\s*(\/min|bpm|mmHg|°c|°f|%)$/i', $obsValue, $uParts)) { + $obsResource['valueQuantity'] = [ + 'value' => (float) $uParts[1], + 'unit' => $uParts[2], + ]; + } elseif (is_numeric($obsValue)) { + $obsResource['valueQuantity'] = [ + 'value' => (float) $obsValue, + 'unit' => $unit, + ]; + } else { + $obsResource['valueString'] = $obsValue; + } + + $builder->addObservation($obsResource); + } + // ── Investigations ───────────────────────────────────────────────────── foreach ((array) ($source['ui_investigations'] ?? $source['investigations'] ?? []) as $idx => $inv) { $text = trim((string) ($inv['text'] ?? '')); @@ -670,6 +778,18 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ]; } + // Physical Examination & Vital Signs + if (! empty($validObservations)) { + $sectionDefinitions[] = [ + 'title' => 'Physical Examination', + 'code' => '425044008', + 'display' => 'Physical examination section', + 'prefix' => 'discharge-observation-', + 'items_indexed' => $validObservations, + 'entry_display' => 'Observation', + ]; + } + // Surgery / Procedure $surgeries = (array) ($source['ui_surgeries'] ?? $source['procedures'] ?? []); if (! empty($surgeries)) { @@ -1078,7 +1198,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac if (empty($diagnoses) && ! empty($source['conditions'])) { $diagnoses = (array) $source['conditions']; } - $vitals = (array) ($source['observations'] ?? []); + $vitals = (array) ($source['ui_physical_exam'] ?? $source['observations'] ?? []); $procedures = (array) ($source['procedures'] ?? []); $medications = ! empty($source['medications']) ? (array) $source['medications'] : (array) ($source['ui_discharge_medicine'] ?? []); $carePlans = ! empty($source['care_plans']) ? (array) $source['care_plans'] : (array) ($source['ui_care_plans'] ?? $source['ui_discharge_summary'] ?? []); diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index f8b611a..02e688e 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -988,6 +988,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertContains('Patient', $resourceTypes); $this->assertContains('Encounter', $resourceTypes); $this->assertContains('Condition', $resourceTypes); + $this->assertContains('Observation', $resourceTypes); $this->assertContains('MedicationRequest', $resourceTypes); $this->assertContains('DiagnosticReport', $resourceTypes); $this->assertContains('CarePlan', $resourceTypes); @@ -996,6 +997,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase $composition = $bundle['entry'][0]['resource']; $sectionTitles = array_column($composition['section'], 'title'); $this->assertContains('Chief complaints', $sectionTitles); + $this->assertContains('Physical Examination', $sectionTitles); $this->assertContains('Medications', $sectionTitles); $this->assertContains('Care Plan', $sectionTitles); }