ABDM records/push: treat 201/409 as submitted, persist record IDs, and update OPD UI state

This commit is contained in:
DevSoftTech Server 2026-07-21 01:32:30 +05:30
parent 5d6acdd480
commit c84ea383e4
6 changed files with 680 additions and 61 deletions

View File

@ -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<string,mixed> $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<string,mixed> $result
*/
private function extractGatewayPushRecordId(array $result): int
{
$meta = $this->extractGatewayPushRecordMeta($result);
return (int) ($meta['id'] ?? 0);
}
/**
* @param array<string,mixed> $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<string,mixed> $result
*/
private function extractGatewayPushQueueId(array $result): string
{
$meta = $this->extractGatewayPushQueueMeta($result);
return (string) ($meta['id'] ?? '');
}
/**
* @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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';
}

View File

@ -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') {

View File

@ -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<int, array<string, string>>
*/
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<string, mixed> $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<string, mixed> $bundle
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @param array<int, string> $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);

View File

@ -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,
];
}

View File

@ -11,6 +11,7 @@ class FhirR4Builder
* @param array<string, mixed> $encounter
* @param array<int, array<string, mixed>> $medications
* @param array<int, array<string, mixed>> $conditions
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
@ -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 = [

View File

@ -297,8 +297,8 @@
<td><span class="text-primary small"><?= esc((string) ($r['abha_id'] ?? '')) ?></span></td>
<td><?= esc(substr((string) ($r['apointment_date'] ?? ''), 0, 16)) ?></td>
<td><?= esc((string) ($r['doc_name'] ?? '')) ?></td>
<td><span class="<?= esc($statusBadgeClass) ?>"><?= esc($statusLabel) ?></span></td>
<td class="small text-muted" style="min-width:260px;max-width:340px;">
<td><span class="<?= esc($statusBadgeClass) ?> opd-consult-status-badge"><?= esc($statusLabel) ?></span></td>
<td class="small text-muted opd-consult-details" style="min-width:260px;max-width:340px;">
<?php if ($statusNote !== ''): ?>
<div><?= esc($statusNote) ?></div>
<?php endif; ?>
@ -2423,6 +2423,29 @@
if (res.ok == 1) {
btn.className = 'btn btn-sm btn-success';
btn.innerHTML = '<i class="bi bi-check-circle"></i> 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 ? ('<div><strong>Queue:</strong> ' + String(res.queue_id) + '</div>') : '';
var bridgeHtml = res.bridge_record_id ? ('<div><strong>Bridge:</strong> #' + String(res.bridge_record_id) + '</div>') : '';
detailsCell.innerHTML = '<div>Submitted to gateway and waiting for link workflow.</div>' + 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 {