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
This commit is contained in:
DevSoftTech Server 2026-10-19 10:16:05 +05:30
parent 9272bb5a9c
commit 6f3b2a62d9
4 changed files with 711 additions and 24 deletions

View File

@ -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 = '';

View File

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

View File

@ -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' => '<div xmlns="http://www.w3.org/1999/xhtml"><p><b>Medication:</b> ' . htmlspecialchars($name) . ($dosageText !== '' ? (' - ' . htmlspecialchars($dosageText)) : '') . '</p></div>',
'div' => '<div xmlns="http://www.w3.org/1999/xhtml"><p><b>Medication:</b> ' . $divMedText . '</p></div>',
],
'status' => 'active',
'intent' => 'order',
@ -1122,11 +1154,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
/** @param array<string,mixed> $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 .= '<table class="grid"><tr><th>Medication Name</th><th>Dosage &amp; Frequency</th></tr>';
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 .= '<tr><td>' . htmlspecialchars($name) . '</td><td>' . htmlspecialchars($dosage) . '</td></tr>';
$nameDisplay = htmlspecialchars($name);
if ($salt !== '' && strcasecmp($salt, $name) !== 0) {
$nameDisplay .= '<br/><small style="color:#64748b;">(' . htmlspecialchars($salt) . ')</small>';
}
$medHtml .= '<tr><td>' . $nameDisplay . '</td><td>' . htmlspecialchars($dosage) . '</td></tr>';
}
}
$medHtml .= '</table>';
@ -1457,10 +1496,15 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
$html .= '<div class="section-title">Prescribed Discharge Medications</div>';
$html .= '<table class="grid"><tr><th>Medication Name</th><th>Dosage &amp; Frequency</th></tr>';
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 .= '<tr><td>' . htmlspecialchars($name) . '</td><td>' . htmlspecialchars($dosage) . '</td></tr>';
$nameDisplay = htmlspecialchars($name);
if ($salt !== '' && strcasecmp($salt, $name) !== 0) {
$nameDisplay .= '<br/><small style="color:#64748b;">(' . htmlspecialchars($salt) . ')</small>';
}
$html .= '<tr><td>' . $nameDisplay . '</td><td>' . htmlspecialchars($dosage) . '</td></tr>';
}
}
$html .= '</table>';
@ -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';
}
}

View File

@ -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 = [