diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 05bb7e4..d5e0199 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -1451,31 +1451,28 @@ class AbdmGateway extends BaseController 'queue_id' => $careContextRef, 'record_data' => $bundle, ]); + $this->logGatewayPushResolution('opd', $result); - $queueId = (string) ($result['queue_id'] ?? ''); - $bridgeRecordId = (int) ($result['id'] ?? 0); + $queueId = $this->extractGatewayPushQueueId($result); + $bridgeRecordId = $this->extractGatewayPushRecordId($result); - $httpCode = (int) ($result['http_code'] ?? 0); - $resultOk = (int) ($result['ok'] ?? 0) === 1; - $errorCode = strtoupper(trim((string) ($result['error_code'] ?? ''))); - $isDuplicate = $httpCode === 409 || $errorCode === 'DUPLICATE_RECORD'; + $httpCode = $this->extractGatewayPushHttpCode($result); + $resultOk = $this->isGatewayPushSubmitted($result); + $isDuplicate = $this->isGatewayPushDuplicate($result); if ($isDuplicate) { - $bridgeRecordId = (int) ($result['existing_record_id'] ?? $bridgeRecordId); $resultOk = true; $status = 'duplicate'; } if (! $resultOk) { - $connectorError = trim((string) ( - $result['message'] - ?? $result['error_text'] - ?? ($result['error_code'] ?? '') - )); + $connectorError = $this->extractGatewayPushErrorText($result); if ($connectorError === '') { $connectorError = 'Bridge push failed'; } $status = 'failed'; + } elseif (! $isDuplicate) { + $status = 'queued'; } } catch (\Throwable $e) { $connectorError = $e->getMessage(); @@ -1628,8 +1625,15 @@ class AbdmGateway extends BaseController 'queue_id' => 'IPD-' . $ipdId . '-' . $visitDate, 'record_data' => $bundle, ]); - $queueId = (string) ($result['queue_id'] ?? ''); - $bridgeRecordId = (int) ($result['id'] ?? 0); + $this->logGatewayPushResolution('ipd_discharge', $result); + $queueId = $this->extractGatewayPushQueueId($result); + $bridgeRecordId = $this->extractGatewayPushRecordId($result); + if (! $this->isGatewayPushSubmitted($result)) { + $connectorError = $this->extractGatewayPushErrorText($result); + if ($connectorError === '') { + $connectorError = 'Bridge push failed'; + } + } } catch (\Throwable $e) { $connectorError = $e->getMessage(); } @@ -1861,8 +1865,15 @@ class AbdmGateway extends BaseController 'queue_id' => 'LAB-' . $labReqId . '-' . $visitDate, 'record_data' => $bundle, ]); - $queueId = (string) ($result['queue_id'] ?? ''); - $bridgeRecordId = (int) ($result['id'] ?? 0); + $this->logGatewayPushResolution('diagnostic_report', $result); + $queueId = $this->extractGatewayPushQueueId($result); + $bridgeRecordId = $this->extractGatewayPushRecordId($result); + if (! $this->isGatewayPushSubmitted($result)) { + $connectorError = $this->extractGatewayPushErrorText($result); + if ($connectorError === '') { + $connectorError = 'Bridge push failed'; + } + } } catch (\Throwable $e) { $connectorError = $e->getMessage(); } @@ -2160,25 +2171,13 @@ class AbdmGateway extends BaseController 'queue_id' => $ccRef, 'record_data' => $bundle, ]); + $this->logGatewayPushResolution('immunization', $result); $bridgeResponse = $result; - $queueId = (string) ($result['queue_id'] ?? $result['existing_queue_id'] ?? ''); - $bridgeRecordId = (int) ($result['record_id'] ?? $result['id'] ?? $result['existing_record_id'] ?? 0); + $queueId = $this->extractGatewayPushQueueId($result); + $bridgeRecordId = $this->extractGatewayPushRecordId($result); - $httpCode = (int) ($result['http_code'] ?? 0); - $resultOk = (int) ($result['ok'] ?? 0) === 1; - $errorCode = strtoupper(trim((string) ($result['error_code'] ?? ''))); - $isDuplicate = $httpCode === 409 || $errorCode === 'DUPLICATE_RECORD'; - - if ($isDuplicate) { - $resultOk = true; - } - - if (! $resultOk) { - $connectorError = trim((string) ( - $result['message'] - ?? $result['error_text'] - ?? ($result['error_code'] ?? '') - )); + if (! $this->isGatewayPushSubmitted($result)) { + $connectorError = $this->extractGatewayPushErrorText($result); if ($connectorError === '' && ! empty($result['errors'])) { $connectorError = (string) json_encode($result['errors'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } @@ -3363,8 +3362,15 @@ class AbdmGateway extends BaseController 'queue_id' => trim((string) ($hr['care_context_reference'] ?? '')), 'record_data' => $fhirBundle, ]); - $queueId = $result['queue_id'] ?? null; - $bridgeRecordId = (int) ($result['id'] ?? 0); + $this->logGatewayPushResolution('health_record_repush', $result); + $queueId = $this->extractGatewayPushQueueId($result); + $bridgeRecordId = $this->extractGatewayPushRecordId($result); + if (! $this->isGatewayPushSubmitted($result)) { + $connectorErr = $this->extractGatewayPushErrorText($result); + if ($connectorErr === '') { + $connectorErr = 'Bridge push failed'; + } + } } catch (\Throwable $e) { $connectorErr = $e->getMessage(); $bridgeRecordId = 0; @@ -4740,6 +4746,209 @@ class AbdmGateway extends BaseController } } + /** + * @param array $result + */ + private function extractGatewayPushRecordMeta(array $result): array + { + $candidates = [ + 'record_id' => $result['record_id'] ?? null, + 'existing_record_id' => $result['existing_record_id'] ?? null, + 'id' => $result['id'] ?? null, + 'response.record_id' => $result['response']['record_id'] ?? null, + 'response.existing_record_id' => $result['response']['existing_record_id'] ?? null, + 'response.id' => $result['response']['id'] ?? null, + 'data.record_id' => $result['data']['record_id'] ?? null, + 'data.existing_record_id' => $result['data']['existing_record_id'] ?? null, + 'data.id' => $result['data']['id'] ?? null, + ]; + + foreach ($candidates as $source => $value) { + $id = (int) $value; + if ($id > 0) { + return ['id' => $id, 'source' => $source]; + } + } + + return ['id' => 0, 'source' => 'none']; + } + + /** + * @param array $result + */ + private function extractGatewayPushRecordId(array $result): int + { + $meta = $this->extractGatewayPushRecordMeta($result); + return (int) ($meta['id'] ?? 0); + } + + /** + * @param array $result + */ + private function extractGatewayPushQueueMeta(array $result): array + { + $candidates = [ + 'queue_id' => $result['queue_id'] ?? null, + 'existing_queue_id' => $result['existing_queue_id'] ?? null, + 'response.queue_id' => $result['response']['queue_id'] ?? null, + 'response.existing_queue_id' => $result['response']['existing_queue_id'] ?? null, + 'data.queue_id' => $result['data']['queue_id'] ?? null, + 'data.existing_queue_id' => $result['data']['existing_queue_id'] ?? null, + ]; + + foreach ($candidates as $source => $value) { + $queueId = trim((string) $value); + if ($queueId !== '') { + return ['id' => $queueId, 'source' => $source]; + } + } + + return ['id' => '', 'source' => 'none']; + } + + /** + * @param array $result + */ + private function extractGatewayPushQueueId(array $result): string + { + $meta = $this->extractGatewayPushQueueMeta($result); + return (string) ($meta['id'] ?? ''); + } + + /** + * @param array $result + */ + private function extractGatewayPushHttpCode(array $result): int + { + $candidates = [ + $result['http_code'] ?? null, + $result['response']['http_code'] ?? null, + $result['data']['http_code'] ?? null, + ]; + + foreach ($candidates as $value) { + $httpCode = (int) $value; + if ($httpCode > 0) { + return $httpCode; + } + } + + return 0; + } + + /** + * @param array $result + */ + private function isGatewayPushDuplicate(array $result): bool + { + $httpCode = $this->extractGatewayPushHttpCode($result); + if ($httpCode === 409) { + return true; + } + + $codes = [ + $result['error_code'] ?? null, + $result['response']['error_code'] ?? null, + $result['data']['error_code'] ?? null, + ]; + foreach ($codes as $code) { + if (strtoupper(trim((string) $code)) === 'DUPLICATE_RECORD') { + return true; + } + } + + return false; + } + + /** + * HTTP 201 and HTTP 409 (duplicate) are treated as submitted success states. + * + * @param array $result + */ + private function isGatewayPushSubmitted(array $result): bool + { + $httpCode = $this->extractGatewayPushHttpCode($result); + if ($httpCode === 201 || $httpCode === 409) { + return true; + } + + if ($this->isGatewayPushDuplicate($result)) { + return true; + } + + $okValues = [ + $result['ok'] ?? null, + $result['response']['ok'] ?? null, + $result['data']['ok'] ?? null, + ]; + + foreach ($okValues as $ok) { + if ((int) $ok === 1 && $httpCode >= 200 && $httpCode < 300) { + return true; + } + } + + return false; + } + + /** + * @param array $result + */ + private function extractGatewayPushErrorText(array $result): string + { + $messages = [ + $result['message'] ?? null, + $result['error_text'] ?? null, + $result['response']['message'] ?? null, + $result['response']['error_text'] ?? null, + $result['data']['message'] ?? null, + $result['data']['error_text'] ?? null, + $result['error_code'] ?? null, + $result['response']['error_code'] ?? null, + $result['data']['error_code'] ?? null, + ]; + + foreach ($messages as $message) { + $text = trim((string) $message); + if ($text !== '') { + return $text; + } + } + + return ''; + } + + /** + * Emit one diagnostic line to show exactly which gateway response fields + * supplied queue/record identifiers for records/push integration. + * + * @param array $result + */ + private function logGatewayPushResolution(string $context, array $result): void + { + try { + $recordMeta = $this->extractGatewayPushRecordMeta($result); + $queueMeta = $this->extractGatewayPushQueueMeta($result); + $httpCode = $this->extractGatewayPushHttpCode($result); + $submitted = $this->isGatewayPushSubmitted($result) ? 1 : 0; + $duplicate = $this->isGatewayPushDuplicate($result) ? 1 : 0; + + log_message( + 'info', + '[abdm.records.push.resolve][' . $context . '] ' + . 'http_code=' . $httpCode + . ' submitted=' . $submitted + . ' duplicate=' . $duplicate + . ' record_id=' . (int) ($recordMeta['id'] ?? 0) + . ' record_source=' . (string) ($recordMeta['source'] ?? 'none') + . ' queue_id=' . (string) ($queueMeta['id'] ?? '') + . ' queue_source=' . (string) ($queueMeta['source'] ?? 'none') + ); + } catch (\Throwable) { + // Fail-open diagnostics + } + } + /** * Update a health_record's push status and txn_id after getting a connector response. * Also creates a matching record_links row. @@ -5364,12 +5573,11 @@ class AbdmGateway extends BaseController 'queue_id' => $ccRef, 'record_data' => $bundle, ]); - $queueId = (string) ($result['queue_id'] ?? $result['existing_queue_id'] ?? ''); - $bridgeRecordId = (int) ($result['record_id'] ?? $result['id'] ?? $result['existing_record_id'] ?? 0); - $httpCode = (int) ($result['http_code'] ?? 0); - $errorCode = strtoupper(trim((string) ($result['error_code'] ?? ''))); - if ((int) ($result['ok'] ?? 0) !== 1 && $httpCode !== 409 && $errorCode !== 'DUPLICATE_RECORD') { - $connectorError = trim((string) ($result['message'] ?? $result['error_text'] ?? $errorCode)); + $this->logGatewayPushResolution('additional_hi_record', $result); + $queueId = $this->extractGatewayPushQueueId($result); + $bridgeRecordId = $this->extractGatewayPushRecordId($result); + if (! $this->isGatewayPushSubmitted($result)) { + $connectorError = $this->extractGatewayPushErrorText($result); if ($connectorError === '') { $connectorError = 'Bridge push failed'; } diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index fbdce1e..d839eee 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -621,7 +621,7 @@ class AbdmTaskBoard extends BaseController $statusTone = 'secondary'; $statusNote = 'Stored locally only (ABHA/consent not ready).'; } elseif ($pushStatus === 'queued') { - $statusLabel = 'Gateway Queued'; + $statusLabel = 'Submitted'; $statusTone = 'warning'; $statusNote = 'Submitted to gateway and waiting for link workflow.'; } elseif ($pushStatus === 'linked') { diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index 23f32a7..faf3e0f 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -4873,6 +4873,7 @@ class Opd_prescription extends BaseController $medications = $this->getPrescriptionMedicines($sessionId); $conditions = $this->getPrescriptionConditions($sessionId); $clinicalContext = $sessionId > 0 ? $this->getPrescriptionClinicalContext($sessionId, $opdRow) : []; + $clinicalContext['attachments'] = $this->collectOpdFhirAttachments($opdId); $bundle = $this->fhirR4Builder->buildPrescriptionBundle($patient, $encounter, $medications, $conditions, $clinicalContext); $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -4960,6 +4961,7 @@ class Opd_prescription extends BaseController 'document_id' => (int) ($row['id'] ?? 0), ]); } + $bundle = $this->appendOpdFhirAttachmentsToBundle($bundle, (int) $opdId); $prettyJson = (string) json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -5420,6 +5422,16 @@ class Opd_prescription extends BaseController 'csrfHash' => csrf_hash(), ]); } + $bundleWithAttachments = $this->appendOpdFhirAttachmentsToBundle($bundle, $opdId); + if ($bundleWithAttachments !== $bundle) { + $bundle = $bundleWithAttachments; + $this->db->table('opd_fhir_documents') + ->where('id', (int) ($bundleRow['id'] ?? 0)) + ->update([ + 'bundle_json' => (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'generated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } // Gather patient / OPD context for the push payload $patientId = 0; @@ -11096,6 +11108,268 @@ class Opd_prescription extends BaseController ]; } + /** + * @return array> + */ + private function collectOpdFhirAttachments(int $opdId, int $limit = 5): array + { + if ($opdId <= 0 || ! $this->db->tableExists('file_upload_data')) { + return []; + } + + $fields = $this->db->getFieldNames('file_upload_data') ?? []; + $select = []; + foreach (['id', 'full_path', 'public_path', 'file_ext', 'file_type', 'insert_date', 'document_type', 'scan_type', 'content_description', 'name', 'file_name', 'orig_name', 'client_name'] as $field) { + if (in_array($field, $fields, true)) { + $select[] = $field; + } + } + if (! in_array('id', $select, true) || ! in_array('full_path', $select, true)) { + return []; + } + + $builder = $this->db->table('file_upload_data') + ->select(implode(',', array_unique($select))) + ->where('opd_id', $opdId); + if (in_array('show_type', $fields, true)) { + $builder->where('show_type', 0); + } + + $rows = $builder->orderBy('id', 'DESC') + ->limit(max(1, min(10, $limit))) + ->get() + ->getResultArray(); + + $attachments = []; + foreach ($rows as $row) { + $path = $this->resolveOpdFhirAttachmentPath($row); + if ($path === '' || ! is_readable($path)) { + continue; + } + + $size = (int) (@filesize($path) ?: 0); + if ($size <= 0 || $size > 8 * 1024 * 1024) { + continue; + } + + $contentType = $this->normalizeOpdFhirAttachmentMimeType((string) ($row['file_type'] ?? ''), (string) ($row['file_ext'] ?? pathinfo($path, PATHINFO_EXTENSION))); + if (! in_array($contentType, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true)) { + continue; + } + + $raw = @file_get_contents($path); + if ($raw === false || $raw === '') { + continue; + } + + $titleParts = []; + foreach (['document_type', 'scan_type', 'content_description'] as $field) { + $value = trim((string) ($row[$field] ?? '')); + if ($value !== '' && strtolower($value) !== 'general' && strtolower($value) !== 'queued for ai analysis') { + $titleParts[] = $value; + } + } + $title = trim(implode(' - ', array_unique($titleParts))); + if ($title === '') { + $title = 'OPD scanned/uploaded document'; + } + + $attachments[] = [ + 'id' => (string) ((int) ($row['id'] ?? 0)), + 'title' => $title, + 'content_type' => $contentType, + 'data_base64' => base64_encode($raw), + 'date' => ! empty($row['insert_date']) ? date(DATE_ATOM, strtotime((string) $row['insert_date'])) : Time::now('Asia/Kolkata')->format(DATE_ATOM), + ]; + } + + return array_reverse($attachments); + } + + /** + * @param array $row + */ + private function resolveOpdFhirAttachmentPath(array $row): string + { + $candidates = []; + foreach (['full_path', 'public_path'] as $field) { + $value = trim((string) ($row[$field] ?? '')); + if ($value !== '') { + $candidates[] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $value); + } + } + + foreach ($candidates as $candidate) { + $checks = [$candidate]; + if (! preg_match('/^[A-Za-z]:\\\\/', $candidate)) { + $relative = ltrim($candidate, DIRECTORY_SEPARATOR); + $checks[] = ROOTPATH . $relative; + $checks[] = FCPATH . $relative; + } + + foreach ($checks as $check) { + $real = realpath($check); + if ($real !== false && is_file($real) && is_readable($real)) { + return $real; + } + } + } + + return ''; + } + + private function normalizeOpdFhirAttachmentMimeType(string $mimeType, string $extension): string + { + $mimeType = strtolower(trim($mimeType)); + if (in_array($mimeType, ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], true)) { + return $mimeType; + } + + $extension = strtolower(ltrim(trim($extension), '.')); + return match ($extension) { + 'pdf' => 'application/pdf', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'jpg', 'jpeg' => 'image/jpeg', + default => 'application/octet-stream', + }; + } + + /** + * @param array $bundle + * @return array + */ + private function appendOpdFhirAttachmentsToBundle(array $bundle, int $opdId): array + { + $attachments = $this->collectOpdFhirAttachments($opdId); + if (empty($attachments) || empty($bundle['entry']) || ! is_array($bundle['entry'])) { + return $bundle; + } + + $compositionIndex = null; + foreach ($bundle['entry'] as $index => $entry) { + $resource = is_array($entry) ? ($entry['resource'] ?? null) : null; + if (is_array($resource) && (string) ($resource['resourceType'] ?? '') === 'Composition') { + $compositionIndex = $index; + break; + } + } + if ($compositionIndex === null) { + return $bundle; + } + + $patientRef = (string) ($bundle['entry'][$compositionIndex]['resource']['subject']['reference'] ?? ''); + if ($patientRef === '') { + foreach ($bundle['entry'] as $entry) { + $resource = is_array($entry) ? ($entry['resource'] ?? null) : null; + if (is_array($resource) && (string) ($resource['resourceType'] ?? '') === 'Patient') { + $patientRef = (string) ($entry['fullUrl'] ?? ('Patient/' . (string) ($resource['id'] ?? 'patient'))); + break; + } + } + } + if ($patientRef === '') { + $patientRef = 'Patient/unknown'; + } + + $stripGenerated = static function (array $entry): bool { + $resource = $entry['resource'] ?? null; + if (! is_array($resource)) { + return true; + } + $id = (string) ($resource['id'] ?? ''); + return ! str_starts_with($id, 'opd-scan-') && ! str_starts_with($id, 'binary-opd-scan-'); + }; + $bundle['entry'] = array_values(array_filter($bundle['entry'], $stripGenerated)); + + $sectionRefs = []; + foreach ($attachments as $attachment) { + $attachmentId = preg_replace('/[^A-Za-z0-9\-\.]/', '-', (string) ($attachment['id'] ?? '')) ?: (string) random_int(1, 999999); + $suffix = bin2hex(random_bytes(6)); + $docId = 'opd-scan-' . $attachmentId . '-' . $suffix; + $binaryId = 'binary-' . $docId; + $docRef = 'urn:uuid:' . $docId; + $binaryRef = 'urn:uuid:' . $binaryId; + $contentType = (string) ($attachment['content_type'] ?? 'application/octet-stream'); + $title = (string) ($attachment['title'] ?? 'OPD scanned/uploaded document'); + $dataBase64 = (string) ($attachment['data_base64'] ?? ''); + $date = ! empty($attachment['date']) ? date(DATE_ATOM, strtotime((string) $attachment['date'])) : Time::now('Asia/Kolkata')->format(DATE_ATOM); + + if ($dataBase64 === '') { + continue; + } + + $sectionRefs[] = ['reference' => $docRef, 'display' => 'DocumentReference']; + $bundle['entry'][] = ['fullUrl' => $binaryRef, 'resource' => [ + 'resourceType' => 'Binary', + 'id' => $binaryId, + 'contentType' => $contentType, + 'data' => $dataBase64, + ]]; + $bundle['entry'][] = ['fullUrl' => $docRef, 'resource' => [ + 'resourceType' => 'DocumentReference', + 'id' => $docId, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']], + 'status' => 'current', + 'type' => ['text' => $title], + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'date' => $date, + 'content' => [[ + 'attachment' => [ + 'contentType' => $contentType, + 'url' => $binaryRef, + 'data' => $dataBase64, + 'title' => $title, + ], + ]], + ]]; + } + + if (empty($sectionRefs)) { + return $bundle; + } + + $sections = (array) ($bundle['entry'][$compositionIndex]['resource']['section'] ?? []); + foreach ($sections as $index => $section) { + if (! is_array($section)) { + continue; + } + $entries = (array) ($section['entry'] ?? []); + $sections[$index]['entry'] = array_values(array_filter($entries, static function ($entry): bool { + return ! is_array($entry) || ! str_contains((string) ($entry['reference'] ?? ''), 'opd-scan-'); + })); + } + + $documentSectionIndex = null; + foreach ($sections as $index => $section) { + if (strcasecmp((string) ($section['title'] ?? ''), 'Document Reference') === 0) { + $documentSectionIndex = $index; + break; + } + } + + if ($documentSectionIndex === null) { + $sections[] = [ + 'title' => 'Document Reference', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '371530004', + 'display' => 'Clinical consultation report', + ]]], + 'entry' => $sectionRefs, + ]; + } else { + $existingEntries = (array) ($sections[$documentSectionIndex]['entry'] ?? []); + $sections[$documentSectionIndex]['entry'] = array_values(array_merge($existingEntries, $sectionRefs)); + } + + $bundle['entry'][$compositionIndex]['resource']['section'] = array_values(array_filter($sections, static function ($section): bool { + return ! is_array($section) || ! empty($section['entry']); + })); + + return $bundle; + } + /** * @param array $payload * @param array $fields @@ -11163,6 +11437,7 @@ class Opd_prescription extends BaseController $medications = $this->getPrescriptionMedicines($sessionId); $conditions = $this->getPrescriptionConditions($sessionId); $clinicalContext = $this->getPrescriptionClinicalContext($sessionId, $opdRow); + $clinicalContext['attachments'] = $this->collectOpdFhirAttachments($opdId); $bundle = $this->fhirR4Builder->buildPrescriptionBundle($patient, $encounter, $medications, $conditions, $clinicalContext); $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php index 7aa8828..503442a 100644 --- a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php +++ b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php @@ -205,24 +205,57 @@ class AbdmGatewayPushClient */ private function mapResponse(int $httpStatus, array $body, string $requestId): array { - $queueId = (string) ($body['queue_id'] ?? $body['existing_queue_id'] ?? ''); - $recordId = (int) ($body['record_id'] ?? $body['id'] ?? $body['existing_record_id'] ?? 0); - $message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus)); - - if ($httpStatus === 201 || ((int) ($body['ok'] ?? 0) === 1 && $httpStatus >= 200 && $httpStatus < 300)) { - return [ - 'ok' => true, - 'status' => 'done', - 'http_status' => $httpStatus, - 'request_id' => $requestId, - 'gateway_record_id' => $recordId > 0 ? $recordId : null, - 'gateway_queue_id' => $queueId !== '' ? $queueId : null, - 'message' => $message, - 'retryable' => false, - ]; + $queueMeta = [ + 'queue_id' => $body['queue_id'] ?? null, + 'existing_queue_id' => $body['existing_queue_id'] ?? null, + 'response.queue_id' => $body['response']['queue_id'] ?? null, + 'response.existing_queue_id' => $body['response']['existing_queue_id'] ?? null, + 'data.queue_id' => $body['data']['queue_id'] ?? null, + 'data.existing_queue_id' => $body['data']['existing_queue_id'] ?? null, + ]; + $queueId = ''; + $queueSource = 'none'; + foreach ($queueMeta as $source => $value) { + $candidate = trim((string) $value); + if ($candidate !== '') { + $queueId = $candidate; + $queueSource = $source; + break; + } } - if ($httpStatus === 409 || strtoupper((string) ($body['error_code'] ?? '')) === 'DUPLICATE_RECORD') { + $recordMeta = [ + 'record_id' => $body['record_id'] ?? null, + 'existing_record_id' => $body['existing_record_id'] ?? null, + 'id' => $body['id'] ?? null, + 'response.record_id' => $body['response']['record_id'] ?? null, + 'response.existing_record_id' => $body['response']['existing_record_id'] ?? null, + 'response.id' => $body['response']['id'] ?? null, + 'data.record_id' => $body['data']['record_id'] ?? null, + 'data.existing_record_id' => $body['data']['existing_record_id'] ?? null, + 'data.id' => $body['data']['id'] ?? null, + ]; + $recordId = 0; + $recordSource = 'none'; + foreach ($recordMeta as $source => $value) { + $candidate = (int) $value; + if ($candidate > 0) { + $recordId = $candidate; + $recordSource = $source; + break; + } + } + + $errorCode = strtoupper(trim((string) ( + $body['error_code'] + ?? ($body['response']['error_code'] ?? null) + ?? ($body['data']['error_code'] ?? '') + ))); + $isDuplicate = $httpStatus === 409 || $errorCode === 'DUPLICATE_RECORD'; + $isSubmitted = $httpStatus === 201 || $isDuplicate; + $message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus)); + + if ($isSubmitted || ((int) ($body['ok'] ?? 0) === 1 && $httpStatus >= 200 && $httpStatus < 300)) { return [ 'ok' => true, 'status' => 'done', @@ -230,7 +263,11 @@ class AbdmGatewayPushClient 'request_id' => $requestId, 'gateway_record_id' => $recordId > 0 ? $recordId : null, 'gateway_queue_id' => $queueId !== '' ? $queueId : null, - 'message' => $message !== '' ? $message : 'Duplicate record handled as success.', + 'gateway_record_source' => $recordSource, + 'gateway_queue_source' => $queueSource, + 'submitted' => $isSubmitted ? 1 : 0, + 'duplicate' => $isDuplicate ? 1 : 0, + 'message' => $message, 'retryable' => false, ]; } @@ -246,6 +283,10 @@ class AbdmGatewayPushClient 'retryable' => true, 'gateway_record_id' => null, 'gateway_queue_id' => null, + 'gateway_record_source' => $recordSource, + 'gateway_queue_source' => $queueSource, + 'submitted' => 0, + 'duplicate' => $isDuplicate ? 1 : 0, ]; } @@ -260,6 +301,10 @@ class AbdmGatewayPushClient 'retryable' => false, 'gateway_record_id' => null, 'gateway_queue_id' => null, + 'gateway_record_source' => $recordSource, + 'gateway_queue_source' => $queueSource, + 'submitted' => 0, + 'duplicate' => $isDuplicate ? 1 : 0, ]; } @@ -273,6 +318,10 @@ class AbdmGatewayPushClient 'retryable' => false, 'gateway_record_id' => null, 'gateway_queue_id' => null, + 'gateway_record_source' => $recordSource, + 'gateway_queue_source' => $queueSource, + 'submitted' => 0, + 'duplicate' => $isDuplicate ? 1 : 0, ]; } diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index 1e4b668..4bd0b47 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -11,6 +11,7 @@ class FhirR4Builder * @param array $encounter * @param array> $medications * @param array> $conditions + * @param array $context * * @return array */ @@ -24,6 +25,7 @@ class FhirR4Builder $complaints = is_array($context['complaints'] ?? null) ? (array) $context['complaints'] : []; $serviceRequests = is_array($context['service_requests'] ?? null) ? (array) $context['service_requests'] : []; $appointments = is_array($context['appointments'] ?? null) ? (array) $context['appointments'] : []; + $attachments = is_array($context['attachments'] ?? null) ? (array) $context['attachments'] : []; // UUID-based identity for every resource (ABDM IG v6.5.0 requirement) $bundleUuid = $this->generateUuid(); @@ -49,6 +51,7 @@ class FhirR4Builder $medicationRefs = []; $serviceRequestRefs = []; $appointmentRefs = []; + $documentRefs = []; // Resource entries collected here; Composition prepended at the end. $resourceEntries = []; @@ -475,6 +478,56 @@ class FhirR4Builder $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $apptUuid, 'resource' => $apptRes]; } + // ── OPD scan/upload attachments (DocumentReference + Binary) ───────── + foreach ($attachments as $index => $attachment) { + if (! is_array($attachment)) { + continue; + } + + $dataBase64 = trim((string) ($attachment['data_base64'] ?? '')); + if ($dataBase64 === '') { + continue; + } + + $contentType = trim((string) ($attachment['content_type'] ?? 'application/octet-stream')); + $title = trim((string) ($attachment['title'] ?? 'OPD scanned document')); + $date = $this->normalizeIsoDateTime((string) ($attachment['date'] ?? ''), $issuedAt); + $attachmentId = preg_replace('/[^A-Za-z0-9\-\.]/', '-', trim((string) ($attachment['id'] ?? ''))); + if ($attachmentId === '' || $attachmentId === null) { + $attachmentId = (string) ($index + 1); + } + + $docUuid = 'opd-scan-' . $attachmentId . '-' . $this->generateUuid(); + $binaryUuid = 'binary-' . $docUuid; + $docRef = 'urn:uuid:' . $docUuid; + $binaryRef = 'urn:uuid:' . $binaryUuid; + + $documentRefs[] = ['reference' => $docRef, 'display' => 'DocumentReference']; + $resourceEntries[] = ['fullUrl' => $binaryRef, 'resource' => [ + 'resourceType' => 'Binary', + 'id' => $binaryUuid, + 'contentType' => $contentType, + 'data' => $dataBase64, + ]]; + $resourceEntries[] = ['fullUrl' => $docRef, 'resource' => [ + 'resourceType' => 'DocumentReference', + 'id' => $docUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']], + 'status' => 'current', + 'type' => ['text' => $title], + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'date' => $date, + 'content' => [[ + 'attachment' => [ + 'contentType' => $contentType, + 'url' => $binaryRef, + 'data' => $dataBase64, + 'title' => $title, + ], + ]], + ]]; + } + // ── Composition sections (ABDM SNOMED section codes) ───────────────── $compositionSections = []; if (! empty($complaintRefs)) { @@ -556,6 +609,17 @@ class FhirR4Builder 'entry' => $appointmentRefs, ]; } + if (! empty($documentRefs)) { + $compositionSections[] = [ + 'title' => 'Document Reference', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '371530004', + 'display' => 'Clinical consultation report', + ]]], + 'entry' => $documentRefs, + ]; + } // ── Composition (first entry per ABDM spec) ─────────────────────────── $composition = [ diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index e344b5b..8e3260b 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -297,8 +297,8 @@ - - + +
@@ -2423,6 +2423,29 @@ if (res.ok == 1) { btn.className = 'btn btn-sm btn-success'; btn.innerHTML = ' Submitted ✓'; + + var rowSubmitBtn = document.querySelector('.btn-opd-consult-submit-gateway[data-opd-id="' + String(_fhirOpdId) + '"]'); + if (rowSubmitBtn) { + var row = rowSubmitBtn.closest('tr'); + if (row) { + row.setAttribute('data-push-status', 'queued'); + var statusBadge = row.querySelector('.opd-consult-status-badge'); + if (statusBadge) { + statusBadge.textContent = 'Submitted'; + statusBadge.className = 'badge bg-warning text-dark opd-consult-status-badge'; + } + var detailsCell = row.querySelector('.opd-consult-details'); + if (detailsCell) { + var queueHtml = res.queue_id ? ('
Queue: ' + String(res.queue_id) + '
') : ''; + var bridgeHtml = res.bridge_record_id ? ('
Bridge: #' + String(res.bridge_record_id) + '
') : ''; + detailsCell.innerHTML = '
Submitted to gateway and waiting for link workflow.
' + queueHtml + bridgeHtml; + } + } + rowSubmitBtn.disabled = true; + rowSubmitBtn.className = 'btn btn-sm btn-success ms-1'; + rowSubmitBtn.textContent = 'Submitted'; + } + var qInfo = res.queue_id ? ' Queue ID: ' + res.queue_id : ''; alert('✅ Bundle submitted to ABDM bridge.\n' + (res.message || '') + qInfo); } else {