From 00b949ad0ad95212eae14eadd670888bd27910b3 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sat, 15 Aug 2026 03:16:32 +0530 Subject: [PATCH] Push M2 records to gateway for NAT deployment and cover all 8 HI types --- app/Commands/AbdmPushBackfill.php | 193 ++++++++++++++++++ app/Controllers/AbdmGateway.php | 62 +++++- .../Abdm/Sync/AbdmGatewayPushClient.php | 46 ++++- .../Abdm/Sync/AbdmSyncWorkerService.php | 50 ++--- app/Libraries/FhirR4Builder.php | 117 ++++++++++- app/Views/abdm/task_board.php | 65 ++++++ 6 files changed, 502 insertions(+), 31 deletions(-) create mode 100644 app/Commands/AbdmPushBackfill.php diff --git a/app/Commands/AbdmPushBackfill.php b/app/Commands/AbdmPushBackfill.php new file mode 100644 index 0000000..4c1c140 --- /dev/null +++ b/app/Commands/AbdmPushBackfill.php @@ -0,0 +1,193 @@ + 'Maximum records to push (default: 20).', + '--id' => 'Push a single health_records id.', + '--dry-run' => 'List eligible records without pushing.', + ]; + + public function run(array $params) + { + $limit = (int) (CLI::getOption('limit') ?? 20); + if ($limit <= 0) { + $limit = 20; + } + $onlyId = (int) (CLI::getOption('id') ?? 0); + $dryRun = CLI::getOption('dry-run') !== null; + + $db = \Config\Database::connect(); + if (! $db->tableExists('health_records')) { + CLI::error('health_records table not found.'); + + return; + } + + $builder = $db->table('health_records') + ->whereNotIn('push_status', ['pushed', 'linked']) + ->where('care_context_reference !=', '') + ->where('abha_id !=', '') + ->orderBy('id', 'ASC'); + if ($onlyId > 0) { + $builder->where('id', $onlyId); + } + + $rows = $builder->get($limit)->getResultArray(); + CLI::write('ABDM Push Backfill' . ($dryRun ? ' (dry-run)' : ''), 'yellow'); + CLI::write('Eligible: ' . count($rows)); + + if ($rows === [] || $dryRun) { + foreach ($rows as $row) { + CLI::write(' id=' . $row['id'] . ' ' . $row['hi_type'] . ' ' . $row['care_context_reference']); + } + + return; + } + + $connector = AbdmConnectorFactory::make(); + $pushed = 0; + $failed = 0; + + foreach ($rows as $row) { + $bundle = $this->resolveBundle($row); + if ($bundle === null) { + CLI::write(' id=' . $row['id'] . ' — no readable FHIR bundle, skipped', 'red'); + $failed++; + continue; + } + + $patient = $this->loadPatient($db, (int) ($row['patient_id'] ?? 0)); + $abhaId = trim((string) ($row['abha_id'] ?? '')); + + try { + $result = $connector->pushRecord([ + 'patient_id' => (string) ($row['patient_id'] ?? ''), + 'patient_name' => $patient['name'] !== '' ? $patient['name'] : ('PATIENT-' . (int) $row['patient_id']), + 'abha_id' => str_contains($abhaId, '@') ? '' : $abhaId, + 'abha_address' => str_contains($abhaId, '@') ? $abhaId : $patient['abha_address'], + 'gender' => $patient['gender'], + 'year_of_birth' => $patient['year_of_birth'], + 'hi_type' => (string) ($row['hi_type'] ?? ''), + 'visit_date' => substr((string) ($row['created_at'] ?? date('Y-m-d')), 0, 10), + 'care_context_reference' => (string) ($row['care_context_reference'] ?? ''), + 'care_context_display' => (string) ($row['hi_type'] ?? '') . ' ' . substr((string) ($row['created_at'] ?? ''), 0, 10), + 'record_data' => $bundle, + ]); + } catch (\Throwable $e) { + CLI::write(' id=' . $row['id'] . ' — exception: ' . $e->getMessage(), 'red'); + $failed++; + continue; + } + + $ok = ! empty($result['ok']) && (int) $result['ok'] === 1; + $httpCode = (int) ($result['http_code'] ?? 0); + if ($ok || $httpCode === 201 || $httpCode === 409) { + $db->table('health_records')->where('id', $row['id'])->update([ + 'push_status' => 'pushed', + 'push_at' => date('Y-m-d H:i:s'), + 'bridge_record_id' => (int) ($result['record_id'] ?? 0) ?: null, + 'abdm_txn_id' => (string) ($result['queue_id'] ?? $row['abdm_txn_id'] ?? ''), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + CLI::write(' id=' . $row['id'] . ' — pushed (record_id=' . (int) ($result['record_id'] ?? 0) . ')', 'green'); + $pushed++; + continue; + } + + $error = trim((string) ($result['message'] ?? $result['error_text'] ?? ('HTTP ' . $httpCode))); + foreach ((array) ($result['errors'] ?? []) as $detail) { + if (is_array($detail)) { + $error .= ' | ' . trim(implode(' ', array_filter([ + (string) ($detail['code'] ?? ''), + (string) ($detail['field'] ?? ''), + (string) ($detail['message'] ?? ''), + ]))); + } + } + + $db->table('health_records')->where('id', $row['id'])->update([ + 'push_status' => 'failed', + 'updated_at' => date('Y-m-d H:i:s'), + ]); + CLI::write(' id=' . $row['id'] . ' — failed: ' . $error, 'red'); + $failed++; + } + + CLI::write('Pushed: ' . $pushed, 'green'); + CLI::write('Failed: ' . $failed, $failed > 0 ? 'red' : 'green'); + } + + /** + * @param array $row + * @return array|null + */ + private function resolveBundle(array $row): ?array + { + $plain = trim((string) ($row['record_data'] ?? '')); + if ($plain === '') { + $encrypted = trim((string) ($row['fhir_bundle_enc'] ?? '')); + if ($encrypted === '') { + return null; + } + try { + $plain = (new FhirEncryptionService())->decrypt($encrypted); + } catch (\Throwable $e) { + return null; + } + } + + $decoded = json_decode($plain, true); + if (! is_array($decoded)) { + return null; + } + + $bundle = $decoded['bundle'] ?? $decoded['fhir_bundle'] ?? $decoded; + + return is_array($bundle) && ($bundle['resourceType'] ?? '') === 'Bundle' ? $bundle : null; + } + + /** + * @return array{name:string,gender:string,year_of_birth:string,abha_address:string} + */ + private function loadPatient($db, int $patientId): array + { + $out = ['name' => '', 'gender' => '', 'year_of_birth' => '', 'abha_address' => '']; + if ($patientId <= 0 || ! $db->tableExists('patient_master')) { + return $out; + } + + $fields = $db->getFieldNames('patient_master') ?? []; + $row = $db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray(); + if (! $row) { + return $out; + } + + $out['name'] = trim((string) ($row['p_fname'] ?? '')); + $gender = (int) ($row['gender'] ?? 0); + $out['gender'] = $gender === 1 ? 'M' : ($gender === 2 ? 'F' : ''); + + $dob = trim((string) ($row['dob'] ?? '')); + if ($dob !== '' && $dob !== '0000-00-00') { + $out['year_of_birth'] = substr($dob, 0, 4); + } + + if (in_array('abha_address', $fields, true)) { + $out['abha_address'] = trim((string) ($row['abha_address'] ?? '')); + } + + return $out; + } +} diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 7d3aaaa..1cc5d8b 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -1102,7 +1102,7 @@ class AbdmGateway extends BaseController $abhaId = trim((string) $this->request->getPost('abha_id')); $abhaAddressPost = trim((string) $this->request->getPost('abha_address')); $consentHandle = trim((string) $this->request->getPost('consent_handle')); - $pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1; + $pushToGateway = $this->resolveGatewayPushMode(); $now = Time::now('Asia/Kolkata')->toDateTimeString(); $logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGateway): void { @@ -2087,7 +2087,7 @@ class AbdmGateway extends BaseController $abhaId = trim((string) $this->request->getPost('abha_id')); $abhaAddressPost = trim((string) $this->request->getPost('abha_address')); $consentHandle = trim((string) $this->request->getPost('consent_handle')); - $pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1; + $pushToGateway = $this->resolveGatewayPushMode(); if ($abhaId === '' && $abhaAddressPost !== '') { $abhaId = $abhaAddressPost; @@ -2095,6 +2095,9 @@ class AbdmGateway extends BaseController if ($patientId <= 0) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']); } + if ($abhaId === '') { + $abhaId = $this->resolvePatientAbhaIdentifier($patientId); + } $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId); if ($payload === null) { @@ -2248,6 +2251,10 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error_text' => 'No wellness/vital data found for this patient']); } + if ($abhaId === '') { + $abhaId = $this->resolvePatientAbhaIdentifier($patientId); + } + return $this->pushAdditionalHiRecord($payload, $patientId, $abhaId, $consentHandle); } @@ -2270,6 +2277,10 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error_text' => 'document_title with document_text/document_base64/file_path is required']); } + if ($abhaId === '') { + $abhaId = $this->resolvePatientAbhaIdentifier($patientId); + } + return $this->pushAdditionalHiRecord($payload, $patientId, $abhaId, $consentHandle); } @@ -2293,6 +2304,10 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invoice not found or patient could not be resolved']); } + if ($abhaId === '') { + $abhaId = $this->resolvePatientAbhaIdentifier((int) $payload['patient_id']); + } + return $this->pushAdditionalHiRecord($payload, (int) $payload['patient_id'], $abhaId, $consentHandle); } @@ -4505,6 +4520,23 @@ class AbdmGateway extends BaseController }; } + /** + * HMS sits behind NAT, so ABDM/bridge discovery callbacks can never reach it — + * records must be pushed to the gateway to become discoverable in the PHR. + * Set ABDM_GATEWAY_PUSH_MODE=0 only for a callback-reachable deployment. + */ + private function resolveGatewayPushMode(): bool + { + $requested = $this->request->getPost('push_to_gateway'); + if ($requested !== null && trim((string) $requested) !== '') { + return (int) $requested === 1; + } + + $setting = strtolower(trim($this->readRuntimeSetting('ABDM_GATEWAY_PUSH_MODE'))); + + return ! in_array($setting, ['0', 'off', 'false', 'no'], true); + } + private function readRuntimeSetting(string $name): string { $envValue = getenv($name); @@ -5771,6 +5803,32 @@ class AbdmGateway extends BaseController ]; } + /** + * Share endpoints are triggered from screens that do not carry the ABHA, and an + * empty identifier silently downgrades the record to local-only instead of pushing. + */ + private function resolvePatientAbhaIdentifier(int $patientId): string + { + $row = $this->loadPatientRow($patientId); + if ($row === []) { + return ''; + } + + $address = trim((string) ($row['abha_address'] ?? '')); + if ($address !== '') { + return $address; + } + + foreach (['abha_id', 'abha_no', 'abha'] as $field) { + $value = trim((string) ($row[$field] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + private function loadPatientRow(int $patientId): array { if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) { diff --git a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php index d1bfc5d..1d6610e 100644 --- a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php +++ b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php @@ -55,7 +55,7 @@ class AbdmGatewayPushClient ]; } - $baseUrl = rtrim($this->readSetting('ABDM_BRIDGE_URL', 'EATRIA_BRIDGE_URL'), '/'); + $baseUrl = $this->resolveBridgeBaseUrl(); if ($baseUrl === '') { return [ 'ok' => false, @@ -81,7 +81,7 @@ class AbdmGatewayPushClient ]; } - $url = $baseUrl . '/api/v3/records/push'; + $url = $this->buildGatewayUrl($baseUrl, '/api/v3/records/push'); $headers = [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', @@ -153,7 +153,7 @@ class AbdmGatewayPushClient ]; } - $baseUrl = rtrim($this->readSetting('ABDM_BRIDGE_URL', 'EATRIA_BRIDGE_URL'), '/'); + $baseUrl = $this->resolveBridgeBaseUrl(); if ($baseUrl === '') { return [ 'ok' => false, @@ -443,6 +443,27 @@ class AbdmGatewayPushClient $isSubmitted = $httpStatus === 201 || $isDuplicate; $message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus)); + // The bridge returns field-level FHIR errors here; without them a 422 is undiagnosable. + $validationErrors = is_array($body['errors'] ?? null) ? $body['errors'] : []; + if ($validationErrors !== []) { + $details = []; + foreach ($validationErrors as $error) { + if (! is_array($error)) { + $details[] = trim((string) $error); + continue; + } + $details[] = trim(implode(' ', array_filter([ + trim((string) ($error['code'] ?? '')), + trim((string) ($error['field'] ?? '')), + trim((string) ($error['message'] ?? '')), + ]))); + } + $details = array_values(array_filter($details)); + if ($details !== []) { + $message .= ' | ' . implode(' ; ', $details); + } + } + if ($isSubmitted || ((int) ($body['ok'] ?? 0) === 1 && $httpStatus >= 200 && $httpStatus < 300)) { return [ 'ok' => true, @@ -654,6 +675,25 @@ class AbdmGatewayPushClient return str_repeat('*', strlen($left) - 4) . substr($left, -4) . ($right !== '' ? '@' . $right : ''); } + /** + * ABDM_BRIDGE_URL still points at the retired /api/v1/bridge root in older .env files, + * so normalise every source down to the bridge API root that serves /api/v3/*. + */ + private function resolveBridgeBaseUrl(): string + { + $baseUrl = trim($this->readSetting('EATRIA_BRIDGE_URL', 'ABDM_BRIDGE_URL')); + if ($baseUrl === '') { + return ''; + } + + $baseUrl = rtrim($baseUrl, '/'); + $baseUrl = (string) preg_replace('#/api/v\d+/bridge$#i', '/api', $baseUrl); + $baseUrl = (string) preg_replace('#/v\d+/bridge$#i', '', $baseUrl); + $baseUrl = (string) preg_replace('#/api/v\d+$#i', '/api', $baseUrl); + + return rtrim($baseUrl, '/'); + } + private function buildGatewayUrl(string $baseUrl, string $path): string { $url = rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); diff --git a/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php b/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php index 8868419..b456975 100644 --- a/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php +++ b/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php @@ -106,43 +106,45 @@ class AbdmSyncWorkerService $patientProfile = $this->resolvePatientProfile((int) ($record['local_patient_id'] ?? 0), $payload); - $hospitalId = (int) ($payload['hospital_id'] ?? 0); - if ($hospitalId <= 0) { - $hospitalId = $this->resolveHospitalIdFromSettings(); + $hfrId = trim((string) ($record['hfr_id'] ?? '')); + if ($hfrId === '') { + $hfrId = trim((string) ($payload['hfr_id'] ?? '')); + } + + $bundle = $payload['fhir_bundle'] ?? null; + if (is_string($bundle)) { + $bundle = json_decode($bundle, true); } $recordType = $this->mapHiTypeToRecordType((string) ($record['hi_type'] ?? '')); - $careContextPayload = [ - 'hospital_id' => $hospitalId, - 'hfr_id' => (string) ($record['hfr_id'] ?? ''), - 'patient' => [ - 'patient_id' => $patientProfile['patient_id'], - 'name' => $patientProfile['name'], - 'mobile' => $patientProfile['mobile'], - 'gender' => $patientProfile['gender'], - 'year_of_birth' => $patientProfile['year_of_birth'], - ], - 'care_contexts' => [[ - 'reference_number' => (string) ($record['care_context_reference'] ?? ''), - 'display' => (string) ($record['care_context_display'] ?? $recordType), - 'record_type' => $recordType, - 'visit_date' => $this->normalizeVisitDateTime((string) ($record['visit_date'] ?? '')), - 'doctor_name' => (string) ($record['doctor_name'] ?? ''), - 'department' => (string) ($record['department'] ?? ''), - ]], + + // NAT deployment: the bridge answers ABDM discovery/data requests from pushed + // records, so /api/v3/records/push is the only call needed (v1 link is retired). + $recordPayload = [ + 'hfr_id' => $hfrId, + 'hi_type' => (string) ($record['hi_type'] ?? $recordType), + 'care_context_reference' => (string) ($record['care_context_reference'] ?? ''), + 'care_context_display' => (string) ($record['care_context_display'] ?? $recordType), + 'visit_date' => $this->normalizeVisitDateTime((string) ($record['visit_date'] ?? '')), + 'doctor_name' => (string) ($record['doctor_name'] ?? ''), + 'department' => (string) ($record['department'] ?? ''), + 'patient_name' => $patientProfile['name'], + 'gender' => $patientProfile['gender'], + 'mobile' => $patientProfile['mobile'], + 'fhir_bundle' => is_array($bundle) ? $bundle : null, ]; $abhaAddress = trim((string) ($patientProfile['abha_address'] ?? '')); if ($abhaAddress !== '') { - $careContextPayload['patient']['abha_address'] = $abhaAddress; + $recordPayload['abha_address'] = $abhaAddress; } $abhaNumber = trim((string) ($patientProfile['abha_number'] ?? '')); if ($abhaNumber !== '') { - $careContextPayload['patient']['abha_number'] = $abhaNumber; + $recordPayload['abha_id'] = $abhaNumber; } - $result = $this->gatewayClient->pushCareContextLink($careContextPayload, $idempotencyKey); + $result = $this->gatewayClient->pushRecord($recordPayload, $idempotencyKey); $this->recordModel->update($syncRecordId, [ 'sync_status' => (bool) ($result['ok'] ?? false) ? 'done' : ((bool) ($result['retryable'] ?? false) ? 'failed' : 'dead'), diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index 4bd0b47..dbe48c4 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -1235,6 +1235,37 @@ class FhirR4Builder if ($organizationRef !== '') { $encounterResource['serviceProvider'] = ['reference' => $organizationRef]; } + + // ABDM rejects a DischargeSummaryRecord without at least one Condition or + // Procedure resource, so the diagnosis must be a resource and not just narrative. + $diagnosisResourceText = trim((string) ( + $summary['diagnosis_text'] + ?? $summary['final_diagnosis'] + ?? $summary['chief_complaints'] + ?? '' + )); + $conditionRef = ''; + if ($diagnosisResourceText !== '') { + $conditionUuid = $this->generateUuid(); + $conditionRef = 'urn:uuid:' . $conditionUuid; + + $encounterResource['diagnosis'] = [[ + 'condition' => ['reference' => $conditionRef, 'display' => 'Condition'], + ]]; + + $resourceEntries[] = ['fullUrl' => $conditionRef, 'resource' => [ + 'resourceType' => 'Condition', + 'id' => $conditionUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']], + 'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active', 'display' => 'Active']]], + 'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', 'code' => 'confirmed', 'display' => 'Confirmed']]], + 'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'encounter-diagnosis', 'display' => 'Encounter Diagnosis']]]], + 'code' => ['text' => $diagnosisResourceText], + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'encounter' => ['reference' => $encounterRef], + ]]; + } + $resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource]; // ── Composition sections per DischargeSummaryRecord spec ────────────── @@ -1264,6 +1295,15 @@ class FhirR4Builder ]; } + if ($conditionRef !== '') { + $sections[] = [ + 'title' => 'Diagnosis', + 'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnosis']]], + 'entry' => [['reference' => $conditionRef, 'display' => 'Condition']], + 'text' => ['status' => 'generated', 'div' => '
' . htmlspecialchars($diagnosisResourceText) . '
'], + ]; + } + // Investigations section (SNOMED 721981007) $investigationsText = trim((string) ($summary['investigations_text'] ?? '')); if ($investigationsText !== '') { @@ -1434,11 +1474,81 @@ class FhirR4Builder ]], ]; + // ABDM validates InvoiceRecord on a FHIR Invoice resource; Claim alone is rejected. + $invoiceLineItems = []; + foreach ($claimItems as $index => $claimItem) { + $invoiceLineItems[] = [ + 'sequence' => $index + 1, + 'chargeItemCodeableConcept' => $claimItem['productOrService'], + 'priceComponent' => [[ + 'type' => 'base', + 'amount' => $claimItem['net'], + ]], + ]; + } + + $invoiceResource = [ + 'resourceType' => 'Invoice', + 'id' => 'invoice-' . $claimId, + 'status' => (string) ($claim['invoice_status'] ?? 'issued'), + 'subject' => ['reference' => $patientRef], + 'date' => $issuedAt, + 'totalGross' => [ + 'value' => (float) ($claim['total'] ?? 0), + 'currency' => (string) ($claim['currency'] ?? 'INR'), + ], + 'totalNet' => [ + 'value' => (float) ($claim['total'] ?? 0), + 'currency' => (string) ($claim['currency'] ?? 'INR'), + ], + ]; + + $invoiceNo = trim((string) ($claim['invoice_no'] ?? '')); + if ($invoiceNo !== '') { + $invoiceResource['identifier'] = [['system' => 'https://ndhm.in/invoice', 'value' => $invoiceNo]]; + } + if ($invoiceLineItems !== []) { + $invoiceResource['lineItem'] = $invoiceLineItems; + } + + // ABDM InvoiceRecord must be a document bundle led by a Composition. + $composition = [ + 'resourceType' => 'Composition', + 'id' => 'comp-invoice-' . $claimId, + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '736762002', + 'display' => 'Invoice record', + ]], + 'text' => 'Invoice', + ], + 'subject' => ['reference' => $patientRef], + 'date' => $issuedAt, + 'title' => 'Invoice', + 'encounter' => ['reference' => $encounterRef], + 'section' => [[ + 'title' => 'Invoice', + 'entry' => [ + ['reference' => 'Invoice/invoice-' . $claimId], + ['reference' => 'Claim/' . $claimId], + ], + ]], + ]; + + $provider = trim((string) ($claim['provider'] ?? '')); + if ($provider !== '') { + $composition['author'] = [['display' => $provider]]; + } + return [ 'resourceType' => 'Bundle', - 'type' => 'collection', + 'type' => 'document', 'timestamp' => $issuedAt, 'entry' => [[ + 'resource' => $composition, + ], [ 'resource' => $this->buildPatientResource($patient), ], [ 'resource' => [ @@ -1447,6 +1557,8 @@ class FhirR4Builder 'status' => (string) ($encounter['status'] ?? 'finished'), 'subject' => ['reference' => $patientRef], ], + ], [ + 'resource' => $invoiceResource, ], [ 'resource' => $claimResource, ]], @@ -2749,7 +2861,8 @@ class FhirR4Builder if ($practitionerRef !== '') { $composition['author'] = [['reference' => $practitionerRef]]; } - $entries[] = ['resource' => $composition]; + // ABDM requires the Composition to be the first bundle entry. + array_unshift($entries, ['resource' => $composition]); return [ 'resourceType' => 'Bundle', diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index b130150..cde440a 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -193,6 +193,26 @@ +
+
Share Additional HI Types
+
+
+ +
+
+ +
+
+ +
+
+
+ + + + +
+
Ready
@@ -1296,6 +1316,51 @@ }); }); + document.querySelectorAll('[data-hi-share]').forEach(function (btn) { + btn.addEventListener('click', function () { + var kind = btn.getAttribute('data-hi-share'); + var patientId = parseInt((document.getElementById('tb_hi_patient_id').value || '0'), 10) || 0; + var opdId = parseInt((document.getElementById('tb_hi_opd_id').value || '0'), 10) || 0; + var invoiceId = parseInt((document.getElementById('tb_hi_invoice_id').value || '0'), 10) || 0; + + if (kind !== 'invoice' && patientId <= 0) { + setSandboxStatus('Enter Patient ID first.', 'warning'); + return; + } + if (kind === 'invoice' && invoiceId <= 0) { + setSandboxStatus('Enter Invoice ID first.', 'warning'); + return; + } + + var urls = { + immunization: '', + wellness: '', + health_document: '', + invoice: '' + }; + + var payload = { patient_id: patientId, consent_handle: '' }; + if (kind === 'wellness') { payload.opd_id = opdId; } + if (kind === 'invoice') { payload.invoice_id = invoiceId; } + + var original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Sharing'; + setSandboxStatus('Sharing ' + kind + ' record...'); + + post(urls[kind], payload, function (res) { + btn.disabled = false; + btn.innerHTML = original; + if (!res || parseInt(res.ok || '0', 10) !== 1) { + setSandboxStatus(res.error_text || res.error || ('Failed to share ' + kind + ' record.'), 'danger'); + return; + } + setSandboxStatus(kind + ' shared. Care context: ' + (res.care_context_reference || '-') + + (res.queue_id ? (' | Queue: ' + res.queue_id) : ''), 'success'); + }); + }); + }); + document.getElementById('btnHideFhirDebug').addEventListener('click', function () { if (fhirDebugCard) { fhirDebugCard.classList.add('d-none');