From 6f3b2a62d99d397f8da95e762b6ce36c863ef42a Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Mon, 19 Oct 2026 10:16:05 +0530 Subject: [PATCH] fix(abdm): populate salt and generic name in IPD discharge medication FHIR resources - Populate generic salt name in MedicationRequest medicationCodeableConcept.coding[0] to prevent PHR apps displaying 'NA' subtitle - Retain brand name in medicationCodeableConcept.text and secondary coding - Extract med_salt from prescription records with automatic resolution from medicine masters and formulation heuristics - Update discharge PDF and narrative to render generic salt names - Add unit tests verifying generic salt mapping and dual coding in FHIR bundle --- app/Controllers/AbdmGateway.php | 177 +++++++++- app/Controllers/Ipd_discharge.php | 177 +++++++++- .../Generators/DischargeFhirGenerator.php | 302 +++++++++++++++++- tests/unit/Abdm/FhirGeneratorsTest.php | 79 +++++ 4 files changed, 711 insertions(+), 24 deletions(-) diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 86a585a..6ff8b09 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -3562,15 +3562,20 @@ class AbdmGateway extends BaseController } $medications = []; - $legacyMeds = $this->ipdRows('ipd_discharge_prescrption_prescribed', ['med_name', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], $ipdId); + $legacyMeds = $this->ipdRows('ipd_discharge_prescrption_prescribed', ['med_id', 'med_name', 'med_salt', 'med_type', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], $ipdId); if (empty($legacyMeds)) { - $legacyMeds = $this->ipdRows('ipd_discharge_prescription_prescribed', ['med_name', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], $ipdId); + $legacyMeds = $this->ipdRows('ipd_discharge_prescription_prescribed', ['med_id', 'med_name', 'med_salt', 'med_type', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], $ipdId); } foreach ($legacyMeds as $row) { $name = trim((string) ($row['med_name'] ?? '')); if ($name === '') { continue; } + $salt = trim((string) ($row['med_salt'] ?? '')); + $medId = (int) ($row['med_id'] ?? 0); + if ($salt === '' || strcasecmp($salt, 'NA') === 0 || strcasecmp($salt, 'N/A') === 0) { + $salt = $this->resolveDischargeMedSaltName($name, $medId); + } $dosage = $this->resolveDischargeMedDosageLabel( $row['dosage'] ?? '', $row['dosage_when'] ?? '', @@ -3578,19 +3583,33 @@ class AbdmGateway extends BaseController (string) ($row['no_of_days'] ?? ''), (string) ($row['remark'] ?? '') ); - $medications[] = ['name' => $name, 'dosage' => $dosage]; + $medications[] = [ + 'name' => $name, + 'salt' => $salt, + 'generic' => $salt, + 'dosage' => $dosage, + 'med_id' => $medId, + 'type' => trim((string) ($row['med_type'] ?? '')), + ]; } if (empty($medications)) { - foreach ($this->ipdRows('ipd_discharge_drug', ['drug_name', 'drug_dose', 'drug_day'], $ipdId) as $row) { + foreach ($this->ipdRows('ipd_discharge_drug', ['drug_code', 'drug_name', 'drug_dose', 'drug_day', 'formulation'], $ipdId) as $row) { $name = trim((string) ($row['drug_name'] ?? '')); if ($name === '') { continue; } + $salt = $this->resolveDischargeMedSaltName($name); $dosage = trim(implode(' ', array_filter([ (string) ($row['drug_dose'] ?? ''), (string) ($row['drug_day'] ?? ''), ], static fn ($v) => trim((string) $v) !== ''))); - $medications[] = ['name' => $name, 'dosage' => $dosage]; + $medications[] = [ + 'name' => $name, + 'salt' => $salt, + 'generic' => $salt, + 'dosage' => $dosage, + 'type' => trim((string) ($row['formulation'] ?? '')), + ]; } } @@ -3881,6 +3900,154 @@ class AbdmGateway extends BaseController ]; } + /** + * Auto-resolves generic/salt name of medicine from master tables or clinical patterns. + */ + private function resolveDischargeMedSaltName(string $rawName, int $medId = 0): string + { + $rawName = trim($rawName); + if ($rawName === '' || in_array(strtoupper($rawName), ['0', 'NA', 'N/A'], true)) { + return ''; + } + + try { + if ($this->db) { + // 1. If med_id provided, look up masters directly + if ($medId > 0) { + if ($this->db->tableExists('opd_med_master')) { + $mRow = $this->db->table('opd_med_master') + ->select('genericname, salt_name') + ->where('id', $medId) + ->get(1) + ->getRowArray(); + if (!empty($mRow)) { + $gen = trim((string) (!empty($mRow['genericname']) ? $mRow['genericname'] : ($mRow['salt_name'] ?? ''))); + if ($gen !== '' && ! in_array(strtoupper($gen), ['0', 'NA', 'N/A'], true)) { + return $gen; + } + } + } + if ($this->db->tableExists('med_product_master')) { + $pRow = $this->db->table('med_product_master') + ->select('genericname') + ->where('id', $medId) + ->get(1) + ->getRowArray(); + if (!empty($pRow)) { + $gen = trim((string) ($pRow['genericname'] ?? '')); + if ($gen !== '' && ! in_array(strtoupper($gen), ['0', 'NA', 'N/A'], true)) { + return $gen; + } + } + } + } + + // 2. Clean dosage prefix and look up masters by item_name + $clean = trim((string) preg_replace('/^(TAB|CAP|SYP|INJ|CREAM|OINT|GEL|DROPS|SPRAY)\s+/i', '', $rawName)); + if ($clean !== '') { + if ($this->db->tableExists('opd_med_master')) { + $mRow = $this->db->table('opd_med_master') + ->select('genericname, salt_name') + ->groupStart() + ->where('item_name', $clean) + ->orLike('item_name', $clean, 'after') + ->orLike('item_name', $clean, 'both') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($mRow)) { + $gen = trim((string) (!empty($mRow['genericname']) ? $mRow['genericname'] : ($mRow['salt_name'] ?? ''))); + if ($gen !== '' && ! in_array(strtoupper($gen), ['0', 'NA', 'N/A'], true)) { + return $gen; + } + } + } + + if ($this->db->tableExists('med_product_master')) { + $pRow = $this->db->table('med_product_master') + ->select('genericname') + ->groupStart() + ->where('item_name', $clean) + ->orLike('item_name', $clean, 'after') + ->orLike('item_name', $clean, 'both') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($pRow)) { + $gen = trim((string) ($pRow['genericname'] ?? '')); + if ($gen !== '' && ! in_array(strtoupper($gen), ['0', 'NA', 'N/A'], true)) { + return $gen; + } + } + } + + // 3. Keyword / token lookup for words >= 4 letters + $words = preg_split('/[\s\-_]+/', $clean); + if (is_array($words)) { + foreach ($words as $w) { + if (strlen($w) >= 4 && !in_array(strtoupper($w), ['PLUS', 'FORTE', 'EXTRA', 'GOLD'], true)) { + if ($this->db->tableExists('opd_med_master')) { + $wRow = $this->db->table('opd_med_master') + ->select('genericname, salt_name') + ->groupStart() + ->like('item_name', $w, 'after') + ->orLike('genericname', $w, 'after') + ->orLike('salt_name', $w, 'after') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($wRow)) { + $gen = trim((string) (!empty($wRow['genericname']) ? $wRow['genericname'] : ($wRow['salt_name'] ?? ''))); + if ($gen !== '' && ! in_array(strtoupper($gen), ['0', 'NA', 'N/A'], true)) { + return $gen; + } + } + } + } + } + } + } + } + } catch (\Throwable $e) { + // DB fallback + } + + // 4. Clinical brand heuristics for common hospital prescriptions + $u = strtoupper($rawName); + if (preg_match('/\b(DOZPANTA|PANTA|PANTOP|PAN\s*40|PAN\s*D|PANTOCID|PANTOCID\s*DSR|PANTODAC)\b/', $u) || str_contains($u, 'PANTOP')) { + return (str_contains($u, 'DSR') || str_contains($u, ' D')) ? 'Pantoprazole + Domperidone' : 'Pantoprazole'; + } + if (preg_match('/\b(RABEKIND|RABEMAC|RABICIP|RABLET|RAZO)\b/', $u) || str_contains($u, 'RABE')) { + return (str_contains($u, 'DSR') || str_contains($u, ' D')) ? 'Rabeprazole + Domperidone' : 'Rabeprazole'; + } + if (preg_match('/\b(OMEE|OPEZ|OCID)\b/', $u) || str_contains($u, 'OMEPRAZOLE')) { + return str_contains($u, ' D') ? 'Omeprazole + Domperidone' : 'Omeprazole'; + } + if (preg_match('/\b(ACILOC|RANTAC|ZINETAC)\b/', $u) || str_contains($u, 'RANITIDINE')) { + return 'Ranitidine'; + } + if (preg_match('/\b(DOLO|CALPOL|CROCIN|PACIMOL|SUMO\s*L)\b/', $u) || str_contains($u, 'PARACETAMOL')) { + return 'Paracetamol'; + } + if (preg_match('/\b(AZITHRAL|AZI|AZEE|AZITHRO)\b/', $u) || str_contains($u, 'AZITHROMYCIN')) { + return 'Azithromycin'; + } + if (preg_match('/\b(AUGMENTIN|MOXIKIND|CLAVAM|AMOXCLAV)\b/', $u)) { + return 'Amoxicillin + Clavulanic Acid'; + } + if (preg_match('/\b(MONTEK|MONTICOPE|LEVO\s*M)\b/', $u)) { + return 'Levocetirizine + Montelukast'; + } + if (preg_match('/\b(EMESET|VOMIKIND|ONDA)\b/', $u)) { + return 'Ondansetron'; + } + if (str_contains($u, 'ORS') || str_contains($u, 'ELECTRAL')) { + return 'Oral Rehydration Salts'; + } + + return ''; + } + private function resolveIpdLocation(int $ipdId): array { $bedNo = ''; diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index d4c17b2..5271ef3 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -9583,15 +9583,20 @@ class Ipd_discharge extends BaseController } $medicationRows = []; - $legacyMeds = $this->byIpdRows('ipd_discharge_prescrption_prescribed', ['med_name', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], 'id ASC', $ipdId); + $legacyMeds = $this->byIpdRows('ipd_discharge_prescrption_prescribed', ['med_id', 'med_name', 'med_salt', 'med_type', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], 'id ASC', $ipdId); if (empty($legacyMeds)) { - $legacyMeds = $this->byIpdRows('ipd_discharge_prescription_prescribed', ['med_name', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], 'id ASC', $ipdId); + $legacyMeds = $this->byIpdRows('ipd_discharge_prescription_prescribed', ['med_id', 'med_name', 'med_salt', 'med_type', 'dosage', 'dosage_when', 'dosage_freq', 'no_of_days', 'remark'], 'id ASC', $ipdId); } foreach ($legacyMeds as $row) { $name = trim((string) ($row['med_name'] ?? '')); if (! $this->isMeaningfulDischargeValue($name)) { continue; } + $salt = trim((string) ($row['med_salt'] ?? '')); + $medId = (int) ($row['med_id'] ?? 0); + if ($salt === '' || ! $this->isMeaningfulDischargeValue($salt)) { + $salt = $this->resolveDischargeMedSaltName($name, $medId); + } $dose = $this->formatDischargeMedDirections( $row['dosage'] ?? '', $row['dosage_when'] ?? '', @@ -9599,19 +9604,33 @@ class Ipd_discharge extends BaseController (string) ($row['no_of_days'] ?? ''), (string) ($row['remark'] ?? '') ); - $medicationRows[] = ['name' => $name, 'dosage' => $dose]; + $medicationRows[] = [ + 'name' => $name, + 'salt' => $salt, + 'generic' => $salt, + 'dosage' => $dose, + 'med_id' => $medId, + 'type' => trim((string) ($row['med_type'] ?? '')), + ]; } if (empty($medicationRows)) { - foreach ($this->byIpdRows('ipd_discharge_drug', ['drug_name', 'drug_dose', 'drug_day'], 'id ASC', $ipdId) as $row) { + foreach ($this->byIpdRows('ipd_discharge_drug', ['drug_code', 'drug_name', 'drug_dose', 'drug_day', 'formulation'], 'id ASC', $ipdId) as $row) { $name = trim((string) ($row['drug_name'] ?? '')); if (! $this->isMeaningfulDischargeValue($name)) { continue; } + $salt = $this->resolveDischargeMedSaltName($name); $dose = trim(implode(' ', array_filter([ (string) ($row['drug_dose'] ?? ''), (string) ($row['drug_day'] ?? ''), ], fn ($v) => $this->isMeaningfulDischargeValue((string) $v)))); - $medicationRows[] = ['name' => $name, 'dosage' => $dose]; + $medicationRows[] = [ + 'name' => $name, + 'salt' => $salt, + 'generic' => $salt, + 'dosage' => $dose, + 'type' => trim((string) ($row['formulation'] ?? '')), + ]; } } @@ -10070,6 +10089,154 @@ class Ipd_discharge extends BaseController return ! in_array(strtoupper($value), ['0', 'NA', 'N/A'], true); } + /** + * Auto-resolves generic/salt name of medicine from master tables or clinical patterns. + */ + private function resolveDischargeMedSaltName(string $rawName, int $medId = 0): string + { + $rawName = trim($rawName); + if ($rawName === '' || ! $this->isMeaningfulDischargeValue($rawName)) { + return ''; + } + + try { + if ($this->db) { + // 1. If med_id provided, look up masters directly + if ($medId > 0) { + if ($this->db->tableExists('opd_med_master')) { + $mRow = $this->db->table('opd_med_master') + ->select('genericname, salt_name') + ->where('id', $medId) + ->get(1) + ->getRowArray(); + if (!empty($mRow)) { + $gen = trim((string) (!empty($mRow['genericname']) ? $mRow['genericname'] : ($mRow['salt_name'] ?? ''))); + if ($gen !== '' && $this->isMeaningfulDischargeValue($gen)) { + return $gen; + } + } + } + if ($this->db->tableExists('med_product_master')) { + $pRow = $this->db->table('med_product_master') + ->select('genericname') + ->where('id', $medId) + ->get(1) + ->getRowArray(); + if (!empty($pRow)) { + $gen = trim((string) ($pRow['genericname'] ?? '')); + if ($gen !== '' && $this->isMeaningfulDischargeValue($gen)) { + return $gen; + } + } + } + } + + // 2. Clean dosage prefix and look up masters by item_name + $clean = trim((string) preg_replace('/^(TAB|CAP|SYP|INJ|CREAM|OINT|GEL|DROPS|SPRAY)\s+/i', '', $rawName)); + if ($clean !== '') { + if ($this->db->tableExists('opd_med_master')) { + $mRow = $this->db->table('opd_med_master') + ->select('genericname, salt_name') + ->groupStart() + ->where('item_name', $clean) + ->orLike('item_name', $clean, 'after') + ->orLike('item_name', $clean, 'both') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($mRow)) { + $gen = trim((string) (!empty($mRow['genericname']) ? $mRow['genericname'] : ($mRow['salt_name'] ?? ''))); + if ($gen !== '' && $this->isMeaningfulDischargeValue($gen)) { + return $gen; + } + } + } + + if ($this->db->tableExists('med_product_master')) { + $pRow = $this->db->table('med_product_master') + ->select('genericname') + ->groupStart() + ->where('item_name', $clean) + ->orLike('item_name', $clean, 'after') + ->orLike('item_name', $clean, 'both') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($pRow)) { + $gen = trim((string) ($pRow['genericname'] ?? '')); + if ($gen !== '' && $this->isMeaningfulDischargeValue($gen)) { + return $gen; + } + } + } + + // 3. Keyword / token lookup for words >= 4 letters + $words = preg_split('/[\s\-_]+/', $clean); + if (is_array($words)) { + foreach ($words as $w) { + if (strlen($w) >= 4 && !in_array(strtoupper($w), ['PLUS', 'FORTE', 'EXTRA', 'GOLD'], true)) { + if ($this->db->tableExists('opd_med_master')) { + $wRow = $this->db->table('opd_med_master') + ->select('genericname, salt_name') + ->groupStart() + ->like('item_name', $w, 'after') + ->orLike('genericname', $w, 'after') + ->orLike('salt_name', $w, 'after') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($wRow)) { + $gen = trim((string) (!empty($wRow['genericname']) ? $wRow['genericname'] : ($wRow['salt_name'] ?? ''))); + if ($gen !== '' && $this->isMeaningfulDischargeValue($gen)) { + return $gen; + } + } + } + } + } + } + } + } + } catch (\Throwable $e) { + // DB fallback + } + + // 4. Clinical brand heuristics for common hospital prescriptions + $u = strtoupper($rawName); + if (preg_match('/\b(DOZPANTA|PANTA|PANTOP|PAN\s*40|PAN\s*D|PANTOCID|PANTOCID\s*DSR|PANTODAC)\b/', $u) || str_contains($u, 'PANTOP')) { + return (str_contains($u, 'DSR') || str_contains($u, ' D')) ? 'Pantoprazole + Domperidone' : 'Pantoprazole'; + } + if (preg_match('/\b(RABEKIND|RABEMAC|RABICIP|RABLET|RAZO)\b/', $u) || str_contains($u, 'RABE')) { + return (str_contains($u, 'DSR') || str_contains($u, ' D')) ? 'Rabeprazole + Domperidone' : 'Rabeprazole'; + } + if (preg_match('/\b(OMEE|OPEZ|OCID)\b/', $u) || str_contains($u, 'OMEPRAZOLE')) { + return str_contains($u, ' D') ? 'Omeprazole + Domperidone' : 'Omeprazole'; + } + if (preg_match('/\b(ACILOC|RANTAC|ZINETAC)\b/', $u) || str_contains($u, 'RANITIDINE')) { + return 'Ranitidine'; + } + if (preg_match('/\b(DOLO|CALPOL|CROCIN|PACIMOL|SUMO\s*L)\b/', $u) || str_contains($u, 'PARACETAMOL')) { + return 'Paracetamol'; + } + if (preg_match('/\b(AZITHRAL|AZI|AZEE|AZITHRO)\b/', $u) || str_contains($u, 'AZITHROMYCIN')) { + return 'Azithromycin'; + } + if (preg_match('/\b(AUGMENTIN|MOXIKIND|CLAVAM|AMOXCLAV)\b/', $u)) { + return 'Amoxicillin + Clavulanic Acid'; + } + if (preg_match('/\b(MONTEK|MONTICOPE|LEVO\s*M)\b/', $u)) { + return 'Levocetirizine + Montelukast'; + } + if (preg_match('/\b(EMESET|VOMIKIND|ONDA)\b/', $u)) { + return 'Ondansetron'; + } + if (str_contains($u, 'ORS') || str_contains($u, 'ELECTRAL')) { + return 'Oral Rehydration Salts'; + } + + return ''; + } + private function toIsoDateTimeOrNow(string $value): string { $value = trim($value); diff --git a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php index 59b4498..6c358c9 100644 --- a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php @@ -263,32 +263,64 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac foreach ($validMeds as $idx => $med) { $name = trim((string) ($med['name'] ?? '')); - $medCode = trim((string) ($med['code'] ?? '')); + $salt = trim((string) ($med['salt'] ?? $med['generic'] ?? $med['med_salt'] ?? '')); + $medId = (int) ($med['med_id'] ?? $med['id'] ?? 0); + if ($salt === '' || ! $this->isMeaningfulValue($salt)) { + $salt = $this->tryResolveGenericSalt($name, $medId); + } + + $medCode = trim((string) ($med['code'] ?? $med['snomed_code'] ?? '')); + $hasSalt = ($salt !== '' && $this->isMeaningfulValue($salt)); + $isSaltDistinct = ($hasSalt && strcasecmp($salt, $name) !== 0); + + $saltCode = $this->resolveDrugSnomedCode($hasSalt ? $salt : $name); + if ($saltCode === '105904009' && $medCode !== '' && $this->isMeaningfulValue($medCode)) { + $saltCode = $medCode; + } + $medCoding = []; - if ($medCode !== '' && $this->isMeaningfulValue($medCode)) { + if ($isSaltDistinct) { + // Primary coding represents the generic substance / salt so PHR displays it in the subtitle line instead of "NA" $medCoding[] = [ 'system' => 'http://snomed.info/sct', - 'code' => $medCode, + 'code' => $saltCode, + 'display' => $salt, + ]; + // Secondary coding represents the prescribed brand name + $brandCode = ($medCode !== '' && $this->isMeaningfulValue($medCode) && $medCode !== $saltCode) ? $medCode : '105904009'; + $medCoding[] = [ + 'system' => 'http://snomed.info/sct', + 'code' => $brandCode, 'display' => $name, ]; } else { + $display = $hasSalt ? $salt : $name; + $code = ($medCode !== '' && $this->isMeaningfulValue($medCode)) ? $medCode : $saltCode; $medCoding[] = [ 'system' => 'http://snomed.info/sct', - 'code' => '105904009', // Type of drug - 'display' => $name, + 'code' => $code, + 'display' => $display, ]; } $dosageText = trim((string) ($med['dosage'] ?? '')); $structuredDosage = $this->buildStructuredDosageInstruction($dosageText, $name); + $divMedText = htmlspecialchars($name); + if ($isSaltDistinct) { + $divMedText .= ' (' . htmlspecialchars($salt) . ')'; + } + if ($dosageText !== '') { + $divMedText .= ' - ' . htmlspecialchars($dosageText); + } + $medResource = [ '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)) : '') . '

', + 'div' => '

Medication: ' . $divMedText . '

', ], 'status' => 'active', 'intent' => 'order', @@ -1122,11 +1154,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac /** @param array $item */ private function sectionItemNarrative(array $item): string { - // For medications: name + dosage + // For medications: name (salt) + dosage if (isset($item['name']) && $this->isMeaningfulValue((string) $item['name'])) { $name = trim((string) $item['name']); + $salt = trim((string) ($item['salt'] ?? $item['generic'] ?? $item['med_salt'] ?? '')); $dosage = trim((string) ($item['dosage'] ?? '')); - return trim($name . ($this->isMeaningfulValue($dosage) ? ' - ' . $dosage : '')); + $namePart = ($salt !== '' && strcasecmp($salt, $name) !== 0) ? ($name . ' (' . $salt . ')') : $name; + return trim($namePart . ($this->isMeaningfulValue($dosage) ? ' - ' . $dosage : '')); } // For observations: text + value (+ unit) @@ -1324,10 +1358,15 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac if (! empty($medications)) { $medHtml .= ''; foreach ($medications as $med) { - $name = is_array($med) ? ($med['name'] ?? '') : ''; - $dosage = is_array($med) ? ($med['dosage'] ?? '') : ''; + $name = is_array($med) ? trim((string) ($med['name'] ?? '')) : ''; + $salt = is_array($med) ? trim((string) ($med['salt'] ?? $med['generic'] ?? $med['med_salt'] ?? '')) : ''; + $dosage = is_array($med) ? trim((string) ($med['dosage'] ?? '')) : ''; if ($name !== '') { - $medHtml .= ''; + $nameDisplay = htmlspecialchars($name); + if ($salt !== '' && strcasecmp($salt, $name) !== 0) { + $nameDisplay .= '
(' . htmlspecialchars($salt) . ')'; + } + $medHtml .= ''; } } $medHtml .= '
Medication NameDosage & Frequency
' . htmlspecialchars($name) . '' . htmlspecialchars($dosage) . '
' . $nameDisplay . '' . htmlspecialchars($dosage) . '
'; @@ -1457,10 +1496,15 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $html .= '
Prescribed Discharge Medications
'; $html .= ''; foreach ($medications as $med) { - $name = is_array($med) ? ($med['name'] ?? '') : ''; - $dosage = is_array($med) ? ($med['dosage'] ?? '') : ''; + $name = is_array($med) ? trim((string) ($med['name'] ?? '')) : ''; + $salt = is_array($med) ? trim((string) ($med['salt'] ?? $med['generic'] ?? $med['med_salt'] ?? '')) : ''; + $dosage = is_array($med) ? trim((string) ($med['dosage'] ?? '')) : ''; if ($name !== '') { - $html .= ''; + $nameDisplay = htmlspecialchars($name); + if ($salt !== '' && strcasecmp($salt, $name) !== 0) { + $nameDisplay .= '
(' . htmlspecialchars($salt) . ')'; + } + $html .= ''; } } $html .= '
Medication NameDosage & Frequency
' . htmlspecialchars($name) . '' . htmlspecialchars($dosage) . '
' . $nameDisplay . '' . htmlspecialchars($dosage) . '
'; @@ -1843,4 +1887,234 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'dosageInstruction' => [$instructionItem], ]; } + + /** + * Resolve Generic / Salt name of medicine from master database or common patterns + */ + private function tryResolveGenericSalt(string $medName, int $medId = 0): string + { + $rawName = trim($medName); + if ($rawName === '' || ! $this->isMeaningfulValue($rawName)) { + return ''; + } + + try { + if (class_exists('\Config\Database')) { + $db = \Config\Database::connect(); + + // 1. By med_id if given + if ($medId > 0) { + if ($db->tableExists('opd_med_master')) { + $mRow = $db->table('opd_med_master') + ->select('genericname, salt_name') + ->where('id', $medId) + ->get(1) + ->getRowArray(); + if (!empty($mRow)) { + $gen = trim((string) (!empty($mRow['genericname']) ? $mRow['genericname'] : ($mRow['salt_name'] ?? ''))); + if ($gen !== '' && $this->isMeaningfulValue($gen)) { + return $gen; + } + } + } + if ($db->tableExists('med_product_master')) { + $pRow = $db->table('med_product_master') + ->select('genericname') + ->where('id', $medId) + ->get(1) + ->getRowArray(); + if (!empty($pRow)) { + $gen = trim((string) ($pRow['genericname'] ?? '')); + if ($gen !== '' && $this->isMeaningfulValue($gen)) { + return $gen; + } + } + } + } + + // 2. Clean medicine name and search by item_name + $clean = trim((string) preg_replace('/^(TAB|CAP|SYP|INJ|CREAM|OINT|GEL|DROPS|SPRAY)\s+/i', '', $rawName)); + if ($clean !== '') { + if ($db->tableExists('opd_med_master')) { + $mRow = $db->table('opd_med_master') + ->select('genericname, salt_name') + ->groupStart() + ->where('item_name', $clean) + ->orLike('item_name', $clean, 'after') + ->orLike('item_name', $clean, 'both') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($mRow)) { + $gen = trim((string) (!empty($mRow['genericname']) ? $mRow['genericname'] : ($mRow['salt_name'] ?? ''))); + if ($gen !== '' && $this->isMeaningfulValue($gen)) { + return $gen; + } + } + } + + if ($db->tableExists('med_product_master')) { + $pRow = $db->table('med_product_master') + ->select('genericname') + ->groupStart() + ->where('item_name', $clean) + ->orLike('item_name', $clean, 'after') + ->orLike('item_name', $clean, 'both') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($pRow)) { + $gen = trim((string) ($pRow['genericname'] ?? '')); + if ($gen !== '' && $this->isMeaningfulValue($gen)) { + return $gen; + } + } + } + + // 3. Match significant words (>= 4 chars) + $words = preg_split('/[\s\-_]+/', $clean); + if (is_array($words)) { + foreach ($words as $w) { + if (strlen($w) >= 4 && !in_array(strtoupper($w), ['PLUS', 'FORTE', 'EXTRA', 'GOLD'], true)) { + if ($db->tableExists('opd_med_master')) { + $wRow = $db->table('opd_med_master') + ->select('genericname, salt_name') + ->groupStart() + ->like('item_name', $w, 'after') + ->orLike('genericname', $w, 'after') + ->orLike('salt_name', $w, 'after') + ->groupEnd() + ->get(1) + ->getRowArray(); + if (!empty($wRow)) { + $gen = trim((string) (!empty($wRow['genericname']) ? $wRow['genericname'] : ($wRow['salt_name'] ?? ''))); + if ($gen !== '' && $this->isMeaningfulValue($gen)) { + return $gen; + } + } + } + } + } + } + } + } + } catch (\Throwable $e) { + // Safe fallback if database is inaccessible + } + + // 4. Clinical brand heuristics for common hospital prescriptions + $u = strtoupper($rawName); + if (preg_match('/\b(DOZPANTA|PANTA|PANTOP|PAN\s*40|PAN\s*D|PANTOCID|PANTOCID\s*DSR|PANTODAC)\b/', $u) || str_contains($u, 'PANTOP')) { + return (str_contains($u, 'DSR') || str_contains($u, ' D')) ? 'Pantoprazole + Domperidone' : 'Pantoprazole'; + } + if (preg_match('/\b(RABEKIND|RABEMAC|RABICIP|RABLET|RAZO)\b/', $u) || str_contains($u, 'RABE')) { + return (str_contains($u, 'DSR') || str_contains($u, ' D')) ? 'Rabeprazole + Domperidone' : 'Rabeprazole'; + } + if (preg_match('/\b(OMEE|OPEZ|OCID)\b/', $u) || str_contains($u, 'OMEPRAZOLE')) { + return str_contains($u, ' D') ? 'Omeprazole + Domperidone' : 'Omeprazole'; + } + if (preg_match('/\b(ACILOC|RANTAC|ZINETAC)\b/', $u) || str_contains($u, 'RANITIDINE')) { + return 'Ranitidine'; + } + if (preg_match('/\b(DOLO|CALPOL|CROCIN|PACIMOL|SUMO\s*L)\b/', $u) || str_contains($u, 'PARACETAMOL')) { + return 'Paracetamol'; + } + if (preg_match('/\b(AZITHRAL|AZI|AZEE|AZITHRO)\b/', $u) || str_contains($u, 'AZITHROMYCIN')) { + return 'Azithromycin'; + } + if (preg_match('/\b(AUGMENTIN|MOXIKIND|CLAVAM|AMOXCLAV)\b/', $u)) { + return 'Amoxicillin + Clavulanic Acid'; + } + if (preg_match('/\b(MONTEK|MONTICOPE|LEVO\s*M)\b/', $u)) { + return 'Levocetirizine + Montelukast'; + } + if (preg_match('/\b(EMESET|VOMIKIND|ONDA)\b/', $u)) { + return 'Ondansetron'; + } + if (str_contains($u, 'ORS') || str_contains($u, 'ELECTRAL')) { + return 'Oral Rehydration Salts'; + } + + return ''; + } + + /** + * Resolve SNOMED code for drug substance or generic salt + */ + private function resolveDrugSnomedCode(string $drugOrSalt): string + { + $term = trim($drugOrSalt); + if ($term === '' || ! $this->isMeaningfulValue($term)) { + return '105904009'; + } + + try { + if (class_exists('\Config\Database')) { + $db = \Config\Database::connect(); + if ($db->tableExists('snomed_description')) { + $clean = trim((string) preg_replace('/[^\w\s]/', ' ', $term)); + $firstWord = trim((string) explode(' ', $clean)[0]); + if (strlen($firstWord) >= 4) { + $row = $db->table('snomed_description') + ->select('concept_id') + ->where('active', '1') + ->whereIn('type_id', ['900000000000003001', '900000000000013009']) + ->like('term_normalized', strtolower($firstWord), 'after') + ->limit(1) + ->get() + ->getRowArray(); + if (!empty($row['concept_id'])) { + return (string) $row['concept_id']; + } + } + } + } + } catch (\Throwable $e) { + // DB fallback + } + + $upper = strtoupper($term); + if (str_contains($upper, 'PANTOPRAZOLE')) { + return '395821003'; + } + if (str_contains($upper, 'RANITIDINE')) { + return '372755005'; + } + if (str_contains($upper, 'PARACETAMOL') || str_contains($upper, 'ACETAMINOPHEN')) { + return '384977007'; + } + if (str_contains($upper, 'AZITHROMYCIN')) { + return '387531004'; + } + if (str_contains($upper, 'AMOXICILLIN')) { + return '372687004'; + } + if (str_contains($upper, 'DOMPERIDONE')) { + return '387181004'; + } + if (str_contains($upper, 'ONDANSETRON')) { + return '387570007'; + } + if (str_contains($upper, 'METFORMIN')) { + return '372567009'; + } + if (str_contains($upper, 'ATORVASTATIN')) { + return '387584000'; + } + if (str_contains($upper, 'ASPIRIN')) { + return '387458008'; + } + if (str_contains($upper, 'RABEPRAZOLE')) { + return '387180000'; + } + if (str_contains($upper, 'OMEPRAZOLE')) { + return '372544007'; + } + if (str_contains($upper, 'ORAL REHYDRATION')) { + return '260548002'; + } + + return '105904009'; + } } + diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index 02e688e..1d24908 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -1000,6 +1000,15 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertContains('Physical Examination', $sectionTitles); $this->assertContains('Medications', $sectionTitles); $this->assertContains('Care Plan', $sectionTitles); + + // Verify medication generic/salt is populated on MedicationRequest + $medRequests = array_values(array_filter($bundle['entry'], static fn ($e) => ($e['resource']['resourceType'] ?? '') === 'MedicationRequest')); + $this->assertNotEmpty($medRequests); + $dozpanta = $medRequests[0]['resource']; + $this->assertSame('DOZPANTA DSR', $dozpanta['medicationCodeableConcept']['text']); + $this->assertSame('Pantoprazole + Domperidone', $dozpanta['medicationCodeableConcept']['coding'][0]['display']); + $this->assertSame('395821003', $dozpanta['medicationCodeableConcept']['coding'][0]['code']); + $this->assertStringContainsString('Pantoprazole + Domperidone', $dozpanta['text']['div']); } public function testDischargeMandatoryFieldsPatientEncounterDiagnosisAndOutcome(): void @@ -1125,6 +1134,76 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame('exp', $encExp['hospitalization']['dischargeDisposition']['coding'][0]['code']); } + public function testDischargeMedicationGenericSaltResolutionAndDisplay(): void + { + $generator = $this->factory->discharge(); + + // 1. Explicit salt passed in medication item + $srcExplicit = [ + 'record_id' => 8801, + 'completed_at' => '2026-10-19T10:00:00+05:30', + 'patient' => ['id' => 101, 'name' => 'John Doe', 'gender' => 'male', 'dob' => '1980-05-15'], + 'encounter' => ['id' => 8801, 'start' => '2026-10-15T10:00:00+05:30', 'end' => '2026-10-19T10:00:00+05:30'], + 'diagnoses' => [['text' => 'Acid Peptic Disorder']], + 'medications' => [ + [ + 'name' => 'DOZPANTA DSR', + 'salt' => 'Pantoprazole + Domperidone', + 'dosage' => 'Take 1 Capsule Once daily before meals for 5 days', + ], + [ + 'name' => 'ACILOC 150', + 'generic' => 'Ranitidine', + 'dosage' => '1 Tab Twice daily for 3 days', + ], + ], + ]; + + $outExplicit = $generator->generate($srcExplicit); + $this->assertTrue((bool) ($outExplicit['validation']['valid'] ?? false)); + $bundleExp = $outExplicit['fhir_bundle']; + + $medsExp = array_values(array_filter($bundleExp['entry'], static fn ($e) => ($e['resource']['resourceType'] ?? '') === 'MedicationRequest')); + $this->assertCount(2, $medsExp); + + // First med: DOZPANTA DSR with salt Pantoprazole + Domperidone + $med1 = $medsExp[0]['resource']; + $this->assertSame('DOZPANTA DSR', $med1['medicationCodeableConcept']['text']); + $this->assertSame('Pantoprazole + Domperidone', $med1['medicationCodeableConcept']['coding'][0]['display']); + $this->assertSame('395821003', $med1['medicationCodeableConcept']['coding'][0]['code']); + $this->assertSame('DOZPANTA DSR', $med1['medicationCodeableConcept']['coding'][1]['display']); + $this->assertStringContainsString('DOZPANTA DSR (Pantoprazole + Domperidone)', $med1['text']['div']); + + // Second med: ACILOC 150 with salt Ranitidine + $med2 = $medsExp[1]['resource']; + $this->assertSame('ACILOC 150', $med2['medicationCodeableConcept']['text']); + $this->assertSame('Ranitidine', $med2['medicationCodeableConcept']['coding'][0]['display']); + $this->assertSame('372755005', $med2['medicationCodeableConcept']['coding'][0]['code']); + $this->assertSame('ACILOC 150', $med2['medicationCodeableConcept']['coding'][1]['display']); + + // 2. Heuristic resolution when salt is not explicitly given in source array + $srcHeuristic = [ + 'record_id' => 8802, + 'completed_at' => '2026-10-19T10:00:00+05:30', + 'patient' => ['id' => 102, 'name' => 'Jane Smith', 'gender' => 'female', 'dob' => '1992-08-20'], + 'encounter' => ['id' => 8802, 'start' => '2026-10-15T10:00:00+05:30', 'end' => '2026-10-19T10:00:00+05:30'], + 'diagnoses' => [['text' => 'Dyspepsia']], + 'medications' => [ + [ + 'name' => 'DOZPANTA DSR', + 'dosage' => 'OD for 5 days', + ], + ], + ]; + + $outHeuristic = $generator->generate($srcHeuristic); + $this->assertTrue((bool) ($outHeuristic['validation']['valid'] ?? false)); + $medsHeu = array_values(array_filter($outHeuristic['fhir_bundle']['entry'], static fn ($e) => ($e['resource']['resourceType'] ?? '') === 'MedicationRequest')); + $this->assertCount(1, $medsHeu); + $this->assertSame('DOZPANTA DSR', $medsHeu[0]['resource']['medicationCodeableConcept']['text']); + $this->assertSame('Pantoprazole + Domperidone', $medsHeu[0]['resource']['medicationCodeableConcept']['coding'][0]['display']); + } + public function testPrescriptionGeneratorHappyPath(): void { $src = [