Revert "ABDM: fix local submit fallback, HFR runtime usage, status sync, and link-token retry"

This reverts commit e08c18f5b2.
This commit is contained in:
DevSoftTech Server 2026-06-30 18:11:34 +05:30
parent e08c18f5b2
commit 3072639cd5
5 changed files with 39 additions and 850 deletions

View File

@ -983,11 +983,10 @@ 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'));
$pushToGatewayRequested = (int) $this->request->getPost('push_to_gateway') === 1;
$pushToGateway = $pushToGatewayRequested || $this->isGatewayPushForcedByDeployment();
$pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1;
$now = Time::now('Asia/Kolkata')->toDateTimeString();
$logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGatewayRequested, $pushToGateway): void {
$logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGateway): void {
if (! $this->db->tableExists('abdm_api_logs')) {
return;
}
@ -1002,7 +1001,6 @@ 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,
@ -2966,24 +2964,6 @@ 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);
@ -2991,35 +2971,6 @@ class AbdmGateway extends BaseController
return $this->response->setJSON($result);
}
/**
* @param array<string,mixed> $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.
// =========================================================================
@ -3711,24 +3662,6 @@ 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 === '') {

View File

@ -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 discovery/fetch workflow completion (callback or bridge sync).';
$statusNote = 'Waiting for ABDM discovery and consent fetch callbacks.';
} elseif ($linkStatus === 'failed') {
$statusLabel = 'Link Failed';
$statusTone = 'danger';

View File

@ -3,7 +3,6 @@
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Libraries\Abdm\AbdmConnectorFactory;
use App\Libraries\AbdmWorkTaskService;
use App\Libraries\BridgeSyncService;
use App\Libraries\CsnotkTerminologyService;
@ -4724,7 +4723,8 @@ class Opd_prescription extends BaseController
try {
// Rebuild bundle using current builder
$abhaIdentity = $this->resolvePatientAbhaIdentifiersForFhir($patientRow);
$abhaField = $this->resolvePatientAbhaField();
$abhaAddress = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : '';
$patFirstName = $this->sanitizePersonNamePart((string) ($patientRow['p_fname'] ?? ''));
$patLastName = $this->sanitizePersonNamePart((string) ($patientRow['p_lname'] ?? ''));
@ -4744,8 +4744,7 @@ 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' => $abhaIdentity['address'],
'abhaId' => $abhaIdentity['id'],
'abhaAddress' => $abhaAddress,
'phone' => $patPhone,
];
@ -4861,56 +4860,6 @@ 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 = [];
@ -4955,13 +4904,6 @@ 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,
]);
@ -5146,13 +5088,10 @@ class Opd_prescription extends BaseController
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>Bundle type is "document"</span></div>'
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>First entry is Composition</span></div>'
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>Patient resource present</span></div>'
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>Patient has at least one identifier</span></div>'
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>At least one clinical resource</span></div>'
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>MedicationRequest entries are coded (if present)</span></div>'
. '<div class="check-item pending"><i class="bi bi-circle text-muted"></i><span>Visit date valid</span></div>'
. '<div class="check-item pending" id="chkBridge"><i class="bi bi-circle text-muted"></i><span>Bridge connection reachable</span></div>'
. '</div>'
. '<div id="strictIssues" class="small mt-2"></div>'
. '<button class="btn btn-primary w-100 mt-3" id="btnVerify">'
. '<i class="bi bi-shield-check me-1"></i> Verify Data'
. '</button>'
@ -5199,8 +5138,6 @@ class Opd_prescription extends BaseController
. ' const btn=this;'
. ' btn.disabled=true;btn.innerHTML=\'<span class="spinner-border spinner-border-sm me-1"></span>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?\'<i class="bi bi-check-circle-fill text-success"></i>\':\'<i class="bi bi-x-circle-fill text-danger"></i>\')+\'<span>\'+msg+\'</span>\';'
@ -5223,27 +5160,16 @@ 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: 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
// Check 5: Clinical resource
. ' const clinTypes=["MedicationRequest","Condition","Observation","AllergyIntolerance","DiagnosticReport"];'
. ' const clinCount=(BUNDLE.entry||[]).filter(e=>e.resource&&clinTypes.includes(e.resource.resourceType)).length;'
. ' setCheck(5,clinCount>0,"Clinical resources: "+clinCount+(clinCount>0?" present":" — bundle has no medications/conditions/observations"));'
. ' setCheck(4,clinCount>0,"Clinical resources: "+clinCount+(clinCount>0?" present":" — bundle has no medications/conditions/observations"));'
// 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
// Check 6: visit date
. ' const dateOk=CTX.visitDate&&/^\d{4}-\d{2}-\d{2}$/.test(CTX.visitDate);'
. ' setCheck(7,dateOk,dateOk?"Visit date: "+CTX.visitDate:"Visit date missing or invalid");'
. ' setCheck(5,dateOk,dateOk?"Visit date: "+CTX.visitDate:"Visit date missing or invalid");'
// Check 9: Bridge connectivity (AJAX)
// Check 7: Bridge connectivity (AJAX)
. ' document.getElementById("chkBridge").className="check-item pending";'
. ' document.getElementById("chkBridge").innerHTML=\'<span class="spinner-border spinner-border-sm"></span><span>Checking bridge\u2026</span>\';'
. ' let bridgeOk=false;'
@ -5252,11 +5178,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(8,bridgeOk,bridgeOk?"Bridge connected"+info:"Bridge unreachable"+info);'
. ' }catch(e){setCheck(8,false,"Bridge check failed: "+e.message);}'
. ' setCheck(6,bridgeOk,bridgeOk?"Bridge connected"+info:"Bridge unreachable"+info);'
. ' }catch(e){setCheck(6,false,"Bridge check failed: "+e.message);}'
// Evaluate overall result
. ' const allPass=hasAbha&&isDoc&&hasPatient&&patientHasIdentifier&&clinCount>0&&medStructuredOk&&dateOk&&bridgeOk;'
. ' const allPass=hasAbha&&isDoc&&isComp&&hasPatient&&clinCount>0&&dateOk&&bridgeOk;'
. ' btn.disabled=false;'
. ' if(allPass){'
. ' btn.className="btn btn-outline-success w-100 mt-3";'
@ -5267,10 +5193,6 @@ class Opd_prescription extends BaseController
. ' btn.className="btn btn-warning w-100 mt-3";'
. ' btn.innerHTML=\'<i class="bi bi-exclamation-triangle me-1"></i>Issues Found — Fix &amp; 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=\'<div class="alert alert-warning py-2 mt-2 mb-0"><strong>Strict checks:</strong> \'+issues.join("; ")+\'</div>\';}'
. ' }'
. '});'
@ -5302,11 +5224,7 @@ class Opd_prescription extends BaseController
. ' el.innerHTML=\'<div class="alert alert-success mt-2 py-2">\u2705 \'+(res.message||"Submitted successfully.")+qPart+idPart+\'</div>\';'
. ' }else{'
. ' btn.disabled=false;btn.innerHTML=origHtml;'
. ' let strictText="";'
. ' if(res.bundle_checks){'
. ' strictText="<div class=\"small mt-1\">Server strict checks failed. Please run Verify Data and fix highlighted items.</div>";'
. ' }'
. ' el.innerHTML=\'<div class="alert alert-danger mt-2 py-2">\u274c \'+(res.message||res.error_text||"Submission failed.")+(res.http_code?" (HTTP "+res.http_code+")":"")+strictText+\'</div>\';'
. ' el.innerHTML=\'<div class="alert alert-danger mt-2 py-2">\u274c \'+(res.message||res.error_text||"Submission failed.")+(res.http_code?" (HTTP "+res.http_code+")":"")+\'</div>\';'
. ' }'
. ' }).catch(e=>{'
. ' clearTimeout(submitTimer);'
@ -5387,55 +5305,6 @@ 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 = '';
@ -5497,31 +5366,6 @@ 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.
@ -5640,8 +5484,7 @@ class Opd_prescription extends BaseController
private function resolveFhirGatewayHandoffTimeoutSec(): int
{
// Keep below PHP max_execution_time to avoid shutdown fatals that return HTML instead of JSON.
$timeout = 60;
$timeout = 120;
if ($this->db->tableExists('hospital_setting')) {
$row = $this->db->table('hospital_setting')
@ -5657,332 +5500,16 @@ class Opd_prescription extends BaseController
}
}
if ($timeout < 15) {
$timeout = 15;
if ($timeout < 30) {
$timeout = 30;
}
if ($timeout > 90) {
$timeout = 90;
if ($timeout > 240) {
$timeout = 240;
}
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<string,mixed> $context
* @return array<string,mixed>
*/
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<string,mixed> $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()));
@ -10681,81 +10208,6 @@ 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<string,mixed> $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<string,mixed> $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<string, int>
*/
@ -11397,7 +10849,8 @@ class Opd_prescription extends BaseController
return false;
}
$abhaIdentity = $this->resolvePatientAbhaIdentifiersForFhir($patientRow);
$abhaField = $this->resolvePatientAbhaField();
$abhaAddress = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : '';
$patFirstName = $this->sanitizePersonNamePart((string) ($patientRow['p_fname'] ?? ''));
$patLastName = $this->sanitizePersonNamePart((string) ($patientRow['p_lname'] ?? ''));
@ -11418,8 +10871,7 @@ 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' => $abhaIdentity['address'],
'abhaId' => $abhaIdentity['id'],
'abhaAddress' => $abhaAddress,
'phone' => $patPhone,
];
@ -11566,7 +11018,6 @@ 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,
@ -11926,8 +11377,9 @@ class Opd_prescription extends BaseController
{
$errors = [];
$abha = $this->resolvePatientAbhaIdentifiersForFhir($patientRow);
if (trim((string) ($abha['address'] ?? '')) === '') {
$abhaField = $this->resolvePatientAbhaField();
$abha = $abhaField !== null ? trim((string) ($patientRow[$abhaField] ?? '')) : '';
if ($abha === '') {
$errors[] = 'ABDM requires ABHA Address before saving consult.';
}

View File

@ -18,6 +18,8 @@ namespace App\Libraries\Abdm;
*/
class EAtriaBridgeConnector implements AbdmConnectorInterface
{
private const REQUIRED_HFR_ID = 'IN0510000870';
private string $baseUrl;
private string $token;
private string $hfrId;
@ -40,7 +42,7 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
$this->tokenSourceByValue[$this->token] = 'config.eatriaBridgeToken';
$this->tokenSource = 'config.eatriaBridgeToken';
}
$this->hfrId = '';
$this->hfrId = self::REQUIRED_HFR_ID;
$this->bridgeHospitalId = '';
$this->timeoutSec = (int) ($config->eatriaBridgeTimeoutSec ?? 30);
@ -133,11 +135,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
?? $dbSettings['ABDM_HOSPITAL_HFR_ID']
?? ''
));
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;
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 . ')');
}
$this->hfrId = self::REQUIRED_HFR_ID;
$dbBridgeHospitalId = trim((string) (
$dbSettings['ABDM_BRIDGE_HOSPITAL_ID']
@ -423,7 +424,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 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_hint'] = 'Verify hospital_setting.EATRIA_BRIDGE_TOKEN mapped to HFR IN0510000870. Current token source=' . $this->tokenSource . ', HFR=' . ($this->hfrId !== '' ? $this->hfrId : '(empty)');
}
$decoded['auth_debug'] = $authDebug;
@ -826,30 +827,6 @@ 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 = [];

View File

@ -356,9 +356,6 @@ 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',
@ -373,36 +370,7 @@ class FhirR4Builder
]];
}
$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];
}
$dosageInstruction = ['text' => (string) ($medication['dosage'] ?? '')];
$routeText = trim((string) ($medication['route_text'] ?? ''));
if ($routeText !== '') {
$routeEntry = ['text' => $routeText];
@ -443,7 +411,7 @@ class FhirR4Builder
'medicationCodeableConcept' => $medicationCodeableConcept,
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'encounter' => ['reference' => $encounterRef],
'authoredOn' => $this->normalizeIsoDateTime((string) ($medication['authored_on'] ?? ''), $issuedAt),
'authoredOn' => $issuedAt,
'dosageInstruction' => [$dosageInstruction],
];
if ($practitionerRef !== '') {
@ -1494,19 +1462,6 @@ 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;
@ -1516,44 +1471,11 @@ class FhirR4Builder
$resource['birthDate'] = (string) $patient['birthDate'];
}
$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 !== '') {
$abhaAddress = trim((string) ($patient['abhaAddress'] ?? ''));
if ($abhaAddress !== '') {
$resource['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient']];
// NI = National unique individual identifier (ABHA number / address)
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'][] = [
$resource['identifier'] = [[
'type' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
'code' => 'NI',
@ -1561,8 +1483,7 @@ class FhirR4Builder
]]],
'system' => 'https://healthid.ndhm.gov.in',
'value' => $abhaAddress,
];
}
]];
}
// Phone / mobile telecom
@ -1651,96 +1572,6 @@ 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)
// =========================================================================
@ -2256,10 +2087,6 @@ 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 !== '') {