From e08c18f5b2a99508b8c98773ce1673228cd49dc5 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Tue, 30 Jun 2026 18:09:55 +0530 Subject: [PATCH] ABDM: fix local submit fallback, HFR runtime usage, status sync, and link-token retry --- app/Controllers/AbdmGateway.php | 71 ++- app/Controllers/AbdmTaskBoard.php | 2 +- app/Controllers/Opd_prescription.php | 594 ++++++++++++++++++- app/Libraries/Abdm/EAtriaBridgeConnector.php | 37 +- app/Libraries/FhirR4Builder.php | 185 +++++- 5 files changed, 850 insertions(+), 39 deletions(-) diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index b948f5f..c00ed18 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -983,10 +983,11 @@ class AbdmGateway extends BaseController $abhaId = trim((string) $this->request->getPost('abha_id')); $abhaAddressPost = trim((string) $this->request->getPost('abha_address')); $consentHandle = trim((string) $this->request->getPost('consent_handle')); - $pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1; + $pushToGatewayRequested = (int) $this->request->getPost('push_to_gateway') === 1; + $pushToGateway = $pushToGatewayRequested || $this->isGatewayPushForcedByDeployment(); $now = Time::now('Asia/Kolkata')->toDateTimeString(); - $logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGateway): void { + $logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGatewayRequested, $pushToGateway): void { if (! $this->db->tableExists('abdm_api_logs')) { return; } @@ -1001,6 +1002,7 @@ class AbdmGateway extends BaseController 'request_json' => (string) json_encode([ 'opd_id' => $opdId, 'patient_id' => $patientId, + 'push_to_gateway_requested' => $pushToGatewayRequested ? 1 : 0, 'push_to_gateway' => $pushToGateway ? 1 : 0, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'response_code' => 200, @@ -2964,6 +2966,24 @@ class AbdmGateway extends BaseController try { $result = $this->connector->linkAndShare($bridgeId); + + if ($this->shouldRetryBridgeLinkAndShareForTokenHipMismatch($result) + && method_exists($this->connector, 'linkAndShareWithFreshToken')) { + /** @var callable $retryCallable */ + $retryCallable = [$this->connector, 'linkAndShareWithFreshToken']; + $retry = $retryCallable($bridgeId); + if (is_array($retry)) { + $retry['retry_mode'] = 'fresh_link_token'; + if ((int) ($retry['ok'] ?? 0) === 1) { + return $this->response->setJSON($retry); + } + // Prefer clearer guidance if retry still fails. + if (! isset($retry['message']) && isset($retry['error_text'])) { + $retry['message'] = (string) $retry['error_text']; + } + $result = $retry; + } + } } catch (\Throwable $e) { // Backward compatibility with older connectors. $result = $this->connector->triggerShare($bridgeId); @@ -2971,6 +2991,35 @@ class AbdmGateway extends BaseController return $this->response->setJSON($result); } + /** + * @param array $result + */ + private function shouldRetryBridgeLinkAndShareForTokenHipMismatch(array $result): bool + { + $httpCode = (int) ($result['http_code'] ?? 0); + $msg = strtolower(trim((string) ( + $result['message'] + ?? $result['error_text'] + ?? $result['error'] + ?? '' + ))); + + if ($httpCode !== 409 || $msg === '') { + return false; + } + + $isLinkTokenIssue = str_contains($msg, 'link token') + || str_contains($msg, 'link-token') + || str_contains($msg, 'link_and_share_link_token') + || str_contains($msg, 'stored token'); + + $isHipMismatch = str_contains($msg, 'hip id') + || str_contains($msg, 'hfr_id') + || str_contains($msg, 'belongs to hip'); + + return $isLinkTokenIssue && $isHipMismatch; + } + // ========================================================================= // Explicit e-Atria orchestration aliases for HMS integration docs. // ========================================================================= @@ -3662,6 +3711,24 @@ class AbdmGateway extends BaseController return ''; } + private function isGatewayPushForcedByDeployment(): bool + { + foreach (['ABDM_FORCE_GATEWAY_PUSH', 'ABDM_HMS_BEHIND_NAT', 'ABDM_GATEWAY_HOSTED_DISCOVERY'] as $key) { + $raw = strtolower(trim((string) $this->readRuntimeSetting($key))); + if ($raw === '') { + continue; + } + if (in_array($raw, ['1', 'true', 'yes', 'on', 'y'], true)) { + return true; + } + if (in_array($raw, ['0', 'false', 'no', 'off', 'n'], true)) { + return false; + } + } + + return false; + } + private function isStaleConsentTransition(string $currentStatus, string $incomingStatus): bool { if ($incomingStatus === '' || $currentStatus === '') { diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index fbdce1e..f97655a 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -640,7 +640,7 @@ class AbdmTaskBoard extends BaseController $statusTone = 'success'; $statusNote = 'Care context link confirmed by callback.'; } elseif ($linkStatus === 'pending_discovery' && $statusLabel === 'Discovery Ready') { - $statusNote = 'Waiting for ABDM discovery and consent fetch callbacks.'; + $statusNote = 'Waiting for discovery/fetch workflow completion (callback or bridge sync).'; } elseif ($linkStatus === 'failed') { $statusLabel = 'Link Failed'; $statusTone = 'danger'; diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index ef2f9f5..701fdca 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\Abdm\AbdmConnectorFactory; use App\Libraries\AbdmWorkTaskService; use App\Libraries\BridgeSyncService; use App\Libraries\CsnotkTerminologyService; @@ -4723,8 +4724,7 @@ class Opd_prescription extends BaseController try { // Rebuild bundle using current builder - $abhaField = $this->resolvePatientAbhaField(); - $abhaAddress = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : ''; + $abhaIdentity = $this->resolvePatientAbhaIdentifiersForFhir($patientRow); $patFirstName = $this->sanitizePersonNamePart((string) ($patientRow['p_fname'] ?? '')); $patLastName = $this->sanitizePersonNamePart((string) ($patientRow['p_lname'] ?? '')); @@ -4744,7 +4744,8 @@ class Opd_prescription extends BaseController 'family_name' => $patLastName, 'gender' => $this->normalizeGender((string) ($patientRow['gender'] ?? '')), 'birthDate' => ! empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '', - 'abhaAddress' => $abhaAddress, + 'abhaAddress' => $abhaIdentity['address'], + 'abhaId' => $abhaIdentity['id'], 'phone' => $patPhone, ]; @@ -4860,6 +4861,56 @@ class Opd_prescription extends BaseController // AJAX callers (modal preview) get JSON directly if ($this->request->isAJAX()) { + $entryList = is_array($bundle['entry'] ?? null) ? $bundle['entry'] : []; + $firstEntryResourceType = (string) ($entryList[0]['resource']['resourceType'] ?? ''); + $compositionFirst = $firstEntryResourceType === 'Composition'; + $patientHasIdentifier = false; + $structuredMedicationRequests = 0; + $totalMedicationRequests = 0; + + foreach ($entryList as $entry) { + $resource = is_array($entry['resource'] ?? null) ? $entry['resource'] : []; + $rt = (string) ($resource['resourceType'] ?? ''); + + if ($rt === 'Patient') { + $identifiers = is_array($resource['identifier'] ?? null) ? $resource['identifier'] : []; + $patientHasIdentifier = ! empty($identifiers); + } + + if ($rt === 'MedicationRequest') { + $totalMedicationRequests++; + $coding = (array) ($resource['medicationCodeableConcept']['coding'] ?? []); + if (! empty($coding)) { + $structuredMedicationRequests++; + } + } + } + + $identityDebug = [ + 'patient_id' => 0, + 'local_identifier_system' => 'https://hms.local/patient-id', + 'local_identifier_value' => '', + 'abha_id' => '', + 'abha_address' => '', + 'has_abha_id' => false, + 'has_abha_address' => false, + ]; + + if ($this->db->tableExists('opd_master')) { + $opdRow = $this->db->table('opd_master')->select('p_id')->where('opd_id', (int) $opdId)->get(1)->getRowArray() ?? []; + $patientId = (int) ($opdRow['p_id'] ?? 0); + if ($patientId > 0 && $this->db->tableExists('patient_master')) { + $patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? []; + $abhaIdentity = $this->resolvePatientAbhaIdentifiersForFhir($patientRow); + $identityDebug['patient_id'] = $patientId; + $identityDebug['local_identifier_value'] = (string) $patientId; + $identityDebug['abha_id'] = (string) ($abhaIdentity['id'] ?? ''); + $identityDebug['abha_address'] = (string) ($abhaIdentity['address'] ?? ''); + $identityDebug['has_abha_id'] = trim((string) ($abhaIdentity['id'] ?? '')) !== ''; + $identityDebug['has_abha_address'] = trim((string) ($abhaIdentity['address'] ?? '')) !== ''; + } + } + // Fetch live complaint and diagnosis terms from current prescription row $liveComplaints = []; $liveDiagnoses = []; @@ -4904,6 +4955,13 @@ class Opd_prescription extends BaseController 'bundle_type' => $bundleType, 'opd_id' => (int) $opdId, 'opd_session_id' => (int) $sessionId, + 'bundle_checks' => [ + 'composition_first' => $compositionFirst, + 'patient_has_identifier' => $patientHasIdentifier, + 'medicationrequest_total' => $totalMedicationRequests, + 'medicationrequest_structured' => $structuredMedicationRequests, + ], + 'identity_debug' => $identityDebug, 'live_complaints' => $liveComplaints, 'live_diagnoses' => $liveDiagnoses, ]); @@ -5088,10 +5146,13 @@ class Opd_prescription extends BaseController . '
Bundle type is "document"
' . '
First entry is Composition
' . '
Patient resource present
' + . '
Patient has at least one identifier
' . '
At least one clinical resource
' + . '
MedicationRequest entries are coded (if present)
' . '
Visit date valid
' . '
Bridge connection reachable
' . '' + . '
' . '' @@ -5138,6 +5199,8 @@ class Opd_prescription extends BaseController . ' const btn=this;' . ' btn.disabled=true;btn.innerHTML=\'Verifying\u2026\';' . ' const checks=document.querySelectorAll("#checkList .check-item");' + . ' const strictIssuesEl=document.getElementById("strictIssues");' + . ' strictIssuesEl.innerHTML="";' . ' function setCheck(i,pass,msg){' . ' checks[i].className="check-item "+(pass?"pass":"fail");' . ' checks[i].innerHTML=(pass?\'\':\'\')+\'\'+msg+\'\';' @@ -5160,16 +5223,27 @@ class Opd_prescription extends BaseController . ' const hasPatient=(BUNDLE.entry||[]).some(e=>e.resource&&e.resource.resourceType==="Patient");' . ' setCheck(3,hasPatient,hasPatient?"Patient resource present":"Patient resource missing from bundle");' - // Check 5: Clinical resource + // Check 5: Patient identifier present + . ' const patientResource=(BUNDLE.entry||[]).map(e=>e&&e.resource?e.resource:null).find(r=>r&&r.resourceType==="Patient")||null;' + . ' const patientHasIdentifier=!!(patientResource&&Array.isArray(patientResource.identifier)&&patientResource.identifier.length>0);' + . ' setCheck(4,patientHasIdentifier,patientHasIdentifier?"Patient identifier present":"Patient identifier missing");' + + // Check 6: Clinical resource . ' const clinTypes=["MedicationRequest","Condition","Observation","AllergyIntolerance","DiagnosticReport"];' . ' const clinCount=(BUNDLE.entry||[]).filter(e=>e.resource&&clinTypes.includes(e.resource.resourceType)).length;' - . ' setCheck(4,clinCount>0,"Clinical resources: "+clinCount+(clinCount>0?" present":" — bundle has no medications/conditions/observations"));' + . ' setCheck(5,clinCount>0,"Clinical resources: "+clinCount+(clinCount>0?" present":" — bundle has no medications/conditions/observations"));' - // Check 6: visit date + // Check 7: Structured MedicationRequest coding + . ' const medReqs=(BUNDLE.entry||[]).filter(e=>e.resource&&e.resource.resourceType==="MedicationRequest").map(e=>e.resource);' + . ' const medReqStructured=medReqs.filter(m=>Array.isArray(((m.medicationCodeableConcept||{}).coding||[]))&&((m.medicationCodeableConcept||{}).coding||[]).length>0).length;' + . ' const medStructuredOk=medReqs.length===0||medReqStructured>0;' + . ' setCheck(6,medStructuredOk,medReqs.length===0?"No MedicationRequest entries in bundle":"Structured MedicationRequest: "+medReqStructured+"/"+medReqs.length);' + + // Check 8: visit date . ' const dateOk=CTX.visitDate&&/^\d{4}-\d{2}-\d{2}$/.test(CTX.visitDate);' - . ' setCheck(5,dateOk,dateOk?"Visit date: "+CTX.visitDate:"Visit date missing or invalid");' + . ' setCheck(7,dateOk,dateOk?"Visit date: "+CTX.visitDate:"Visit date missing or invalid");' - // Check 7: Bridge connectivity (AJAX) + // Check 9: Bridge connectivity (AJAX) . ' document.getElementById("chkBridge").className="check-item pending";' . ' document.getElementById("chkBridge").innerHTML=\'Checking bridge\u2026\';' . ' let bridgeOk=false;' @@ -5178,11 +5252,11 @@ class Opd_prescription extends BaseController . ' const j=await r.json();' . ' bridgeOk=r.ok&&(j.ok===1||j.status==="ok");' . ' const info=bridgeOk?(j.authenticated_as?(" ("+j.authenticated_as+")"):""):" — "+( j.error_text||j.message||"unreachable");' - . ' setCheck(6,bridgeOk,bridgeOk?"Bridge connected"+info:"Bridge unreachable"+info);' - . ' }catch(e){setCheck(6,false,"Bridge check failed: "+e.message);}' + . ' setCheck(8,bridgeOk,bridgeOk?"Bridge connected"+info:"Bridge unreachable"+info);' + . ' }catch(e){setCheck(8,false,"Bridge check failed: "+e.message);}' // Evaluate overall result - . ' const allPass=hasAbha&&isDoc&&isComp&&hasPatient&&clinCount>0&&dateOk&&bridgeOk;' + . ' const allPass=hasAbha&&isDoc&&hasPatient&&patientHasIdentifier&&clinCount>0&&medStructuredOk&&dateOk&&bridgeOk;' . ' btn.disabled=false;' . ' if(allPass){' . ' btn.className="btn btn-outline-success w-100 mt-3";' @@ -5193,6 +5267,10 @@ class Opd_prescription extends BaseController . ' btn.className="btn btn-warning w-100 mt-3";' . ' btn.innerHTML=\'Issues Found — Fix & Re-verify\';' . ' document.getElementById("submitCard").style.display="none";' + . ' const issues=[];' + . ' if(!patientHasIdentifier) issues.push("Patient identifier missing");' + . ' if(!medStructuredOk) issues.push("MedicationRequest entries are not coded");' + . ' if(issues.length){strictIssuesEl.innerHTML=\'
Strict checks: \'+issues.join("; ")+\'
\';}' . ' }' . '});' @@ -5224,7 +5302,11 @@ class Opd_prescription extends BaseController . ' el.innerHTML=\'
\u2705 \'+(res.message||"Submitted successfully.")+qPart+idPart+\'
\';' . ' }else{' . ' btn.disabled=false;btn.innerHTML=origHtml;' - . ' el.innerHTML=\'
\u274c \'+(res.message||res.error_text||"Submission failed.")+(res.http_code?" (HTTP "+res.http_code+")":"")+\'
\';' + . ' let strictText="";' + . ' if(res.bundle_checks){' + . ' strictText="
Server strict checks failed. Please run Verify Data and fix highlighted items.
";' + . ' }' + . ' el.innerHTML=\'
\u274c \'+(res.message||res.error_text||"Submission failed.")+(res.http_code?" (HTTP "+res.http_code+")":"")+strictText+\'
\';' . ' }' . ' }).catch(e=>{' . ' clearTimeout(submitTimer);' @@ -5305,6 +5387,55 @@ class Opd_prescription extends BaseController ]); } + // Strict pre-submit validation to prevent avoidable ABDM rejections. + $entryList = is_array($bundle['entry'] ?? null) ? $bundle['entry'] : []; + $firstEntryResourceType = (string) ($entryList[0]['resource']['resourceType'] ?? ''); + $compositionFirst = $firstEntryResourceType === 'Composition'; + $patientHasIdentifier = false; + $totalMedicationRequests = 0; + $structuredMedicationRequests = 0; + + foreach ($entryList as $entry) { + $resource = is_array($entry['resource'] ?? null) ? $entry['resource'] : []; + $rt = (string) ($resource['resourceType'] ?? ''); + + if ($rt === 'Patient') { + $identifiers = is_array($resource['identifier'] ?? null) ? $resource['identifier'] : []; + $patientHasIdentifier = ! empty($identifiers); + } + + if ($rt === 'MedicationRequest') { + $totalMedicationRequests++; + $coding = (array) ($resource['medicationCodeableConcept']['coding'] ?? []); + if (! empty($coding)) { + $structuredMedicationRequests++; + } + } + } + + $strictErrors = []; + if (! $patientHasIdentifier) { + $strictErrors[] = 'FHIR validation failed: Patient.identifier is missing.'; + } + if ($totalMedicationRequests > 0 && $structuredMedicationRequests === 0) { + $strictErrors[] = 'FHIR validation failed: MedicationRequest resources are present but none contain coded medicationCodeableConcept.'; + } + + if (! empty($strictErrors)) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => implode(' ', $strictErrors), + 'bundle_checks' => [ + 'composition_first' => $compositionFirst, + 'patient_has_identifier' => $patientHasIdentifier, + 'medicationrequest_total' => $totalMedicationRequests, + 'medicationrequest_structured' => $structuredMedicationRequests, + ], + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + // Gather patient / OPD context for the push payload $patientId = 0; $patientName = ''; @@ -5366,6 +5497,31 @@ class Opd_prescription extends BaseController $cookieHeader = (string) $this->request->getHeaderLine('Cookie'); $handoffTimeoutSec = $this->resolveFhirGatewayHandoffTimeoutSec(); + // In PHP built-in server mode, posting back into the same app can deadlock + // because only one request is processed at a time. + if ($this->isLocalSingleThreadHandoffRisk($gatewayUrl)) { + $directResult = $this->submitFhirBundleDirectBridgePush([ + 'opd_id' => $opdId, + 'opd_session_id' => $sessionId, + 'patient_id' => $patientId, + 'patient_name' => $patientName, + 'abha_id' => $abhaId, + 'bundle' => $bundle, + 'bundle_row' => $bundleRow, + 'visit_date' => $visitDate, + ]); + + $directResult['message'] = (string) ($directResult['message'] ?? ($directResult['ok'] ?? 0 ? 'Direct bridge push completed.' : 'Direct bridge push failed.')); + $directResult['local_direct_push'] = 1; + $directResult['csrfName'] = csrf_token(); + $directResult['csrfHash'] = csrf_hash(); + + return $this->response->setJSON([ + ...$directResult, + 'message' => '[Local fallback] ' . $directResult['message'], + ]); + } + // Avoid self-call deadlock: this request forwards the same session cookie // to an internal endpoint protected by permission filter. // Releasing session lock allows the internal request to proceed. @@ -5484,7 +5640,8 @@ class Opd_prescription extends BaseController private function resolveFhirGatewayHandoffTimeoutSec(): int { - $timeout = 120; + // Keep below PHP max_execution_time to avoid shutdown fatals that return HTML instead of JSON. + $timeout = 60; if ($this->db->tableExists('hospital_setting')) { $row = $this->db->table('hospital_setting') @@ -5500,16 +5657,332 @@ class Opd_prescription extends BaseController } } - if ($timeout < 30) { - $timeout = 30; + if ($timeout < 15) { + $timeout = 15; } - if ($timeout > 240) { - $timeout = 240; + if ($timeout > 90) { + $timeout = 90; } return $timeout; } + private function isLocalSingleThreadHandoffRisk(string $gatewayUrl): bool + { + if (PHP_SAPI !== 'cli-server') { + return false; + } + + $targetHost = strtolower((string) (parse_url($gatewayUrl, PHP_URL_HOST) ?? '')); + $targetPort = (int) (parse_url($gatewayUrl, PHP_URL_PORT) ?? 0); + $requestHostHeader = strtolower(trim((string) $this->request->getHeaderLine('Host'))); + $requestHost = $requestHostHeader; + $requestPort = 0; + + if (str_contains($requestHostHeader, ':')) { + [$requestHost, $portPart] = explode(':', $requestHostHeader, 2); + $requestPort = (int) $portPart; + } + + if ($requestPort <= 0) { + $requestPort = (int) ($this->request->getServer('SERVER_PORT') ?? 0); + } + + if ($targetHost === '' || $requestHost === '') { + return false; + } + + if ($targetPort <= 0) { + $targetPort = $requestPort; + } + + return $targetHost === strtolower($requestHost) && $targetPort === $requestPort; + } + + private function resolveLatestBridgeShareErrorForOpd(int $opdId): string + { + if ($opdId <= 0 || ! $this->db->tableExists('abdm_api_logs')) { + return ''; + } + + try { + $row = $this->db->table('abdm_api_logs') + ->select('error_message,response_json,status,created_at') + ->where('channel', 'bridge') + ->where('event_type', 'abdm.opd.prescription.share.result') + ->where('entity_type', 'opd') + ->where('entity_id', (string) $opdId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (empty($row)) { + return ''; + } + + $createdAtRaw = trim((string) ($row['created_at'] ?? '')); + if ($createdAtRaw !== '') { + $createdTs = strtotime($createdAtRaw); + if ($createdTs !== false && (time() - $createdTs) > (20 * 60)) { + // Do not surface stale bridge errors in submit alerts. + return ''; + } + } + + $error = trim((string) ($row['error_message'] ?? '')); + if ($error !== '') { + return $error; + } + + $response = json_decode((string) ($row['response_json'] ?? ''), true); + if (is_array($response)) { + $message = trim((string) ($response['message'] ?? $response['error_text'] ?? $response['error'] ?? '')); + if ($message !== '') { + return $message; + } + } + } catch (\Throwable) { + return ''; + } + + return ''; + } + + /** + * @param array $context + * @return array + */ + private function submitFhirBundleDirectBridgePush(array $context): array + { + $opdId = (int) ($context['opd_id'] ?? 0); + $sessionId = (int) ($context['opd_session_id'] ?? 0); + $patientId = (int) ($context['patient_id'] ?? 0); + $patientName = trim((string) ($context['patient_name'] ?? '')); + $abhaRaw = trim((string) ($context['abha_id'] ?? '')); + $bundle = $context['bundle'] ?? []; + $bundleRow = is_array($context['bundle_row'] ?? null) ? $context['bundle_row'] : []; + $visitDate = trim((string) ($context['visit_date'] ?? date('Y-m-d'))); + + if ($opdId <= 0 || $patientId <= 0 || ! is_array($bundle)) { + return [ + 'ok' => 0, + 'message' => 'Local fallback failed: invalid OPD/patient/bundle context.', + 'http_code' => 0, + ]; + } + + $abhaAddress = $this->isValidAbhaAddress($abhaRaw) ? $abhaRaw : ''; + $abhaDigits = preg_replace('/\D/', '', $abhaRaw); + $abhaNumber = (is_string($abhaDigits) && strlen($abhaDigits) === 14) ? $abhaDigits : ''; + + if ($abhaAddress === '' && $abhaNumber === '') { + return [ + 'ok' => 0, + 'message' => 'ABHA address or ABHA number is required for bridge push.', + 'http_code' => 0, + ]; + } + + $sessionForRef = $sessionId > 0 ? $sessionId : (int) ($bundleRow['opd_session_id'] ?? 0); + $careContextRef = 'OPD-' . $opdId . '-S' . ($sessionForRef > 0 ? $sessionForRef : 0) . '-' . $visitDate; + $careContextDisplay = 'OPD Visit - ' . $visitDate; + $bundleType = trim((string) ($bundleRow['bundle_type'] ?? 'OPConsultRecord')); + $hiType = match ($bundleType) { + 'MedicationRequestBundle', 'Prescription', 'PrescriptionRecord' => 'PrescriptionRecord', + default => 'OPConsultRecord', + }; + + if ($patientName === '') { + $patientName = 'PATIENT-' . $patientId; + } + + try { + $connector = AbdmConnectorFactory::make(); + $result = $connector->pushRecord([ + 'patient_id' => (string) $patientId, + 'patient_name' => $patientName, + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, + 'hi_type' => $hiType, + 'record_type' => $hiType, + 'visit_date' => $visitDate, + 'care_context_reference' => $careContextRef, + 'care_context_display' => $careContextDisplay, + 'notes' => $careContextDisplay, + 'queue_id' => $careContextRef, + 'record_data' => $bundle, + ]); + + $ok = (int) ($result['ok'] ?? 0) === 1 ? 1 : 0; + $queueId = (string) ($result['queue_id'] ?? ''); + $bridgeId = (int) ($result['id'] ?? $result['bridge_record_id'] ?? 0); + $httpCode = (int) ($result['http_code'] ?? 200); + $message = trim((string) ($result['message'] ?? $result['error_text'] ?? $result['error'] ?? '')); + if ($message === '') { + $message = $ok === 1 ? 'Record pushed to bridge successfully.' : 'Bridge push failed.'; + } + + $this->persistDirectFallbackSubmissionState( + $opdId, + $patientId, + $abhaAddress !== '' ? $abhaAddress : $abhaNumber, + $careContextRef, + $queueId, + $bridgeId, + $ok === 1, + [ + 'http_code' => $httpCode, + 'message' => $message, + 'raw' => $result, + ] + ); + + return [ + 'ok' => $ok, + 'queue_id' => $queueId !== '' ? $queueId : null, + 'bridge_id' => $bridgeId > 0 ? $bridgeId : null, + 'http_code' => $httpCode, + 'message' => $message, + ]; + } catch (\Throwable $e) { + return [ + 'ok' => 0, + 'message' => 'Direct bridge push failed: ' . $e->getMessage(), + 'http_code' => 0, + ]; + } + } + + /** + * @param array $responsePayload + */ + private function persistDirectFallbackSubmissionState( + int $opdId, + int $patientId, + string $abhaId, + string $careContextRef, + string $queueId, + int $bridgeId, + bool $ok, + array $responsePayload + ): void { + try { + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + if ($this->db->tableExists('health_records')) { + $fields = $this->db->getFieldNames('health_records') ?? []; + $builder = $this->db->table('health_records')->select('id'); + + if (in_array('entity_type', $fields, true) && in_array('entity_id', $fields, true)) { + $builder->where('entity_type', 'opd')->where('entity_id', (string) $opdId); + } + if ($careContextRef !== '' && in_array('care_context_reference', $fields, true)) { + $builder->where('care_context_reference', $careContextRef); + } + + $hrRow = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); + $hrId = (int) ($hrRow['id'] ?? 0); + + if ($hrId > 0) { + $update = []; + if (in_array('push_status', $fields, true)) { + $update['push_status'] = $ok ? 'queued' : 'failed'; + } + if (in_array('abdm_txn_id', $fields, true) && $queueId !== '') { + $update['abdm_txn_id'] = $queueId; + } + if (in_array('bridge_record_id', $fields, true) && $bridgeId > 0) { + $update['bridge_record_id'] = $bridgeId; + } + if (in_array('care_context_reference', $fields, true) && $careContextRef !== '') { + $update['care_context_reference'] = $careContextRef; + } + if (in_array('updated_at', $fields, true)) { + $update['updated_at'] = $now; + } + + if (! empty($update)) { + $this->db->table('health_records')->where('id', $hrId)->update($update); + } + } + } + + if ($this->db->tableExists('record_links')) { + $fields = $this->db->getFieldNames('record_links') ?? []; + $existingBuilder = $this->db->table('record_links')->select('id'); + + if ($careContextRef !== '' && in_array('care_context_reference', $fields, true)) { + $existingBuilder->where('care_context_reference', $careContextRef); + } + if ($abhaId !== '' && in_array('abha_id', $fields, true)) { + $existingBuilder->where('abha_id', $abhaId); + } + + $existing = $existingBuilder->orderBy('id', 'DESC')->get(1)->getRowArray(); + $linkUpdate = []; + + if (in_array('abha_id', $fields, true) && $abhaId !== '') { + $linkUpdate['abha_id'] = $abhaId; + } + if (in_array('abdm_txn_id', $fields, true) && $queueId !== '') { + $linkUpdate['abdm_txn_id'] = $queueId; + } + if (in_array('care_context_reference', $fields, true) && $careContextRef !== '') { + $linkUpdate['care_context_reference'] = $careContextRef; + } + if (in_array('link_status', $fields, true)) { + $linkUpdate['link_status'] = $ok ? 'pending' : 'failed'; + } + if (in_array('response_json', $fields, true)) { + $linkUpdate['response_json'] = (string) json_encode($responsePayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + if (in_array('updated_at', $fields, true)) { + $linkUpdate['updated_at'] = $now; + } + if ($ok && in_array('linked_at', $fields, true)) { + $linkUpdate['linked_at'] = null; + } + + if (! empty($existing)) { + if (! empty($linkUpdate)) { + $this->db->table('record_links')->where('id', (int) ($existing['id'] ?? 0))->update($linkUpdate); + } + } else { + if (in_array('created_at', $fields, true)) { + $linkUpdate['created_at'] = $now; + } + if (! empty($linkUpdate)) { + $this->db->table('record_links')->insert($linkUpdate); + } + } + } + + if ($this->db->tableExists('abdm_api_logs')) { + $this->db->table('abdm_api_logs')->insert([ + 'channel' => 'bridge', + 'event_type' => 'abdm.opd.prescription.share.result', + 'endpoint' => '/Opd_prescription/fhir_bundle_submit(local_fallback)', + 'http_method' => 'POST', + 'entity_type' => 'opd', + 'entity_id' => (string) $opdId, + 'request_json' => (string) json_encode([ + 'opd_id' => $opdId, + 'patient_id' => $patientId, + 'care_context_reference' => $careContextRef, + 'mode' => 'local_direct_push', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_code' => (int) ($responsePayload['http_code'] ?? 200), + 'response_json' => (string) json_encode($responsePayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => $ok ? 'success' : 'error', + 'error_message' => $ok ? null : mb_substr((string) ($responsePayload['message'] ?? 'bridge push failed'), 0, 1000), + 'created_at' => $now, + ]); + } + } catch (\Throwable $e) { + log_message('warning', '[fhir_bundle_submit] local fallback state sync failed for OPD#' . $opdId . ': ' . $e->getMessage()); + } + } + private function isTransientFhirGatewayHandoffError(\Throwable $e): bool { $msg = strtolower(trim($e->getMessage())); @@ -10208,6 +10681,81 @@ class Opd_prescription extends BaseController return null; } + /** + * Resolve best-available ABHA value for FHIR Patient.identifier. + * + * Preference order: + * 1) Valid ABHA Address from abha_address. + * 2) Valid ABHA Address stored in legacy ABHA columns. + * 3) 14-digit ABHA number from any ABHA column. + * 4) First non-empty ABHA-like value as last fallback. + * + * @param array $patientRow + */ + private function resolvePatientAbhaForFhir(array $patientRow): string + { + $resolved = $this->resolvePatientAbhaIdentifiersForFhir($patientRow); + if ($resolved['address'] !== '') { + return $resolved['address']; + } + if ($resolved['id'] !== '') { + return $resolved['id']; + } + + return ''; + } + + /** + * Resolve ABHA identity values separately for FHIR identifiers. + * + * @param array $patientRow + * @return array{id:string,address:string} + */ + private function resolvePatientAbhaIdentifiersForFhir(array $patientRow): array + { + $fields = $this->db->tableExists('patient_master') + ? ($this->db->getFieldNames('patient_master') ?? []) + : []; + + $address = ''; + $abhaId = ''; + + if (in_array('abha_address', $fields, true)) { + $candidate = trim((string) ($patientRow['abha_address'] ?? '')); + if ($candidate !== '' && $this->isValidAbhaAddress($candidate)) { + $address = $candidate; + } + } + + foreach (['abha_id', 'abha_no', 'abha', 'abha_number'] as $field) { + if (! in_array($field, $fields, true)) { + continue; + } + $value = trim((string) ($patientRow[$field] ?? '')); + if ($value === '') { + continue; + } + + if ($address === '' && $this->isValidAbhaAddress($value)) { + $address = $value; + } + + $digits = preg_replace('/\D/', '', $value); + if ($abhaId === '' && is_string($digits) && strlen($digits) === 14) { + $abhaId = $digits; + } + } + + if ($abhaId === '' && in_array('abha_address', $fields, true)) { + $digits = preg_replace('/\D/', '', trim((string) ($patientRow['abha_address'] ?? ''))); + if (is_string($digits) && strlen($digits) === 14) { + $abhaId = $digits; + } + } + + return ['id' => $abhaId, 'address' => $address]; + } + /** * @return array */ @@ -10849,8 +11397,7 @@ class Opd_prescription extends BaseController return false; } - $abhaField = $this->resolvePatientAbhaField(); - $abhaAddress = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : ''; + $abhaIdentity = $this->resolvePatientAbhaIdentifiersForFhir($patientRow); $patFirstName = $this->sanitizePersonNamePart((string) ($patientRow['p_fname'] ?? '')); $patLastName = $this->sanitizePersonNamePart((string) ($patientRow['p_lname'] ?? '')); @@ -10871,7 +11418,8 @@ class Opd_prescription extends BaseController 'family_name' => $patLastName, 'gender' => $this->normalizeGender((string) ($patientRow['gender'] ?? '')), 'birthDate' => ! empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '', - 'abhaAddress' => $abhaAddress, + 'abhaAddress' => $abhaIdentity['address'], + 'abhaId' => $abhaIdentity['id'], 'phone' => $patPhone, ]; @@ -11018,6 +11566,7 @@ class Opd_prescription extends BaseController 'dosage' => implode(' | ', $dosageParts), 'route_text' => trim((string) ($row['dosage_where'] ?? '')), 'frequency_text' => trim((string) ($row['dosage_freq'] ?? '')), + 'no_of_days' => (int) ($row['no_of_days'] ?? 0), 'med_id' => $mid, 'generic_name' => $genericName, 'med_type' => $medType, @@ -11377,9 +11926,8 @@ class Opd_prescription extends BaseController { $errors = []; - $abhaField = $this->resolvePatientAbhaField(); - $abha = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : ''; - if ($abha === '') { + $abha = $this->resolvePatientAbhaIdentifiersForFhir($patientRow); + if (trim((string) ($abha['address'] ?? '')) === '') { $errors[] = 'ABDM requires ABHA Address before saving consult.'; } diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index 317215b..19f2647 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -18,8 +18,6 @@ namespace App\Libraries\Abdm; */ class EAtriaBridgeConnector implements AbdmConnectorInterface { - private const REQUIRED_HFR_ID = 'IN0510000870'; - private string $baseUrl; private string $token; private string $hfrId; @@ -42,7 +40,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $this->tokenSourceByValue[$this->token] = 'config.eatriaBridgeToken'; $this->tokenSource = 'config.eatriaBridgeToken'; } - $this->hfrId = self::REQUIRED_HFR_ID; + $this->hfrId = ''; $this->bridgeHospitalId = ''; $this->timeoutSec = (int) ($config->eatriaBridgeTimeoutSec ?? 30); @@ -135,10 +133,11 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface ?? $dbSettings['ABDM_HOSPITAL_HFR_ID'] ?? '' )); - if ($dbHfrId !== '' && strcasecmp($dbHfrId, self::REQUIRED_HFR_ID) !== 0) { - log_message('warning', '[EAtriaBridge] Overriding configured HFR ID with mandated HFR ' . self::REQUIRED_HFR_ID . ' (found ' . $dbHfrId . ')'); + if ($dbHfrId === '') { + log_message('warning', '[EAtriaBridge] ABDM_HFR_ID is empty in hospital_setting; bridge requests may fail with hfr_id errors.'); + } else { + $this->hfrId = $dbHfrId; } - $this->hfrId = self::REQUIRED_HFR_ID; $dbBridgeHospitalId = trim((string) ( $dbSettings['ABDM_BRIDGE_HOSPITAL_ID'] @@ -424,7 +423,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface } $this->dbLog($method, $path, $url, $body, $httpCode, (string) $raw, $ok === 1 ? 'success' : 'error', $logErr); if (($httpCode === 401 || $httpCode === 403) && ! isset($decoded['auth_hint'])) { - $decoded['auth_hint'] = 'Verify hospital_setting.EATRIA_BRIDGE_TOKEN mapped to HFR IN0510000870. Current token source=' . $this->tokenSource . ', HFR=' . ($this->hfrId !== '' ? $this->hfrId : '(empty)'); + $decoded['auth_hint'] = 'Verify hospital_setting.EATRIA_BRIDGE_TOKEN is mapped to the same HFR as hospital_setting.ABDM_HFR_ID. Current token source=' . $this->tokenSource . ', HFR=' . ($this->hfrId !== '' ? $this->hfrId : '(empty)'); } $decoded['auth_debug'] = $authDebug; @@ -827,6 +826,30 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface return $this->post('/v3/records/' . $bridgeId . '/link-and-share', $body); } + /** + * Retry variant for stale link-token scenarios after HFR/HIP changes. + * Sends explicit regeneration hints; gateway may ignore unknown flags safely. + */ + public function linkAndShareWithFreshToken(int $bridgeId): array + { + $body = [ + 'regenerate_link_token' => true, + 'force_regenerate_link_token' => true, + 'refresh_link_token' => true, + ]; + + if ($this->hfrId !== '') { + $body['hfr_id'] = $this->hfrId; + } + + $result = $this->post('/v3/records/' . $bridgeId . '/link-and-share', $body); + if (is_array($result)) { + $result['retry_mode'] = 'fresh_link_token'; + } + + return $result; + } + public function workflowStatus(int $bridgeId): array { $query = []; diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index e42df43..90cd11a 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -356,6 +356,9 @@ class FhirR4Builder $snomedCode = trim((string) ($medication['snomed_code'] ?? '')); $atcCode = strtoupper(trim((string) ($medication['atc_code'] ?? ''))); + if ($snomedCode === '' && $atcCode === '') { + $atcCode = $this->inferAtcCodeFromMedicationText($displayText, $drugName, $genericName); + } if ($snomedCode !== '') { $medicationCodeableConcept['coding'] = [[ 'system' => 'http://snomed.info/sct', @@ -370,7 +373,36 @@ class FhirR4Builder ]]; } - $dosageInstruction = ['text' => (string) ($medication['dosage'] ?? '')]; + $dosageText = trim((string) ($medication['dosage'] ?? '')); + $dosageInstruction = []; + if ($dosageText !== '') { + $dosageInstruction['text'] = $dosageText; + } + + $repeat = []; + $frequency = $this->resolveFrequencyPerDayFromText((string) ($medication['frequency_text'] ?? '')); + if ($frequency === null && $dosageText !== '') { + $frequency = $this->resolveFrequencyPerDayFromText($dosageText); + } + if ($frequency !== null) { + $repeat['frequency'] = $frequency; + $repeat['period'] = 1; + $repeat['periodUnit'] = 'd'; + } + + $durationDays = (int) ($medication['no_of_days'] ?? 0); + if ($durationDays <= 0 && $dosageText !== '') { + $durationDays = $this->extractDaysFromDosageText($dosageText); + } + if ($durationDays > 0) { + $repeat['duration'] = $durationDays; + $repeat['durationUnit'] = 'd'; + } + + if (! empty($repeat)) { + $dosageInstruction['timing'] = ['repeat' => $repeat]; + } + $routeText = trim((string) ($medication['route_text'] ?? '')); if ($routeText !== '') { $routeEntry = ['text' => $routeText]; @@ -411,7 +443,7 @@ class FhirR4Builder 'medicationCodeableConcept' => $medicationCodeableConcept, 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], 'encounter' => ['reference' => $encounterRef], - 'authoredOn' => $issuedAt, + 'authoredOn' => $this->normalizeIsoDateTime((string) ($medication['authored_on'] ?? ''), $issuedAt), 'dosageInstruction' => [$dosageInstruction], ]; if ($practitionerRef !== '') { @@ -1462,6 +1494,19 @@ class FhirR4Builder 'name' => [$nameEntry], ]; + $localPatientId = trim((string) ($patient['id'] ?? '')); + if ($localPatientId !== '') { + $resource['identifier'] = [[ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'MR', + 'display' => 'Medical record number', + ]]], + 'system' => 'https://hms.local/patient-id', + 'value' => $localPatientId, + ]]; + } + $gender = $this->normalizeAdministrativeGender($patient['gender'] ?? null); if ($gender !== '') { $resource['gender'] = $gender; @@ -1471,11 +1516,44 @@ class FhirR4Builder $resource['birthDate'] = (string) $patient['birthDate']; } - $abhaAddress = trim((string) ($patient['abhaAddress'] ?? '')); - if ($abhaAddress !== '') { + $abhaAddress = trim((string) ($patient['abhaAddress'] ?? ($patient['abha_address'] ?? ''))); + $abhaIdRaw = trim((string) ($patient['abhaId'] ?? ($patient['abha_id'] ?? ''))); + $abhaIdDigits = preg_replace('/\D/', '', $abhaIdRaw); + $abhaId = is_string($abhaIdDigits) && strlen($abhaIdDigits) === 14 ? $abhaIdDigits : ''; + + if ($abhaAddress !== '' || $abhaId !== '') { $resource['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient']]; // NI = National unique individual identifier (ABHA number / address) - $resource['identifier'] = [[ + if (! isset($resource['identifier']) || ! is_array($resource['identifier'])) { + $resource['identifier'] = []; + } + + if ($abhaId !== '') { + $resource['identifier'][] = [ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'NI', + 'display' => 'National unique individual identifier', + ]]], + 'system' => 'https://healthid.ndhm.gov.in', + 'value' => $abhaId, + ]; + } + + if ($abhaAddress !== '' && strcasecmp($abhaAddress, $abhaId) !== 0) { + $resource['identifier'][] = [ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'NI', + 'display' => 'National unique individual identifier', + ]]], + 'system' => 'https://healthid.ndhm.gov.in', + 'value' => $abhaAddress, + ]; + } + + if (empty($resource['identifier'])) { + $resource['identifier'][] = [ 'type' => ['coding' => [[ 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'NI', @@ -1483,7 +1561,8 @@ class FhirR4Builder ]]], 'system' => 'https://healthid.ndhm.gov.in', 'value' => $abhaAddress, - ]]; + ]; + } } // Phone / mobile telecom @@ -1572,6 +1651,96 @@ class FhirR4Builder return $map[$key] ?? ''; } + /** + * Best-effort ATC inference from medicine text when master coding is missing. + */ + private function inferAtcCodeFromMedicationText(string ...$texts): string + { + $haystack = strtolower(trim(implode(' ', $texts))); + if ($haystack === '') { + return ''; + } + + $map = [ + // Common OPD examples and analgesics + 'paracetamol' => 'N02BE01', + 'acetaminophen' => 'N02BE01', + 'aspirin' => 'B01AC06', + 'acetylsalicylic' => 'B01AC06', + 'disprin' => 'B01AC06', + // Acid suppression + 'ranitidine' => 'A02BA02', + 'aciloc' => 'A02BA02', + 'pantoprazole' => 'A02BC02', + 'omeprazole' => 'A02BC01', + // Frequent antibiotics + 'azithromycin' => 'J01FA10', + 'amoxicillin' => 'J01CA04', + 'cefixime' => 'J01DD08', + // Antihistamines + 'cetirizine' => 'R06AE07', + 'levocetirizine' => 'R06AE09', + ]; + + foreach ($map as $needle => $code) { + if (str_contains($haystack, $needle)) { + return $code; + } + } + + return ''; + } + + /** + * Infer per-day dosage frequency from common OPD text labels. + */ + private function resolveFrequencyPerDayFromText(string $text): ?int + { + $value = strtolower(trim($text)); + if ($value === '') { + return null; + } + + if (preg_match('/\b(qid|q\.i\.d|four\s*times?)\b/', $value) === 1) { + return 4; + } + if (preg_match('/\b(tds|tid|t\.d\.s|t\.i\.d|three\s*times?)\b/', $value) === 1) { + return 3; + } + if (preg_match('/\b(bd|bid|b\.d|b\.i\.d|two\s*times?|twice)\b/', $value) === 1) { + return 2; + } + if (preg_match('/\b(od|qd|o\.d|q\.d|once\s*daily|daily|once)\b/', $value) === 1) { + return 1; + } + + if (preg_match('/\b([1-9])\s*(x|times?)\b/', $value, $m) === 1) { + return (int) ($m[1] ?? 0) ?: null; + } + + if (preg_match('/\b([1-9])\s*\/\s*day\b/', $value, $m) === 1) { + return (int) ($m[1] ?? 0) ?: null; + } + + return null; + } + + /** + * Extract course duration in days from dosage text (e.g. "for 5 days"). + */ + private function extractDaysFromDosageText(string $text): int + { + if (preg_match('/\bfor\s+(\d{1,3})\s*day(?:s)?\b/i', $text, $m) === 1) { + return max(0, (int) ($m[1] ?? 0)); + } + + if (preg_match('/\b(\d{1,3})\s*day(?:s)?\b/i', $text, $m) === 1) { + return max(0, (int) ($m[1] ?? 0)); + } + + return 0; + } + // ========================================================================= // Enhanced FHIR Bundle Builders (ABDM M3 Compliant) // ========================================================================= @@ -2087,6 +2256,10 @@ class FhirR4Builder $mc = ['text' => $drugName]; $snomedCode = is_array($med) ? trim((string) ($med['snomed_code'] ?? '')) : ''; $atcCode = is_array($med) ? strtoupper(trim((string) ($med['atc_code'] ?? ''))) : ''; + if ($snomedCode === '' && $atcCode === '') { + $generic = is_array($med) ? trim((string) ($med['generic_name'] ?? '')) : ''; + $atcCode = $this->inferAtcCodeFromMedicationText($drugName, $generic); + } if ($snomedCode !== '') { $mc['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $drugName]]; } elseif ($atcCode !== '') {