fix(abdm): generate physical exam observations, extract comorbidities, and trigger FHIR sync only on final discharge

This commit is contained in:
DevSoftTech Server 2026-10-17 13:13:46 +05:30
parent 0344561808
commit fdd188df6e
5 changed files with 169 additions and 4 deletions

View File

@ -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) {

View File

@ -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.');

View File

@ -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) {

View File

@ -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' => '<div xmlns="http://www.w3.org/1999/xhtml"><p><b>' . htmlspecialchars($obsText) . ' (' . htmlspecialchars($obsCategoryText) . '):</b> ' . htmlspecialchars($obsValue) . '</p></div>',
],
'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'] ?? []);

View File

@ -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);
}