From 625987955e0ff9e1a82530bc4f52d344e342de8a Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sun, 16 Aug 2026 01:30:11 +0530 Subject: [PATCH] feat(abdm): improve bridge record delivery Add NAT-safe Immunization and InvoiceRecord Bridge workflows, stable care-context handling, ABHA address enrichment, embedded invoice PDFs, M2 status synchronization, and strict Bridge response normalization. --- app/Config/Routes.php | 2 + app/Controllers/AbdmGateway.php | 1331 ++++++++++++++--- app/Controllers/AbdmTaskBoard.php | 397 ++++- app/Libraries/Abdm/EAtriaBridgeConnector.php | 101 +- app/Libraries/Abdm/M2LinkStatusResolver.php | 45 + .../Abdm/Sync/AbdmGatewayPushClient.php | 19 +- app/Libraries/FhirR4Builder.php | 73 +- app/Views/abdm/task_board.php | 546 ++++++- tests/unit/Abdm/AbdmGatewayPushClientTest.php | 75 + tests/unit/Abdm/FhirGeneratorsTest.php | 320 ++++ tests/unit/Abdm/M2LinkStatusResolverTest.php | 42 + 11 files changed, 2714 insertions(+), 237 deletions(-) create mode 100644 app/Libraries/Abdm/M2LinkStatusResolver.php create mode 100644 tests/unit/Abdm/M2LinkStatusResolverTest.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 67bc42a..2f381f9 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -736,6 +736,8 @@ $routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmuni $routes->post('AbdmGateway/share_wellness_bundle', 'AbdmGateway::shareWellnessBundle', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/share_health_document_bundle', 'AbdmGateway::shareHealthDocumentBundle', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/share_invoice_bundle', 'AbdmGateway::shareInvoiceBundle', ['filter' => $abdmPermFilter]); +$routes->get('AbdmGateway/invoice_fhir_preview', 'AbdmGateway::invoiceFhirPreview', ['filter' => $abdmPermFilter]); +$routes->post('AbdmGateway/share_invoice_source_bundle', 'AbdmGateway::shareInvoiceSourceBundle', ['filter' => $abdmPermFilter]); $routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.immunization.access']); $routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']); $routes->post('Immunization/sync_uip_master', 'Immunization::syncUipMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 1cc5d8b..b869c72 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -6,6 +6,7 @@ use App\Libraries\Abdm\AbdmConnectorInterface; use App\Libraries\Abdm\AbdmConnectorFactory; use App\Libraries\Abdm\Fhir\FhirGeneratorFactory; use App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter; +use App\Libraries\Abdm\M2LinkStatusResolver; use App\Libraries\FhirR4Builder; use App\Libraries\FhirEncryptionService; use App\Libraries\AbdmAuditService; @@ -139,9 +140,9 @@ class AbdmGateway extends BaseController $payload = []; } - $requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id'))); + $requestId = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? $this->request->getHeaderLine('X-Request-Id'))); $abhaId = trim((string) ($payload['abha_id'] ?? '')); - $abhaAddress = trim((string) ($payload['abha_address'] ?? '')); + $abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abhaAddress'] ?? '')); if ($abhaId === '' && $abhaAddress === '') { return $this->response->setStatusCode(400)->setJSON([ @@ -152,25 +153,58 @@ class AbdmGateway extends BaseController ]); } - if (! $this->db->tableExists('patient_records')) { + if (! $this->db->tableExists('patient_records') && ! $this->db->tableExists('health_records')) { return $this->response->setJSON(['ok' => 1, 'patient' => null, 'care_contexts' => []]); } $abhaLookup = $abhaAddress !== '' ? $abhaAddress : $abhaId; - $rows = $this->db->table('patient_records') - ->select('patient_id, abha_id, record_type, created_at, consent_id') - ->where('abha_id', $abhaLookup) - ->where('status', 'ACTIVE') - ->orderBy('record_id', 'DESC') - ->limit(200) - ->get() - ->getResultArray(); + $rows = []; + if ($this->db->tableExists('patient_records')) { + $rows = $this->db->table('patient_records') + ->select('patient_id, abha_id, record_type, created_at, consent_id') + ->where('abha_id', $abhaLookup) + ->where('status', 'ACTIVE') + ->orderBy('record_id', 'DESC') + ->limit(200) + ->get() + ->getResultArray(); + } - if (empty($rows)) { + $healthRows = []; + if ($this->db->tableExists('health_records')) { + $healthFields = $this->db->getFieldNames('health_records') ?? []; + if (in_array('care_context_reference', $healthFields, true)) { + $healthRows = $this->db->table('health_records') + ->select('id, patient_id, abha_id, hi_type, entity_type, entity_id, care_context_reference, created_at') + ->where('abha_id', $abhaLookup) + ->where('care_context_reference !=', '') + ->whereIn('push_status', ['local_discovery_ready', 'queued', 'pushed', 'linked']) + ->orderBy('id', 'DESC') + ->limit(300) + ->get() + ->getResultArray(); + } + } + + if (empty($rows) && empty($healthRows)) { return $this->response->setJSON(['ok' => 1, 'patient' => null, 'care_contexts' => []]); } $contexts = []; + foreach ($healthRows as $row) { + $ccRef = trim((string) ($row['care_context_reference'] ?? '')); + $hiType = trim((string) ($row['hi_type'] ?? '')); + if ($ccRef === '' || $hiType === '') { + continue; + } + $createdAt = trim((string) ($row['created_at'] ?? '')); + $datePart = $createdAt !== '' ? date('Y-m-d', strtotime($createdAt)) : date('Y-m-d'); + $contexts[$ccRef] = [ + 'referenceNumber' => $ccRef, + 'display' => $hiType . ' - ' . $datePart, + 'hiType' => $hiType, + ]; + } foreach ($rows as $row) { $patientId = (int) ($row['patient_id'] ?? 0); $createdAt = trim((string) ($row['created_at'] ?? '')); @@ -201,7 +235,7 @@ class AbdmGateway extends BaseController 'action' => 'discovery_records', 'entity_type' => 'patient_records', 'abha_id' => $abhaLookup, - 'patient_id' => (int) ($rows[0]['patient_id'] ?? 0), + 'patient_id' => (int) ($healthRows[0]['patient_id'] ?? $rows[0]['patient_id'] ?? 0), 'request' => $payload, 'response' => ['count' => count($contextsOut)], 'outcome' => 'success', @@ -249,12 +283,12 @@ class AbdmGateway extends BaseController ]); } - if (! $this->db->tableExists('patient_records')) { + if (! $this->db->tableExists('patient_records') && ! $this->db->tableExists('health_records')) { return $this->response->setJSON(['ok' => 1, 'entries' => []]); } $requestedRefs = []; - $incomingRefs = $payload['care_context_references'] ?? []; + $incomingRefs = $payload['care_context_references'] ?? $payload['careContextReferences'] ?? []; if (is_array($incomingRefs)) { foreach ($incomingRefs as $ref) { $v = trim((string) $ref); @@ -264,16 +298,53 @@ class AbdmGateway extends BaseController } } - $rows = $this->db->table('patient_records') - ->select('record_id, patient_id, abha_id, consent_id, record_type, fhir_resource, created_at') - ->where('abha_id', $abhaLookup) - ->where('status', 'ACTIVE') - ->orderBy('record_id', 'DESC') - ->limit(300) - ->get() - ->getResultArray(); + $rows = []; + if ($this->db->tableExists('patient_records')) { + $rows = $this->db->table('patient_records') + ->select('record_id, patient_id, abha_id, consent_id, record_type, fhir_resource, created_at') + ->where('abha_id', $abhaLookup) + ->where('status', 'ACTIVE') + ->orderBy('record_id', 'DESC') + ->limit(300) + ->get() + ->getResultArray(); + } $entries = []; + $resolvedRefs = []; + $deliveredHealthRows = []; + if ($this->db->tableExists('health_records')) { + $healthFields = $this->db->getFieldNames('health_records') ?? []; + if (in_array('record_data', $healthFields, true) && in_array('care_context_reference', $healthFields, true)) { + $healthBuilder = $this->db->table('health_records') + ->select('id, care_context_reference, record_data, entity_type, entity_id, push_status') + ->where('abha_id', $abhaLookup) + ->where('care_context_reference !=', '') + ->whereIn('push_status', ['local_discovery_ready', 'queued', 'pushed', 'linked']) + ->orderBy('id', 'DESC') + ->limit(300); + if (! empty($requestedRefs)) { + $healthBuilder->whereIn('care_context_reference', $requestedRefs); + } + foreach ($healthBuilder->get()->getResultArray() as $healthRow) { + $ccRef = trim((string) ($healthRow['care_context_reference'] ?? '')); + if ($ccRef === '' || isset($resolvedRefs[$ccRef])) { + continue; + } + $fhir = json_decode((string) ($healthRow['record_data'] ?? ''), true); + if (! is_array($fhir)) { + continue; + } + $resolvedRefs[$ccRef] = true; + $deliveredHealthRows[] = $healthRow; + $entries[] = [ + 'careContextReference' => $ccRef, + 'media' => 'application/fhir+json', + 'fhir' => $fhir, + ]; + } + } + } foreach ($rows as $row) { $patientId = (int) ($row['patient_id'] ?? 0); $recordType = strtoupper(trim((string) ($row['record_type'] ?? 'OTHER'))); @@ -281,7 +352,7 @@ class AbdmGateway extends BaseController $datePart = $createdAt !== '' ? date('Y-m-d', strtotime($createdAt)) : date('Y-m-d'); $ccRef = $recordType . '-' . $patientId . '-' . str_replace('-', '', $datePart); - if (! empty($requestedRefs) && ! in_array($ccRef, $requestedRefs, true)) { + if (isset($resolvedRefs[$ccRef]) || (! empty($requestedRefs) && ! in_array($ccRef, $requestedRefs, true))) { continue; } @@ -295,8 +366,11 @@ class AbdmGateway extends BaseController 'media' => 'application/fhir+json', 'fhir' => $fhir, ]; + $resolvedRefs[$ccRef] = true; } + $this->confirmFetchedHealthRecordsLinked($deliveredHealthRows, $abhaLookup, $requestId, $payload); + $this->getAuditService()->log([ 'action' => 'fetch_record', 'entity_type' => 'patient_records', @@ -314,6 +388,80 @@ class AbdmGateway extends BaseController ]); } + /** + * A successful authenticated fetch proves that these canonical care contexts + * were linked and delivered, even when the bridge omits a link-status callback. + * + * @param array> $healthRows + * @param array $payload + */ + private function confirmFetchedHealthRecordsLinked(array $healthRows, string $abhaId, string $requestId, array $payload): void + { + if ($healthRows === [] || ! $this->db->tableExists('health_records')) { + return; + } + + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $this->db->transStart(); + foreach ($healthRows as $healthRow) { + $healthRecordId = (int) ($healthRow['id'] ?? 0); + $careContextRef = trim((string) ($healthRow['care_context_reference'] ?? '')); + if ($healthRecordId <= 0 || $careContextRef === '') { + continue; + } + + if (strtolower(trim((string) ($healthRow['push_status'] ?? ''))) !== 'linked') { + $this->db->table('health_records')->where('id', $healthRecordId)->update([ + 'push_status' => 'linked', + 'linked_at' => $now, + 'updated_at' => $now, + ]); + } + + if ($this->db->tableExists('record_links')) { + $existing = $this->db->table('record_links') + ->where('care_context_reference', $careContextRef) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $linkData = [ + 'health_record_id' => $healthRecordId, + 'abdm_txn_id' => $requestId !== '' ? $requestId : null, + 'link_status' => 'linked', + 'updated_at' => $now, + 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; + if (! empty($existing)) { + if (strtolower(trim((string) ($existing['link_status'] ?? ''))) !== 'linked') { + $linkData['linked_at'] = $now; + } + $this->db->table('record_links')->where('id', (int) $existing['id'])->update($linkData); + } else { + $this->db->table('record_links')->insert(array_merge($linkData, [ + 'abha_id' => $abhaId, + 'care_context_reference' => $careContextRef, + 'linked_at' => $now, + 'created_at' => $now, + ])); + } + } + + if ($this->db->tableExists('abdm_work_tasks')) { + $this->db->table('abdm_work_tasks') + ->where('entity_type', (string) ($healthRow['entity_type'] ?? '')) + ->where('entity_id', (string) ($healthRow['entity_id'] ?? '')) + ->whereIn('status', ['pending', 'in_progress']) + ->update([ + 'status' => 'completed', + 'last_action_result' => 'Care context delivered through authenticated M2 fetch.', + 'completed_at' => $now, + 'updated_at' => $now, + ]); + } + } + $this->db->transComplete(); + } + /** * POST /api/v1/abdm/gateway/consent/upsert */ @@ -376,11 +524,11 @@ class AbdmGateway extends BaseController $payload = []; } - $requestId = trim((string) ($payload['request_id'] ?? $this->request->getHeaderLine('X-Request-Id'))); - $abhaId = trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')); - $status = strtolower(trim((string) ($payload['status'] ?? ''))); - $careContextRef = trim((string) ($payload['care_context_reference'] ?? '')); - $linkedAt = trim((string) ($payload['linked_at'] ?? '')); + $callback = M2LinkStatusResolver::parse($payload, $this->request->getHeaderLine('X-Request-Id')); + $requestId = $callback['request_id']; + $abhaId = $callback['abha_id']; + $careContextRef = $callback['care_context_reference']; + $linkedAt = $callback['linked_at']; if ($abhaId === '' || $careContextRef === '') { return $this->response->setStatusCode(400)->setJSON([ @@ -391,37 +539,90 @@ class AbdmGateway extends BaseController ]); } + $incomingLinkStatus = $callback['status']; + $persistedLinkStatus = $incomingLinkStatus; + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $healthRecord = null; + + if ($this->db->tableExists('health_records')) { + $healthRecord = $this->db->table('health_records') + ->select('id, push_status, entity_type, entity_id') + ->where('care_context_reference', $careContextRef) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + } + + $this->db->transStart(); if ($this->db->tableExists('record_links')) { $existing = $this->db->table('record_links') ->where('care_context_reference', $careContextRef) - ->where('abha_id', $abhaId) ->orderBy('id', 'DESC') ->get(1) ->getRowArray(); - $linkStatus = $status === 'linked' ? 'linked' : 'failed'; - $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $existingStatus = strtolower(trim((string) ($existing['link_status'] ?? ''))); + $linkStatus = M2LinkStatusResolver::merge($existingStatus, $incomingLinkStatus); + $persistedLinkStatus = $linkStatus; + $linkData = [ + 'health_record_id' => ! empty($healthRecord) ? (int) $healthRecord['id'] : null, + 'abdm_txn_id' => $requestId !== '' ? $requestId : null, + 'link_status' => $linkStatus, + 'linked_at' => $linkStatus === 'linked' ? ($linkedAt !== '' ? $linkedAt : $now) : null, + 'updated_at' => $now, + 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; if (! empty($existing)) { - $this->db->table('record_links')->where('id', (int) $existing['id'])->update([ - 'link_status' => $linkStatus, - 'linked_at' => $linkedAt !== '' ? $linkedAt : $now, - 'updated_at' => $now, - 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ]); + if ($existingStatus === 'linked') { + unset($linkData['linked_at']); + } + $this->db->table('record_links')->where('id', (int) $existing['id'])->update($linkData); } else { - $this->db->table('record_links')->insert([ + $this->db->table('record_links')->insert(array_merge($linkData, [ 'abha_id' => $abhaId, 'care_context_reference' => $careContextRef, - 'link_status' => $linkStatus, - 'linked_at' => $linkedAt !== '' ? $linkedAt : $now, 'created_at' => $now, - 'updated_at' => $now, - 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ]); + ])); } } - return $this->response->setJSON(['ok' => 1]); + if (! empty($healthRecord)) { + $currentPushStatus = strtolower(trim((string) ($healthRecord['push_status'] ?? ''))); + if ($currentPushStatus === 'linked') { + $persistedLinkStatus = 'linked'; + } + if ($currentPushStatus !== 'linked' && in_array($incomingLinkStatus, ['linked', 'failed'], true)) { + $healthRecordData = [ + 'push_status' => $incomingLinkStatus, + 'updated_at' => $now, + ]; + if ($incomingLinkStatus === 'linked') { + $healthRecordData['linked_at'] = $linkedAt !== '' ? $linkedAt : $now; + } + $this->db->table('health_records')->where('id', (int) $healthRecord['id'])->update($healthRecordData); + } + + if ($incomingLinkStatus === 'linked' && $this->db->tableExists('abdm_work_tasks')) { + $this->db->table('abdm_work_tasks') + ->where('entity_type', (string) ($healthRecord['entity_type'] ?? '')) + ->where('entity_id', (string) ($healthRecord['entity_id'] ?? '')) + ->whereIn('status', ['pending', 'in_progress']) + ->update([ + 'status' => 'completed', + 'last_action_result' => 'Care context link confirmed by M2 callback.', + 'completed_at' => $linkedAt !== '' ? $linkedAt : $now, + 'updated_at' => $now, + ]); + } + } + $this->db->transComplete(); + + return $this->response->setJSON([ + 'ok' => $this->db->transStatus() ? 1 : 0, + 'request_id' => $requestId, + 'care_context_reference' => $careContextRef, + 'status' => $persistedLinkStatus, + ]); } public function consentRequest() @@ -1130,10 +1331,6 @@ class AbdmGateway extends BaseController ]); }; - if ($abhaId === '' && $abhaAddressPost !== '') { - $abhaId = $abhaAddressPost; - } - if ($opdId <= 0 || $patientId <= 0) { $logBridge('error', ['ok' => 0, 'error_text' => 'opd_id and patient_id are required'], 'opd_id and patient_id are required', 'abdm.opd.prescription.share.validation'); return $this->response->setJSON(['ok' => 0, 'error_text' => 'opd_id and patient_id are required']); @@ -1144,13 +1341,17 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error_text' => 'opd_fhir_documents table not found']); } - $hasAbha = $abhaId !== '' || $abhaAddressPost !== ''; + $abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId, $abhaAddressPost); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + $abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber; + $hasAbha = $abhaNumber !== '' || $abhaAddress !== ''; $consent = null; $consentWarning = ''; $consentEventType = 'abdm.opd.prescription.share.consent_missing'; $consentLogStatus = 'warning'; if ($hasAbha) { - $consent = $this->getActiveConsentRecord($patientId, $abhaId !== '' ? $abhaId : $abhaAddressPost, $consentHandle); + $consent = $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle); if ($consent === null) { if ($pushToGateway) { $consentWarning = 'No active consent found. Proceeding with care-context push only.'; @@ -1192,59 +1393,8 @@ class AbdmGateway extends BaseController $bundle = ['raw' => $bundleJson]; } - // Resolve ABHA identifiers from request + patient profile. - $abhaAddress = str_contains($abhaId, '@') ? $abhaId : ''; - $abhaNumber = ''; - if ($abhaAddressPost !== '' && str_contains($abhaAddressPost, '@')) { - $abhaAddress = $abhaAddressPost; - } - if ($abhaAddress === '') { - $digits = preg_replace('/\D/', '', $abhaId); - if (strlen((string) $digits) === 14) { - $abhaNumber = (string) $digits; - } - } - - $patientName = ''; - $patientRow = []; - if ($this->db->tableExists('patient_master')) { - $pmFields = $this->db->getFieldNames('patient_master') ?? []; - $pmSelect = ['id']; - foreach (['p_fname', 'p_lname', 'dob', 'p_dob', 'birth_date', 'date_of_birth', 'birth_year', 'year_of_birth', 'age', 'p_age', 'abha_address', 'abha_id', 'abha_no', 'abha'] as $f) { - if (in_array($f, $pmFields, true)) { - $pmSelect[] = $f; - } - } - - $patientRow = $this->db->table('patient_master') - ->select(implode(',', array_unique($pmSelect))) - ->where('id', $patientId) - ->get(1) - ->getRowArray() ?? []; - - $patientName = $this->patientDisplayName($patientRow); - - if ($abhaAddress === '') { - foreach (['abha_address', 'abha', 'abha_id', 'abha_no'] as $field) { - $value = trim((string) ($patientRow[$field] ?? '')); - if ($value !== '' && str_contains($value, '@')) { - $abhaAddress = $value; - break; - } - } - } - - if ($abhaNumber === '') { - foreach (['abha_id', 'abha_no', 'abha'] as $field) { - $value = trim((string) ($patientRow[$field] ?? '')); - $digits = preg_replace('/\D/', '', $value); - if (strlen((string) $digits) === 14) { - $abhaNumber = (string) $digits; - break; - } - } - } - } + $patientRow = $this->loadPatientRow($patientId); + $patientName = $this->patientDisplayName($patientRow); $patientBirthYear = $this->resolvePatientBirthYear($patientRow, $abhaAddress, $abhaNumber); @@ -1534,13 +1684,18 @@ class AbdmGateway extends BaseController $ipdId = (int) $this->request->getPost('ipd_id'); $patientId = (int) $this->request->getPost('patient_id'); $abhaId = trim((string) $this->request->getPost('abha_id')); + $abhaAddressPost = trim((string) $this->request->getPost('abha_address')); $consentHandle = trim((string) $this->request->getPost('consent_handle')); if ($ipdId <= 0 || $patientId <= 0) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'ipd_id and patient_id are required']); } - $hasAbha = $abhaId !== ''; + $abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId, $abhaAddressPost); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + $abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber; + $hasAbha = $abhaNumber !== '' || $abhaAddress !== ''; $consent = null; $consentWarning = ''; if ($hasAbha) { @@ -1565,6 +1720,7 @@ class AbdmGateway extends BaseController $ccRef = (string) ($fhirPayload['care_context_reference'] ?? ('IPD-' . $ipdId . '-' . $visitDate)); $ccDisplay = (string) ($fhirPayload['care_context_display'] ?? ('Discharge Summary ' . $visitDate)); $bundle = (array) ($fhirPayload['bundle'] ?? []); + $attachmentPath = trim((string) ($fhirPayload['attachment_path'] ?? '')); $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // ── Store health_record ─────────────────────────────────────────────── @@ -1577,6 +1733,7 @@ class AbdmGateway extends BaseController 'fhir_bundle' => $bundleJson, 'care_context_reference' => $ccRef, 'consent_handle' => $effectiveConsent, + 'attachment_path' => $attachmentPath, ]); if (! $hasAbha) { @@ -1616,7 +1773,8 @@ class AbdmGateway extends BaseController $result = $this->connector->pushRecord([ 'patient_id' => (string) $patientId, 'patient_name' => $patName !== '' ? $patName : ('PATIENT-' . $patientId), - 'abha_id' => $abhaId, + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, 'year_of_birth' => $birthYear, 'hi_type' => 'DischargeSummaryRecord', 'record_type' => 'DischargeSummaryRecord', @@ -1677,6 +1835,7 @@ class AbdmGateway extends BaseController $labReqId = (int) ($this->request->getPost('lab_req_id') ?? 0); $patientId = (int) ($this->request->getPost('patient_id') ?? 0); $abhaId = trim((string) ($this->request->getPost('abha_id') ?? '')); + $abhaAddressPost = trim((string) ($this->request->getPost('abha_address') ?? '')); $consentHandle = trim((string) ($this->request->getPost('consent_handle') ?? '')); if ($labReqId <= 0 || $patientId <= 0) { @@ -1694,7 +1853,11 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error' => 'Lab request not found']); } - $hasAbha = $abhaId !== ''; + $abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId, $abhaAddressPost); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + $abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber; + $hasAbha = $abhaNumber !== '' || $abhaAddress !== ''; $consentRecord = null; $consentWarning = ''; if ($hasAbha) { @@ -1859,7 +2022,8 @@ class AbdmGateway extends BaseController $result = $this->connector->pushRecord([ 'patient_id' => (string) $patientId, 'patient_name' => $patName, - 'abha_id' => $abhaId, + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, 'year_of_birth' => $patientBirthYear, 'hi_type' => 'DiagnosticReportRecord', 'record_type' => 'DiagnosticReportRecord', @@ -2087,16 +2251,19 @@ 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 = $this->resolveGatewayPushMode(); - if ($abhaId === '' && $abhaAddressPost !== '') { - $abhaId = $abhaAddressPost; - } if ($patientId <= 0) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']); } + $abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId, $abhaAddressPost); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + $abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber; if ($abhaId === '') { - $abhaId = $this->resolvePatientAbhaIdentifier($patientId); + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error_text' => 'ABHA ID is required to push ImmunizationRecord to ABDM Bridge.', + ]); } $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId); @@ -2106,7 +2273,7 @@ class AbdmGateway extends BaseController $bundle = (array) ($payload['bundle'] ?? []); $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $ccRef = (string) ($payload['care_context_reference'] ?? ('IMM-' . $patientId . '-' . date('Y-m-d'))); + $ccRef = (string) ($payload['care_context_reference'] ?? ('IMM-' . ($recordId > 0 ? $recordId : ('PAT-' . $patientId)))); $visitDate = (string) ($payload['visit_date'] ?? date('Y-m-d')); $patientName = (string) ($payload['patient_name'] ?? ('PATIENT-' . $patientId)); @@ -2122,54 +2289,27 @@ class AbdmGateway extends BaseController 'fhir_bundle' => $bundleJson, 'care_context_reference' => $ccRef, 'consent_handle' => $effectiveConsent, + 'reuse_existing' => true, ]); - - if ($abhaId === '') { - if ($healthRecordId > 0 && $this->db->tableExists('health_records')) { - $this->db->table('health_records')->where('id', $healthRecordId)->update([ - 'push_status' => 'local_only', - 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), - ]); - } - - return $this->response->setJSON([ - 'ok' => 1, - 'status' => 'local_stored', - 'health_record_id' => $healthRecordId, + if ($healthRecordId <= 0) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error_text' => 'Unable to persist ImmunizationRecord for M2 discovery.', 'care_context_reference' => $ccRef, - 'message' => 'ABHA not available. Immunization record stored locally for later discovery.', - ]); - } - - if (! $pushToGateway) { - if ($healthRecordId > 0 && $this->db->tableExists('health_records')) { - $this->db->table('health_records')->where('id', $healthRecordId)->update([ - 'push_status' => 'local_discovery_ready', - 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), - ]); - } - - return $this->response->setJSON([ - 'ok' => 1, - 'status' => 'local_discovery_ready', - 'health_record_id' => $healthRecordId, - 'care_context_reference' => $ccRef, - 'consent_handle' => $effectiveConsent, - 'mode' => 'm2_hms_source', - 'message' => 'Immunization record registered in HMS for ABDM discovery/fetch callbacks.', ]); } $queueId = null; $bridgeRecordId = 0; + $firstPushedAt = ''; $connectorError = null; $bridgeResponse = []; try { $result = $this->connector->pushRecord([ 'patient_id' => (string) $patientId, 'patient_name' => $patientName, - 'abha_id' => preg_match('/^\d{14}$/', $abhaId) === 1 ? $abhaId : '', - 'abha_address' => str_contains($abhaId, '@') ? $abhaId : '', + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, 'year_of_birth' => (int) ($payload['year_of_birth'] ?? 0), 'hi_type' => 'ImmunizationRecord', 'record_type' => 'ImmunizationRecord', @@ -2184,6 +2324,7 @@ class AbdmGateway extends BaseController $bridgeResponse = $result; $queueId = $this->extractGatewayPushQueueId($result); $bridgeRecordId = $this->extractGatewayPushRecordId($result); + $firstPushedAt = $this->extractGatewayPushFirstPushedAt($result); if (! $this->isGatewayPushSubmitted($result)) { $connectorError = $this->extractGatewayPushErrorText($result); @@ -2199,7 +2340,7 @@ class AbdmGateway extends BaseController } if ($healthRecordId > 0) { - $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError, $bridgeRecordId); + $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError, $bridgeRecordId, $firstPushedAt); } $this->getAuditService()->log([ @@ -2219,6 +2360,25 @@ class AbdmGateway extends BaseController 'error_message' => (string) ($connectorError ?? ''), ]); + if ($this->db->tableExists('abdm_work_tasks')) { + $taskStatus = $connectorError === null ? 'in_progress' : 'failed'; + $taskResult = $connectorError === null + ? 'Submitted to ABDM Bridge at ' . $ccRef + . ($queueId ? '; queue ID ' . $queueId : '') + . ($bridgeRecordId > 0 ? '; record ID ' . $bridgeRecordId : '') . '.' + : 'ABDM Bridge push failed for ' . $ccRef . ': ' . $connectorError; + $this->db->table('abdm_work_tasks') + ->where('task_type', 'immunization_record_publish') + ->where('entity_type', 'immunization') + ->where('entity_id', (string) $recordId) + ->whereIn('status', ['pending', 'in_progress', 'failed']) + ->update([ + 'status' => $taskStatus, + 'last_action_result' => $taskResult, + 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + return $this->response->setJSON([ 'ok' => $connectorError === null ? 1 : 0, 'queue_id' => $queueId, @@ -2311,6 +2471,85 @@ class AbdmGateway extends BaseController return $this->pushAdditionalHiRecord($payload, (int) $payload['patient_id'], $abhaId, $consentHandle); } + public function invoiceFhirPreview() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(403)->setJSON(['status' => 'error', 'message' => 'AJAX only']); + } + + $source = strtolower(trim((string) $this->request->getGet('source'))); + $billId = (int) $this->request->getGet('bill_id'); + $patientId = (int) $this->request->getGet('patient_id'); + $abhaId = trim((string) $this->request->getGet('abha_id')); + + if (! in_array($source, ['opd_invoice', 'charges_invoice', 'ipd_invoice'], true) || $billId <= 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 'error', + 'message' => 'A valid source and bill_id are required.', + ]); + } + + try { + $payload = $this->buildInvoiceSourceRecordPayload($source, $billId, $patientId, $abhaId); + } catch (\RuntimeException $e) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 'error', + 'message' => $e->getMessage(), + ]); + } + if ($payload === null) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 'error', + 'message' => 'Invoice not found or patient could not be resolved.', + ]); + } + + return $this->response->setJSON([ + 'status' => 'ok', + 'source' => $source, + 'bill_id' => $billId, + 'patient_id' => (int) $payload['patient_id'], + 'hi_type' => 'InvoiceRecord', + 'care_context_reference' => (string) $payload['care_context_reference'], + 'bundle' => (array) $payload['bundle'], + ]); + } + + public function shareInvoiceSourceBundle() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $source = strtolower(trim((string) $this->request->getPost('source'))); + $billId = (int) $this->request->getPost('bill_id'); + $patientId = (int) $this->request->getPost('patient_id'); + $abhaId = trim((string) ($this->request->getPost('abha_id') ?? $this->request->getPost('abha_address') ?? '')); + $consentHandle = trim((string) $this->request->getPost('consent_handle')); + + if (! in_array($source, ['opd_invoice', 'charges_invoice', 'ipd_invoice'], true) || $billId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'A valid source and bill_id are required']); + } + + try { + $payload = $this->buildInvoiceSourceRecordPayload($source, $billId, $patientId, $abhaId); + } catch (\RuntimeException $e) { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error_text' => $e->getMessage(), + ]); + } + if ($payload === null) { + return $this->response->setStatusCode(404)->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); + } + public function ipdDischargeFhirPreview() { if (! $this->request->isAJAX()) { @@ -2370,6 +2609,16 @@ class AbdmGateway extends BaseController $doctorName = trim((string) ($ipdRow['r_doc_name'] ?? '')); $doctorId = (int) ($ipdRow['r_doc_id'] ?? 0); + if ($doctorId <= 0 && $this->db->tableExists('ipd_master_doc_list')) { + $doctorLink = $this->db->table('ipd_master_doc_list') + ->select('doc_id') + ->where('ipd_id', $ipdId) + ->where('doc_id >', 0) + ->orderBy('id', 'ASC') + ->get(1) + ->getRowArray(); + $doctorId = (int) ($doctorLink['doc_id'] ?? 0); + } $doctorRegNo = ''; if ($doctorId > 0 && $this->db->tableExists('doctor_master')) { $dFields = $this->db->getFieldNames('doctor_master') ?? []; @@ -2609,9 +2858,11 @@ class AbdmGateway extends BaseController $hospital = $this->getHospitalProfileForFhir(); $hfrId = trim((string) ($hospital['hfr_id'] ?? '')); + $documents = $this->buildIpdPdfDocuments($ipdId, $dischargeRaw); $source = [ 'record_id' => (string) $ipdId, + 'bundle_identifier' => 'discharge-' . (trim((string) ($ipdRow['ipd_code'] ?? '')) ?: (string) $ipdId), 'session_id' => (string) $ipdId, 'visit_date' => $visitDate, 'completed_at' => $this->toIsoDateTimeOrNow($dischargeRaw), @@ -2635,7 +2886,8 @@ class AbdmGateway extends BaseController 'abha_address' => $abhaAddress, ], 'encounter' => [ - 'id' => (string) $ipdId, + 'id' => trim((string) ($ipdRow['ipd_code'] ?? '')) ?: (string) $ipdId, + 'class_code' => 'IMP', 'start' => $this->toIsoDateTimeOrNow($admissionRaw), 'end' => $this->toIsoDateTimeOrNow($dischargeRaw), ], @@ -2646,6 +2898,7 @@ class AbdmGateway extends BaseController 'investigations' => $investigations, 'allergies' => $allergies, 'care_plans' => $carePlans, + 'documents' => $documents, ]; $factory = new FhirGeneratorFactory(); @@ -2660,9 +2913,40 @@ class AbdmGateway extends BaseController 'doctor_name' => (string) ($payload['doctor_name'] ?? $doctorName), 'visit_date' => (string) ($payload['visit_date'] ?? $visitDate), 'ipd_row' => $ipdRow, + 'attachment_path' => (string) ($documents[0]['path'] ?? ''), ]; } + /** @return array> */ + private function buildIpdPdfDocuments(int $ipdId, string $dischargeRaw): array + { + $directory = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'abdm' . DIRECTORY_SEPARATOR . 'ipd' . DIRECTORY_SEPARATOR . $ipdId; + $createdAt = $this->toIsoDateTimeOrNow($dischargeRaw); + $documents = []; + foreach ([ + ['file' => 'discharge-summary.pdf', 'title' => 'IPD Discharge Summary', 'loinc' => '18842-5'], + ] as $definition) { + $path = $directory . DIRECTORY_SEPARATOR . $definition['file']; + if (! is_file($path) || filesize($path) === 0) { + continue; + } + $binary = file_get_contents($path); + if (! is_string($binary) || ! str_starts_with($binary, '%PDF-')) { + continue; + } + $documents[] = [ + 'title' => $definition['title'], + 'loinc_code' => $definition['loinc'], + 'content_type' => 'application/pdf', + 'data' => base64_encode($binary), + 'created_at' => $createdAt, + 'path' => $path, + ]; + } + + return $documents; + } + /** * @return array|null */ @@ -2678,7 +2962,15 @@ class AbdmGateway extends BaseController } $recordsBuilder = $this->db->table('immunization_records r') - ->select('r.*, s.series_name, s.series_doses, s.age_label, v.target_disease_code, v.target_disease_name') + ->select("r.*, s.series_name, s.series_doses, s.age_label, + COALESCE(NULLIF(r.vaccine_code, ''), v.vaccine_code) AS vaccine_code, + COALESCE(NULLIF(r.vaccine_code_system, ''), v.vaccine_code_system) AS vaccine_code_system, + COALESCE(NULLIF(r.vaccine_name, ''), NULLIF(v.vaccine_display, ''), v.vaccine_name) AS vaccine_name, + COALESCE(NULLIF(r.route_code, ''), v.route_code) AS route_code, + COALESCE(NULLIF(r.route_name, ''), v.route_name) AS route_name, + COALESCE(NULLIF(r.site_code, ''), v.site_code) AS site_code, + COALESCE(NULLIF(r.site_name, ''), v.site_name) AS site_name, + v.target_disease_code, v.target_disease_name", false) ->join('immunization_schedule_master s', 's.id = r.schedule_id', 'left') ->join('immunization_vaccine_master v', 'v.id = r.vaccine_master_id', 'left') ->where('r.patient_id', $patientId) @@ -2739,7 +3031,16 @@ class AbdmGateway extends BaseController } } $visitDate = $latestDate !== '' ? date('Y-m-d', strtotime($latestDate)) : date('Y-m-d'); - $ccRef = $recordId > 0 ? ('IMM-' . $recordId . '-' . $visitDate) : ('IMM-' . $patientId . '-' . $visitDate); + $ccRef = self::resolveImmunizationCareContextReference($records, $recordId, $patientId); + if ($recordId > 0 && trim((string) ($records[0]['abdm_care_context_reference'] ?? '')) === '') { + $this->db->table('immunization_records') + ->where('id', $recordId) + ->groupStart() + ->where('abdm_care_context_reference', null) + ->orWhere('abdm_care_context_reference', '') + ->groupEnd() + ->update(['abdm_care_context_reference' => $ccRef]); + } $birthDate = ''; foreach (['dob', 'birth_date', 'date_of_birth', 'p_dob'] as $field) { @@ -2778,6 +3079,36 @@ class AbdmGateway extends BaseController ]; } + /** + * @param array> $records + */ + private static function resolveImmunizationCareContextReference(array $records, int $recordId, int $patientId): string + { + if ($recordId > 0) { + $storedReference = trim((string) ($records[0]['abdm_care_context_reference'] ?? '')); + return $storedReference !== '' ? $storedReference : 'IMM-' . $recordId; + } + + $sourceIds = []; + foreach ($records as $record) { + $sourceId = (int) ($record['id'] ?? 0); + if ($sourceId > 0) { + $sourceIds[] = $sourceId; + } + } + $sourceIds = array_values(array_unique($sourceIds)); + sort($sourceIds, SORT_NUMERIC); + + if (count($sourceIds) === 1) { + return 'IMM-' . $sourceIds[0]; + } + if ($sourceIds !== []) { + return 'IMM-SET-' . substr(hash('sha256', implode(',', $sourceIds)), 0, 20); + } + + return 'IMM-PAT-' . $patientId; + } + /** * @param array $columns * @return array> @@ -2839,13 +3170,13 @@ class AbdmGateway extends BaseController private function normalizeFhirGender(string $gender): string { $value = strtolower(trim($gender)); - if ($value === 'm' || $value === 'male') { + if ($value === '1' || $value === 'm' || $value === 'male') { return 'male'; } - if ($value === 'f' || $value === 'female') { + if ($value === '2' || $value === 'f' || $value === 'female') { return 'female'; } - if ($value === 'other') { + if ($value === '3' || $value === 'other') { return 'other'; } return 'unknown'; @@ -3335,6 +3666,14 @@ class AbdmGateway extends BaseController // Try from POST param as override $abhaId = trim((string) $this->request->getPost('abha_id')); } + $abhaIdentity = $this->resolvePatientAbhaIdentity( + $patientId, + $abhaId, + trim((string) $this->request->getPost('abha_address')) + ); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + $abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber; // Prefer the plain stored bundle; fall back to the encrypted copy for older rows. $storedPayload = []; @@ -3383,7 +3722,8 @@ class AbdmGateway extends BaseController $result = $this->connector->pushRecord([ 'patient_id' => (string) $patientId, 'patient_name' => $sanitizedPatientName !== '' ? $sanitizedPatientName : ('PATIENT-' . $patientId), - 'abha_id' => $abhaId, + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, 'year_of_birth' => $patientBirthYear, 'hi_type' => $hiType, 'record_type' => $this->mapHiTypeToRecordType($hiType), @@ -4615,7 +4955,8 @@ class AbdmGateway extends BaseController * Returns 0 when the table is absent or on any DB error (fail-open). * * @param array{patient_id: int, abha_id: string, hi_type: string, entity_type: string, - * entity_id: string, fhir_bundle: string, care_context_reference?: string, consent_handle?: string} $data + * entity_id: string, fhir_bundle: string, care_context_reference?: string, consent_handle?: string, + * attachment_path?: string, reuse_existing?: bool} $data */ private function storeHealthRecord(array $data): int { @@ -4655,6 +4996,29 @@ class AbdmGateway extends BaseController if (in_array('care_context_reference', $hrFields, true)) { $insert['care_context_reference'] = trim((string) ($data['care_context_reference'] ?? '')) ?: null; } + if (in_array('attachment_path', $hrFields, true)) { + $insert['attachment_path'] = trim((string) ($data['attachment_path'] ?? '')) ?: null; + } + + $careContextReference = trim((string) ($data['care_context_reference'] ?? '')); + if (($data['reuse_existing'] ?? false) === true && $careContextReference !== '') { + $existing = $this->db->table('health_records') + ->select('id') + ->where('hi_type', (string) ($data['hi_type'] ?? 'unknown')) + ->where('entity_type', (string) ($data['entity_type'] ?? '')) + ->where('entity_id', (string) ($data['entity_id'] ?? '')) + ->where('care_context_reference', $careContextReference) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($existing)) { + $existingId = (int) ($existing['id'] ?? 0); + $update = $insert; + unset($update['push_status'], $update['push_at'], $update['created_at']); + $this->db->table('health_records')->where('id', $existingId)->update($update); + return $existingId; + } + } $this->db->table('health_records')->insert($insert); $insertId = (int) $this->db->insertID(); @@ -4893,17 +5257,16 @@ class AbdmGateway extends BaseController private function isGatewayPushDuplicate(array $result): bool { $httpCode = $this->extractGatewayPushHttpCode($result); - if ($httpCode === 409) { - return true; - } - $codes = [ $result['error_code'] ?? null, + is_array($result['error'] ?? null) ? ($result['error']['code'] ?? null) : ($result['error'] ?? null), $result['response']['error_code'] ?? null, + is_array($result['response']['error'] ?? null) ? ($result['response']['error']['code'] ?? null) : ($result['response']['error'] ?? null), $result['data']['error_code'] ?? null, + is_array($result['data']['error'] ?? null) ? ($result['data']['error']['code'] ?? null) : ($result['data']['error'] ?? null), ]; foreach ($codes as $code) { - if (strtoupper(trim((string) $code)) === 'DUPLICATE_RECORD') { + if ($httpCode === 409 && strtoupper(trim((string) $code)) === 'DUPLICATE_RECORD') { return true; } } @@ -4919,7 +5282,7 @@ class AbdmGateway extends BaseController private function isGatewayPushSubmitted(array $result): bool { $httpCode = $this->extractGatewayPushHttpCode($result); - if ($httpCode === 201 || $httpCode === 409) { + if ($httpCode === 201) { return true; } @@ -4969,6 +5332,25 @@ class AbdmGateway extends BaseController return ''; } + /** + * @param array $result + */ + private function extractGatewayPushFirstPushedAt(array $result): string + { + foreach ([ + $result['first_pushed_at'] ?? null, + $result['response']['first_pushed_at'] ?? null, + $result['data']['first_pushed_at'] ?? null, + ] as $value) { + $timestamp = trim((string) $value); + if ($timestamp !== '') { + return $timestamp; + } + } + + return ''; + } + /** * Emit one diagnostic line to show exactly which gateway response fields * supplied queue/record identifiers for records/push integration. @@ -5009,7 +5391,7 @@ class AbdmGateway extends BaseController * @param string|null $error Connector error message (null = success) * @param int $bridgeRecordId Bridge record_id (from POST /v3/records/push response) */ - private function updateHealthRecordTxn(int $healthRecordId, string $queueId, ?string $error, int $bridgeRecordId = 0): void + private function updateHealthRecordTxn(int $healthRecordId, string $queueId, ?string $error, int $bridgeRecordId = 0, string $firstPushedAt = ''): void { try { $now = Time::now('Asia/Kolkata')->toDateTimeString(); @@ -5020,12 +5402,12 @@ class AbdmGateway extends BaseController 'push_status' => $error === null ? 'queued' : 'failed', 'updated_at' => $now, ]; - // Store bridge record_id when the column exists (migration adds it) - if ($bridgeRecordId > 0) { - $hrFields = $this->db->getFieldNames('health_records') ?? []; - if (in_array('bridge_record_id', $hrFields, true)) { - $hrUpdate['bridge_record_id'] = $bridgeRecordId; - } + $hrFields = $this->db->getFieldNames('health_records') ?? []; + if ($bridgeRecordId > 0 && in_array('bridge_record_id', $hrFields, true)) { + $hrUpdate['bridge_record_id'] = $bridgeRecordId; + } + if ($firstPushedAt !== '' && in_array('first_pushed_at', $hrFields, true)) { + $hrUpdate['first_pushed_at'] = $firstPushedAt; } $this->db->table('health_records') ->where('id', $healthRecordId) @@ -5560,6 +5942,10 @@ class AbdmGateway extends BaseController private function pushAdditionalHiRecord(array $payload, int $patientId, string $abhaId, string $consentHandle) { + $abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + $abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber; $hiType = (string) ($payload['hi_type'] ?? 'HealthDocumentRecord'); $entityType = (string) ($payload['entity_type'] ?? strtolower($hiType)); $entityId = (string) ($payload['entity_id'] ?? $patientId); @@ -5619,8 +6005,8 @@ class AbdmGateway extends BaseController $result = $this->connector->pushRecord([ 'patient_id' => (string) $patientId, 'patient_name' => $patientName, - 'abha_id' => preg_match('/^\d{14}$/', $abhaId) === 1 ? $abhaId : '', - 'abha_address' => str_contains($abhaId, '@') ? $abhaId : '', + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, 'year_of_birth' => $patientBirthYear, 'hi_type' => $hiType, 'record_type' => $hiType, @@ -5803,30 +6189,202 @@ class AbdmGateway extends BaseController ]; } + private function buildInvoiceSourceRecordPayload(string $source, int $billId, int $patientId, string $abhaId): ?array + { + $invoice = []; + $items = []; + $sourcePrefix = ''; + + if ($source === 'opd_invoice' && $this->db->tableExists('opd_master')) { + $row = $this->db->table('opd_master')->where('opd_id', $billId)->get(1)->getRowArray() ?? []; + if ($row !== []) { + $amount = (float) ($row['opd_fee_amount'] ?? 0); + $invoice = [ + 'id' => $billId, + 'invoice_code' => (string) ($row['opd_code'] ?? $billId), + 'invoice_type_code' => '03', + 'invoice_type_display' => 'OPD', + 'encounter_class' => 'AMB', + 'practitioner_id' => (int) ($row['doc_id'] ?? 0), + 'practitioner_name' => trim((string) ($row['doc_name'] ?? '')), + 'encounter_start' => (string) ($row['apointment_date'] ?? $row['opd_book_date'] ?? ''), + 'inv_date' => (string) ($row['apointment_date'] ?? $row['opd_book_date'] ?? date('Y-m-d')), + 'net_amount' => $amount, + 'total_amount' => (float) ($row['opd_fee_gross_amount'] ?? $amount), + 'patient_id' => (int) ($row['p_id'] ?? 0), + ]; + $items[] = [ + 'item_name' => trim((string) ($row['opd_fee_desc'] ?? '')) ?: 'OPD Consultation Fee', + 'item_qty' => 1, + 'item_rate' => $amount, + 'item_amount' => $amount, + ]; + $sourcePrefix = 'OPD'; + } + } elseif ($source === 'charges_invoice' && $this->db->tableExists('invoice_master')) { + $row = $this->db->table('invoice_master')->where('id', $billId)->get(1)->getRowArray() ?? []; + if ($row !== []) { + $contextRow = []; + $opdCode = trim((string) ($row['opd_code'] ?? '')); + if ($opdCode !== '' && $opdCode !== '0' && $this->db->tableExists('opd_master')) { + $contextRow = $this->db->table('opd_master')->where('opd_code', $opdCode)->get(1)->getRowArray() ?? []; + } elseif ((int) ($row['ipd_id'] ?? 0) > 0 && $this->db->tableExists('ipd_master')) { + $contextRow = $this->db->table('ipd_master')->where('id', (int) $row['ipd_id'])->get(1)->getRowArray() ?? []; + } + $isIpdContext = isset($contextRow['ipd_code']); + $invoice = [ + 'id' => $billId, + 'invoice_code' => (string) ($row['invoice_code'] ?? $billId), + 'invoice_type_code' => '99', + 'invoice_type_display' => 'Others', + 'encounter_class' => $isIpdContext ? 'IMP' : 'AMB', + 'practitioner_id' => (int) ($contextRow[$isIpdContext ? 'r_doc_id' : 'doc_id'] ?? 0), + 'practitioner_name' => trim((string) ($contextRow[$isIpdContext ? 'r_doc_name' : 'doc_name'] ?? '')), + 'encounter_start' => (string) ($contextRow[$isIpdContext ? 'register_date' : 'apointment_date'] ?? $row['inv_date'] ?? ''), + 'encounter_end' => (string) ($contextRow['discharge_date'] ?? $row['inv_date'] ?? ''), + 'inv_date' => (string) ($row['inv_date'] ?? date('Y-m-d')), + 'net_amount' => (float) ($row['net_amount'] ?? 0), + 'total_amount' => (float) ($row['total_amount'] ?? $row['net_amount'] ?? 0), + 'patient_id' => (int) ($row['attach_id'] ?? 0), + ]; + $items = $this->db->tableExists('invoice_item') + ? $this->db->table('invoice_item')->where('inv_master_id', $billId)->orderBy('id', 'ASC')->get()->getResultArray() + : []; + $sourcePrefix = 'CHG'; + } + } elseif ($source === 'ipd_invoice' && $this->db->tableExists('ipd_master')) { + $row = $this->db->table('ipd_master')->where('id', $billId)->get(1)->getRowArray() ?? []; + if ($row !== []) { + $invoice = [ + 'id' => $billId, + 'invoice_code' => (string) ($row['ipd_code'] ?? $billId), + 'invoice_type_code' => '02', + 'invoice_type_display' => 'IPD', + 'encounter_class' => 'IMP', + 'practitioner_id' => (int) ($row['r_doc_id'] ?? 0), + 'practitioner_name' => trim((string) ($row['r_doc_name'] ?? '')), + 'encounter_start' => (string) ($row['register_date'] ?? ''), + 'encounter_end' => (string) ($row['discharge_date'] ?? ''), + 'inv_date' => (string) ($row['discharge_date'] ?? $row['register_date'] ?? date('Y-m-d')), + 'net_amount' => (float) ($row['net_amount'] ?? 0), + 'total_amount' => (float) ($row['gross_amount'] ?? $row['net_amount'] ?? 0), + 'patient_id' => (int) ($row['p_id'] ?? 0), + ]; + + if ($this->db->tableExists('ipd_invoice_item')) { + $items = $this->db->table('ipd_invoice_item') + ->select('item_name, item_qty, item_rate, item_amount') + ->where('ipd_id', $billId) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + } + if ($this->db->tableExists('invoice_master') && $this->db->tableExists('invoice_item')) { + $chargeItems = $this->db->table('invoice_master i') + ->select('t.item_name, t.item_qty, t.item_rate, t.item_amount') + ->join('invoice_item t', 't.inv_master_id = i.id') + ->where('i.ipd_id', $billId) + ->where('i.ipd_include', 1) + ->orderBy('t.id', 'ASC') + ->get() + ->getResultArray(); + $items = array_merge($items, $chargeItems); + } + if ($this->db->tableExists('invoice_med_master')) { + $medicineBills = $this->db->table('invoice_med_master') + ->select('inv_med_code, net_amount') + ->where('ipd_id', $billId) + ->where('ipd_credit', 1) + ->where('ipd_credit_type', 1) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + foreach ($medicineBills as $medicineBill) { + $amount = (float) ($medicineBill['net_amount'] ?? 0); + $items[] = [ + 'item_name' => 'Pharmacy ' . (string) ($medicineBill['inv_med_code'] ?? ''), + 'item_qty' => 1, + 'item_rate' => $amount, + 'item_amount' => $amount, + ]; + } + } + $sourcePrefix = 'IPD'; + } + } + + $resolvedPatientId = (int) ($invoice['patient_id'] ?? 0); + if ($invoice === [] || $resolvedPatientId <= 0 || ($patientId > 0 && $patientId !== $resolvedPatientId)) { + return null; + } + + $patientRow = $this->loadPatientRow($resolvedPatientId); + if ($patientRow === []) { + return null; + } + + if ($abhaId === '') { + $abhaId = $this->resolvePatientAbhaIdentifier($resolvedPatientId); + } + $visitTimestamp = strtotime((string) ($invoice['inv_date'] ?? '')); + $visitDate = $visitTimestamp !== false ? date('Y-m-d', $visitTimestamp) : date('Y-m-d'); + $patient = $this->buildAbdmPatientResource($patientRow, $resolvedPatientId, $abhaId); + + return [ + 'hi_type' => 'InvoiceRecord', + 'entity_type' => $source, + 'entity_id' => (string) $billId, + 'patient_id' => $resolvedPatientId, + 'patient_name' => $this->patientDisplayName($patientRow), + 'visit_date' => $visitDate, + 'care_context_reference' => 'INVOICE-' . $sourcePrefix . '-' . $billId . '-' . $visitDate, + 'care_context_display' => 'Invoice ' . (string) ($invoice['invoice_code'] ?? $billId), + 'bundle' => $this->buildSimpleInvoiceBundle($patient, $invoice, $items), + ]; + } + /** * 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 { + $identity = $this->resolvePatientAbhaIdentity($patientId); + + return $identity['abha_address'] !== '' ? $identity['abha_address'] : $identity['abha_id']; + } + + /** @return array{abha_id:string,abha_address:string} */ + private function resolvePatientAbhaIdentity(int $patientId, string $abhaId = '', string $abhaAddress = ''): array + { + $number = ''; + $address = ''; $row = $this->loadPatientRow($patientId); - if ($row === []) { - return ''; - } + $candidates = [ + $abhaAddress, + $abhaId, + (string) ($row['abha_address'] ?? ''), + (string) ($row['abha_id'] ?? ''), + (string) ($row['abha_no'] ?? ''), + (string) ($row['abha'] ?? ''), + ]; - $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; + foreach ($candidates as $candidate) { + $candidate = trim($candidate); + if ($candidate === '') { + continue; + } + if ($address === '' && str_contains($candidate, '@')) { + $address = $candidate; + continue; + } + $digits = preg_replace('/\D/', '', $candidate); + if ($number === '' && is_string($digits) && strlen($digits) === 14) { + $number = $digits; } } - return ''; + return ['abha_id' => $number, 'abha_address' => $address]; } private function loadPatientRow(int $patientId): array @@ -6041,18 +6599,409 @@ class AbdmGateway extends BaseController private function buildSimpleInvoiceBundle(array $patient, array $invoice, array $items): array { $issuedAt = Time::now('Asia/Kolkata')->format(DATE_ATOM); - $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'patient-unknown'); + $invoiceNumber = trim((string) ($invoice['invoice_code'] ?? $invoice['id'] ?? '')) ?: 'unknown'; $invoiceId = 'invoice-' . (int) ($invoice['id'] ?? 0); + $compositionId = 'composition-' . $invoiceId; + $patientId = (string) ($patient['id'] ?? 'patient-unknown'); + $organizationId = 'organization-invoice-issuer'; + $encounterId = $invoiceId . '-encounter'; + $patientRef = 'urn:uuid:' . $patientId; + $invoiceRef = 'urn:uuid:' . $invoiceId; + $organizationRef = 'urn:uuid:' . $organizationId; + $encounterRef = 'urn:uuid:' . $encounterId; + $identifierSystem = 'https://hms.local/fhir/invoice'; + $hospital = $this->getHospitalProfileForFhir(); + $hospitalName = trim((string) ($hospital['name'] ?? '')) ?: 'Healthcare Facility'; + $hospitalIdentifier = trim((string) ($hospital['hfr_id'] ?? '')) ?: 'local-hospital'; + $invoiceDateTimestamp = strtotime((string) ($invoice['inv_date'] ?? '')); + $invoiceDate = $invoiceDateTimestamp !== false ? date('Y-m-d', $invoiceDateTimestamp) : date('Y-m-d'); + $encounterStartTimestamp = strtotime((string) ($invoice['encounter_start'] ?? '')); + $encounterEndTimestamp = strtotime((string) ($invoice['encounter_end'] ?? '')); + $encounterStart = $encounterStartTimestamp !== false ? date('Y-m-d', $encounterStartTimestamp) : $invoiceDate; + $encounterEnd = $encounterEndTimestamp !== false ? date('Y-m-d', $encounterEndTimestamp) : $encounterStart; + $netAmount = (float) ($invoice['net_amount'] ?? $invoice['total_amount'] ?? 0); + $grossAmount = (float) ($invoice['total_amount'] ?? $invoice['net_amount'] ?? 0); + $invoiceTypeCode = trim((string) ($invoice['invoice_type_code'] ?? '99')) ?: '99'; + $invoiceTypeDisplay = trim((string) ($invoice['invoice_type_display'] ?? 'Others')) ?: 'Others'; + $encounterClass = trim((string) ($invoice['encounter_class'] ?? 'AMB')) ?: 'AMB'; + $practitionerName = trim((string) ($invoice['practitioner_name'] ?? '')); + $practitionerSourceId = (int) ($invoice['practitioner_id'] ?? 0); + $practitionerId = $practitionerName !== '' + ? 'practitioner-' . ($practitionerSourceId > 0 ? $practitionerSourceId : substr(md5($practitionerName), 0, 12)) + : ''; + $practitionerRef = $practitionerId !== '' ? 'urn:uuid:' . $practitionerId : ''; + $escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $documentReferenceId = $invoiceId . '-pdf'; + $pdfFileStem = trim((string) preg_replace('/[^A-Za-z0-9._-]+/', '-', 'Invoice-' . $invoiceNumber), '-'); + $pdfFilename = ($pdfFileStem !== '' ? $pdfFileStem : 'Invoice') . '.pdf'; + $lineItems = []; + $chargeItems = []; foreach ($items as $idx => $item) { - $lineItems[] = ['sequence' => $idx + 1, 'chargeItemCodeableConcept' => ['text' => (string) ($item['item_name'] ?? 'Item')], 'priceComponent' => [['type' => 'base', 'amount' => ['value' => (float) ($item['item_amount'] ?? 0), 'currency' => 'INR']]]]; + $itemName = trim((string) ($item['item_name'] ?? '')) ?: 'Item'; + $quantity = (float) ($item['item_qty'] ?? 1); + if ($quantity <= 0) { + $quantity = 1; + } + $unit = trim((string) ($item['item_unit'] ?? $item['unit'] ?? '')) ?: 'unit'; + $rate = (float) ($item['item_rate'] ?? $item['item_amount'] ?? 0); + $chargeItemId = $invoiceId . '-charge-' . ($idx + 1); + $chargeItemRef = 'urn:uuid:' . $chargeItemId; + $chargeItems[] = [ + 'resourceType' => 'ChargeItem', + 'id' => $chargeItemId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ChargeItem'], + ], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . $escape($itemName) + . ': ' . $escape((string) $quantity) . ' ' . $escape($unit) . '

', + ], + 'status' => 'billed', + 'code' => [ + 'coding' => [[ + 'system' => 'https://nrces.in/ndhm/fhir/r4/CodeSystem/ndhm-billing-codes', + 'code' => $invoiceTypeCode, + 'display' => $invoiceTypeDisplay, + ]], + 'text' => $itemName, + ], + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'occurrenceDateTime' => $invoiceDate, + 'quantity' => ['value' => $quantity, 'unit' => $unit], + 'productCodeableConcept' => ['text' => $itemName], + ]; + $chargeItemActor = $practitionerRef !== '' ? $practitionerRef : $organizationRef; + $chargeItems[array_key_last($chargeItems)]['performer'] = [[ + 'actor' => ['reference' => $chargeItemActor], + ]]; + $lineItems[] = [ + 'sequence' => $idx + 1, + 'chargeItemReference' => [ + 'reference' => $chargeItemRef, + 'display' => $itemName, + ], + 'priceComponent' => [[ + 'type' => 'base', + 'code' => ['coding' => [[ + 'system' => 'https://nrces.in/ndhm/fhir/r4/CodeSystem/ndhm-price-components', + 'code' => '01', + 'display' => 'Rate', + ]]], + 'amount' => [ + 'value' => $rate, + 'currency' => 'INR', + ], + ]], + ]; } - $invoiceResource = ['resourceType' => 'Invoice', 'id' => $invoiceId, 'status' => 'issued', 'subject' => ['reference' => $patientRef], 'date' => ! empty($invoice['inv_date']) ? date('Y-m-d', strtotime((string) $invoice['inv_date'])) : date('Y-m-d'), 'totalNet' => ['value' => (float) ($invoice['net_amount'] ?? $invoice['total_amount'] ?? 0), 'currency' => 'INR'], 'totalGross' => ['value' => (float) ($invoice['total_amount'] ?? $invoice['net_amount'] ?? 0), 'currency' => 'INR']]; + + $patient['meta'] = array_replace((array) ($patient['meta'] ?? []), [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient'], + ]); + $patient['text'] = [ + 'status' => 'generated', + 'div' => '

Patient: ' + . $escape((string) ($patient['name'][0]['text'] ?? $patientId)) . '

', + ]; + foreach ((array) ($patient['identifier'] ?? []) as &$identifier) { + $identifier['assigner'] = ['reference' => $organizationRef]; + } + unset($identifier); + + $organization = [ + 'resourceType' => 'Organization', + 'id' => $organizationId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization'], + ], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . $escape($hospitalName) . '

', + ], + 'identifier' => [[ + 'system' => 'https://facility.abdm.gov.in', + 'value' => $hospitalIdentifier, + ]], + 'active' => true, + 'name' => $hospitalName, + ]; + + $invoiceResource = [ + 'resourceType' => 'Invoice', + 'id' => $invoiceId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Invoice'], + ], + 'text' => [ + 'status' => 'generated', + 'div' => '

Invoice ' + . $escape($invoiceNumber) . ' for INR ' . $escape(number_format($netAmount, 2, '.', '')) . '

', + ], + 'identifier' => [['system' => $identifierSystem, 'value' => $invoiceNumber]], + 'status' => 'issued', + 'type' => ['coding' => [[ + 'system' => 'https://nrces.in/ndhm/fhir/r4/CodeSystem/ndhm-billing-codes', + 'code' => $invoiceTypeCode, + 'display' => $invoiceTypeDisplay, + ]], 'text' => 'Healthcare invoice'], + 'subject' => ['reference' => $patientRef], + 'date' => $invoiceDate, + 'issuer' => ['reference' => $organizationRef], + 'totalPriceComponent' => [[ + 'type' => 'base', + 'code' => ['coding' => [[ + 'system' => 'https://nrces.in/ndhm/fhir/r4/CodeSystem/ndhm-price-components', + 'code' => '01', + 'display' => 'Rate', + ]]], + 'amount' => ['value' => $netAmount, 'currency' => 'INR'], + ]], + 'totalNet' => ['value' => $netAmount, 'currency' => 'INR'], + 'totalGross' => ['value' => $grossAmount, 'currency' => 'INR'], + ]; if (! empty($lineItems)) { $invoiceResource['lineItem'] = $lineItems; } - $composition = ['resource' => ['resourceType' => 'Composition', 'id' => 'composition-invoice-' . (int) ($invoice['id'] ?? 0), 'status' => 'final', 'type' => ['text' => 'Invoice Record'], 'subject' => ['reference' => $patientRef], 'date' => $issuedAt, 'title' => 'Invoice ' . (string) ($invoice['invoice_code'] ?? $invoice['id'] ?? ''), 'section' => [['title' => 'Invoice details', 'entry' => [['reference' => 'Invoice/' . $invoiceId]]]]]]; - return ['resourceType' => 'Bundle', 'type' => 'document', 'timestamp' => $issuedAt, 'entry' => [$composition, ['resource' => $patient], ['resource' => $invoiceResource]]]; + if ($practitionerRef !== '') { + $invoiceResource['participant'] = [[ + 'actor' => ['reference' => $practitionerRef, 'display' => $practitionerName], + ]]; + } + + $patientName = trim((string) ($patient['name'][0]['text'] ?? $patientId)) ?: 'Patient'; + $invoiceRows = ''; + foreach ($items as $idx => $item) { + $itemName = trim((string) ($item['item_name'] ?? '')) ?: 'Item'; + $quantity = (float) ($item['item_qty'] ?? 1); + if ($quantity <= 0) { + $quantity = 1; + } + $rate = (float) ($item['item_rate'] ?? $item['item_amount'] ?? 0); + $amount = (float) ($item['item_amount'] ?? ($quantity * $rate)); + $invoiceRows .= '' . ($idx + 1) . '' . $escape($itemName) . '' + . $escape((string) $quantity) . '' . number_format($rate, 2) . '' + . number_format($amount, 2) . ''; + } + if ($invoiceRows === '') { + $invoiceRows = 'No line items'; + } + $invoicePdfHtml = '

' . $escape($hospitalName) . '

Invoice ' . $escape($invoiceNumber) . '
' + . '
Patient: ' . $escape($patientName) + . '
Gender: ' . $escape((string) ($patient['gender'] ?? '')) + . '
Date of Birth: ' . $escape((string) ($patient['birthDate'] ?? '')) + . '
Visit: ' . $escape($invoiceTypeDisplay) + . '
Visit Date: ' . $escape($encounterStart) + . '
Practitioner: ' . $escape($practitionerName) + . '
Invoice Date: ' . $escape($invoiceDate) . '
' + . '' + . '' . $invoiceRows . '
#ItemQtyRate (INR)Amount (INR)
' + . '
Gross: INR ' . number_format($grossAmount, 2) . '
Net: INR ' + . number_format($netAmount, 2) . '
'; + $invoicePdf = $this->renderReportHtmlToPdfAttachment($invoicePdfHtml, $pdfFileStem); + if ($invoicePdf === null) { + throw new \RuntimeException('Unable to render invoice PDF attachment.'); + } + $invoicePdfBytes = base64_decode((string) $invoicePdf['data_base64'], true); + if (! is_string($invoicePdfBytes) || ! str_starts_with($invoicePdfBytes, '%PDF-')) { + throw new \RuntimeException('Generated invoice PDF attachment is invalid.'); + } + $documentReference = [ + 'resourceType' => 'DocumentReference', + 'id' => $documentReferenceId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference'], + ], + 'text' => [ + 'status' => 'generated', + 'div' => '

PDF copy of invoice ' + . $escape($invoiceNumber) . '

', + ], + 'status' => 'current', + 'docStatus' => 'final', + 'type' => ['text' => 'Invoice Record'], + 'subject' => ['reference' => $patientRef, 'display' => $patientName], + 'date' => $issuedAt, + 'author' => [[ + 'reference' => $practitionerRef !== '' ? $practitionerRef : $organizationRef, + 'display' => $practitionerRef !== '' ? $practitionerName : $hospitalName, + ]], + 'custodian' => ['reference' => $organizationRef, 'display' => $hospitalName], + 'content' => [[ + 'attachment' => [ + 'contentType' => 'application/pdf', + 'language' => 'en-IN', + 'data' => $invoicePdf['data_base64'], + 'size' => strlen($invoicePdfBytes), + 'hash' => base64_encode(sha1($invoicePdfBytes, true)), + 'title' => $pdfFilename, + 'creation' => $issuedAt, + ], + ]], + 'context' => [ + 'encounter' => [['reference' => $encounterRef]], + 'related' => [['reference' => $invoiceRef, 'display' => 'Invoice ' . $invoiceNumber]], + ], + ]; + + $encounter = [ + 'resourceType' => 'Encounter', + 'id' => $encounterId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter'], + ], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . $escape($invoiceTypeDisplay) + . ' visit on ' . $escape($invoiceDate) . ' at ' . $escape($hospitalName) . '

', + ], + 'identifier' => [['system' => $identifierSystem . '/encounter', 'value' => $invoiceNumber]], + 'status' => 'finished', + 'class' => [ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode', + 'code' => $encounterClass, + 'display' => $encounterClass === 'IMP' ? 'inpatient encounter' : 'ambulatory', + ], + 'subject' => ['reference' => $patientRef, 'display' => (string) ($patient['name'][0]['text'] ?? 'Patient')], + 'period' => ['start' => $encounterStart, 'end' => $encounterEnd], + 'serviceProvider' => ['reference' => $organizationRef, 'display' => $hospitalName], + ]; + if ($practitionerRef !== '') { + $encounter['participant'] = [[ + 'individual' => ['reference' => $practitionerRef, 'display' => $practitionerName], + ]]; + } + + $practitioner = null; + if ($practitionerRef !== '') { + $practitioner = [ + 'resourceType' => 'Practitioner', + 'id' => $practitionerId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner'], + ], + 'identifier' => [[ + 'type' => [ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'MD', + 'display' => 'Medical License number', + ]], + ], + 'system' => $identifierSystem . '/practitioner', + 'value' => $practitionerSourceId > 0 ? (string) $practitionerSourceId : $practitionerId, + ]], + 'text' => [ + 'status' => 'generated', + 'div' => '

' . $escape($practitionerName) . '

', + ], + 'active' => true, + 'name' => [['text' => $practitionerName]], + ]; + } + + $composition = [ + 'resourceType' => 'Composition', + 'id' => $compositionId, + 'meta' => [ + 'versionId' => '1', + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/InvoiceRecord'], + ], + 'text' => [ + 'status' => 'generated', + 'div' => '

Invoice Record ' + . $escape($invoiceNumber) . '

', + ], + 'identifier' => ['system' => $identifierSystem, 'value' => $invoiceNumber], + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => '69705-9', + 'display' => 'Healthcare Invoice', + ]], + 'text' => 'Invoice Record', + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => ['reference' => $encounterRef], + 'date' => $issuedAt, + 'author' => [[ + 'reference' => $practitionerRef !== '' ? $practitionerRef : $organizationRef, + 'display' => $practitionerRef !== '' ? $practitionerName : $hospitalName, + ]], + 'title' => 'Invoice ' . $invoiceNumber, + 'custodian' => ['reference' => $organizationRef], + 'section' => [[ + 'title' => 'Invoice details', + 'text' => [ + 'status' => 'generated', + 'div' => '

Billing details for invoice ' + . $escape($invoiceNumber) . '

', + ], + 'entry' => [['reference' => $invoiceRef, 'type' => 'Invoice']], + ]], + ]; + + $resources = [$composition, $patient, $organization, $encounter, $invoiceResource, $documentReference]; + if ($practitioner !== null) { + $resources[] = $practitioner; + } + $resources = array_merge($resources, $chargeItems); + $referenceMap = []; + foreach ($resources as $resource) { + $id = (string) ($resource['id'] ?? ''); + $hex = md5((string) ($resource['resourceType'] ?? 'Resource') . '/' . $id); + $uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) + . '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20, 12); + $referenceMap['urn:uuid:' . $id] = 'urn:uuid:' . $uuid; + } + $rewriteReferences = static function ($value) use (&$rewriteReferences, $referenceMap) { + if (is_string($value)) { + return $referenceMap[$value] ?? $value; + } + if (! is_array($value)) { + return $value; + } + foreach ($value as $key => $nested) { + $value[$key] = $rewriteReferences($nested); + } + return $value; + }; + $entries = []; + foreach ($resources as $resource) { + $entries[] = [ + 'fullUrl' => $referenceMap['urn:uuid:' . (string) $resource['id']], + 'resource' => $rewriteReferences($resource), + ]; + } + + return [ + 'resourceType' => 'Bundle', + 'id' => 'bundle-' . $invoiceId, + 'meta' => [ + 'versionId' => '1', + 'lastUpdated' => $issuedAt, + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle'], + ], + 'identifier' => ['system' => $identifierSystem, 'value' => $invoiceNumber], + 'type' => 'document', + 'timestamp' => $issuedAt, + 'entry' => $entries, + ]; } private function resolveReadableAttachmentPath(string $path): string diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index d839eee..a5f6e9e 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -18,13 +18,30 @@ class AbdmTaskBoard extends BaseController { $this->backfillPatientAbhaTasks(); $this->backfillLabRadiologyTasks(); - $tasks = $this->taskService->getOpenTasks(300); + $this->backfillImmunizationTasks(); + $tasks = $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300)); + + $dateFrom = trim((string) ($this->request->getGet('date_from') ?? date('Y-m-d'))); + $dateTo = trim((string) ($this->request->getGet('date_to') ?? date('Y-m-d'))); + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) { + $dateFrom = date('Y-m-d'); + } + if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) { + $dateTo = date('Y-m-d'); + } + if ($dateFrom > $dateTo) { + [$dateFrom, $dateTo] = [$dateTo, $dateFrom]; + } return view('abdm/task_board', [ 'tasks' => $tasks, + 'dashboard_metrics' => $this->getDashboardMetrics($dateFrom, $dateTo), + 'dashboard_date_from' => $dateFrom, + 'dashboard_date_to' => $dateTo, 'today_credit_opd_rows' => $this->getTodayCreditOpdConsultRows(), 'opd_book_rows' => $this->getOpdBookRows(), 'opd_consult_rows' => $this->getOpdConsultPublishRows(), + 'invoice_rows' => $this->getInvoiceRows(), ]); } @@ -32,9 +49,10 @@ class AbdmTaskBoard extends BaseController { $this->backfillPatientAbhaTasks(); $this->backfillLabRadiologyTasks(); + $this->backfillImmunizationTasks(); return $this->response->setJSON([ 'ok' => 1, - 'tasks' => $this->taskService->getOpenTasks(300), + 'tasks' => $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300)), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); @@ -65,6 +83,56 @@ class AbdmTaskBoard extends BaseController ]); } + /** @param array> $tasks */ + private function enrichImmunizationPushState(array $tasks): array + { + if ($tasks === [] || ! $this->db->tableExists('health_records')) { + return $tasks; + } + + $entityIds = []; + foreach ($tasks as $task) { + if (($task['task_type'] ?? '') === 'immunization_record_publish') { + $entityIds[] = (string) ($task['entity_id'] ?? ''); + } + } + $entityIds = array_values(array_filter(array_unique($entityIds), static fn (string $id): bool => $id !== '')); + if ($entityIds === []) { + return $tasks; + } + + $latest = []; + $rows = $this->db->table('health_records') + ->select('id, entity_id, push_status, care_context_reference, linked_at') + ->where('hi_type', 'ImmunizationRecord') + ->where('entity_type', 'immunization') + ->whereIn('entity_id', $entityIds) + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $entityId = (string) ($row['entity_id'] ?? ''); + if ($entityId !== '' && ! isset($latest[$entityId])) { + $latest[$entityId] = $row; + } + } + + foreach ($tasks as &$task) { + if (($task['task_type'] ?? '') !== 'immunization_record_publish') { + continue; + } + $healthRecord = $latest[(string) ($task['entity_id'] ?? '')] ?? null; + $pushStatus = strtolower(trim((string) ($healthRecord['push_status'] ?? ''))); + $task['bridge_health_record_id'] = (int) ($healthRecord['id'] ?? 0); + $task['bridge_push_status'] = $pushStatus; + $task['bridge_care_context_reference'] = trim((string) ($healthRecord['care_context_reference'] ?? '')); + $task['bridge_submitted'] = in_array($pushStatus, ['queued', 'pushed', 'linked'], true) ? 1 : 0; + } + unset($task); + + return $tasks; + } + public function performAction() { if (! $this->request->isAJAX()) { @@ -309,6 +377,80 @@ class AbdmTaskBoard extends BaseController } } + private function backfillImmunizationTasks(): void + { + if (! $this->db->tableExists('abdm_work_tasks') || ! $this->db->tableExists('immunization_records') || ! $this->db->tableExists('patient_master')) { + return; + } + + $patientFields = $this->db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']); + if ($abhaField === null) { + return; + } + + $rows = $this->db->table('immunization_records r') + ->select('r.id, r.patient_id, r.vaccine_name, r.given_date, p.p_fname AS patient_name, p.' . $abhaField . ' AS abha_id', false) + ->join('patient_master p', 'p.id = r.patient_id', 'left') + ->where('r.status', 'completed') + ->where('p.' . $abhaField . ' !=', '') + ->orderBy('r.id', 'DESC') + ->limit(500) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $recordId = (int) ($row['id'] ?? 0); + $patientId = (int) ($row['patient_id'] ?? 0); + $abhaId = trim((string) ($row['abha_id'] ?? '')); + if ($recordId <= 0 || $patientId <= 0 || $abhaId === '') { + continue; + } + + $linked = $this->db->tableExists('health_records') && ! empty($this->db->table('health_records') + ->select('id') + ->where('hi_type', 'ImmunizationRecord') + ->where('entity_type', 'immunization') + ->where('entity_id', (string) $recordId) + ->where('push_status', 'linked') + ->get(1) + ->getRowArray()); + if ($linked) { + continue; + } + + $exists = $this->db->table('abdm_work_tasks') + ->select('id') + ->where('task_type', 'immunization_record_publish') + ->where('entity_type', 'immunization') + ->where('entity_id', (string) $recordId) + ->whereIn('status', ['pending', 'in_progress']) + ->get(1) + ->getRowArray(); + if (! empty($exists)) { + continue; + } + + $this->taskService->createOrRefreshTask( + 'immunization_record_publish', + 'immunization', + 'immunization', + (string) $recordId, + $patientId, + trim((string) ($row['patient_name'] ?? '')), + $abhaId, + 'register_m2', + [ + 'record_id' => $recordId, + 'vaccine_name' => (string) ($row['vaccine_name'] ?? ''), + 'given_date' => (string) ($row['given_date'] ?? ''), + 'hi_type' => 'ImmunizationRecord', + 'trigger' => 'task_board.backfill', + ] + ); + } + } + /** * @return array> */ @@ -439,33 +581,252 @@ class AbdmTaskBoard extends BaseController } /** - * OPD Book rows: HMS OPD appointments (last 7 days) where patient has ABHA. + * @return array> + */ + private function getInvoiceRows(): array + { + $rows = []; + + if ($this->db->tableExists('opd_master')) { + foreach ($this->db->table('opd_master') + ->select('opd_id AS bill_id, opd_code AS bill_code, p_id AS patient_id, P_name AS patient_name, apointment_date AS bill_date, opd_fee_amount AS amount') + ->orderBy('opd_id', 'DESC') + ->limit(100) + ->get() + ->getResultArray() as $row) { + $rows[] = array_merge($row, ['source' => 'OPD', 'source_key' => 'opd_invoice']); + } + } + + if ($this->db->tableExists('invoice_master')) { + foreach ($this->db->table('invoice_master i') + ->select('i.id AS bill_id, i.invoice_code AS bill_code, i.attach_id AS patient_id, COALESCE(NULLIF(i.inv_name, ""), p.p_fname) AS patient_name, i.inv_date AS bill_date, i.net_amount AS amount') + ->join('patient_master p', 'p.id = i.attach_id AND i.attach_type = 0', 'left') + ->orderBy('i.id', 'DESC') + ->limit(100) + ->get() + ->getResultArray() as $row) { + $rows[] = array_merge($row, ['source' => 'Charges', 'source_key' => 'charges_invoice']); + } + } + + if ($this->db->tableExists('ipd_master')) { + foreach ($this->db->table('ipd_master i') + ->select("i.id AS bill_id, i.ipd_code AS bill_code, i.p_id AS patient_id, COALESCE(NULLIF(NULLIF(TRIM(i.P_name), ''), '0'), TRIM(CONCAT_WS(' ', p.p_fname, p.p_lname))) AS patient_name, COALESCE(i.discharge_date, i.register_date) AS bill_date, i.net_amount AS amount", false) + ->join('patient_master p', 'p.id = i.p_id', 'left') + ->orderBy('i.id', 'DESC') + ->limit(100) + ->get() + ->getResultArray() as $row) { + $rows[] = array_merge($row, ['source' => 'IPD Billing', 'source_key' => 'ipd_invoice']); + } + } + + $healthRecords = []; + if ($this->db->tableExists('health_records')) { + $hrFields = $this->db->getFieldNames('health_records') ?? []; + $select = ['id', 'entity_type', 'entity_id', 'push_status', 'abdm_txn_id', 'care_context_reference', 'push_at', 'linked_at']; + if (in_array('bridge_record_id', $hrFields, true)) { + $select[] = 'bridge_record_id'; + } + + $hrRows = $this->db->table('health_records') + ->select(implode(',', $select)) + ->where('hi_type', 'InvoiceRecord') + ->whereIn('entity_type', ['invoice', 'charges_invoice', 'opd_invoice', 'ipd_invoice']) + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + foreach ($hrRows as $hr) { + $key = (string) ($hr['entity_type'] ?? '') . ':' . (string) ($hr['entity_id'] ?? ''); + if ($key !== ':' && ! isset($healthRecords[$key])) { + $healthRecords[$key] = $hr; + } + } + } + + $recordLinks = []; + $healthRecordIds = array_values(array_filter(array_map( + static fn (array $hr): int => (int) ($hr['id'] ?? 0), + $healthRecords + ))); + if ($healthRecordIds !== [] && $this->db->tableExists('record_links')) { + $linkRows = $this->db->table('record_links') + ->select('health_record_id, link_status, linked_at') + ->whereIn('health_record_id', $healthRecordIds) + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + foreach ($linkRows as $link) { + $healthRecordId = (int) ($link['health_record_id'] ?? 0); + if ($healthRecordId > 0 && ! isset($recordLinks[$healthRecordId])) { + $recordLinks[$healthRecordId] = $link; + } + } + } + + foreach ($rows as &$row) { + $billId = (string) ($row['bill_id'] ?? ''); + $keys = [(string) $row['source_key'] . ':' . $billId]; + if ($row['source_key'] === 'charges_invoice') { + $keys[] = 'invoice:' . $billId; + } + + $hr = null; + foreach ($keys as $key) { + if (isset($healthRecords[$key])) { + $hr = $healthRecords[$key]; + break; + } + } + + $pushStatus = strtolower(trim((string) ($hr['push_status'] ?? ''))); + $link = $hr !== null ? ($recordLinks[(int) ($hr['id'] ?? 0)] ?? null) : null; + $linkStatus = strtolower(trim((string) ($link['link_status'] ?? ''))); + $statusLabel = 'Not Pushed'; + $statusTone = 'secondary'; + + if ($pushStatus === 'queued') { + $statusLabel = 'Submitted'; + $statusTone = 'warning'; + } elseif ($pushStatus === 'pushed') { + $statusLabel = 'Pushed'; + $statusTone = 'primary'; + } elseif ($pushStatus === 'linked') { + $statusLabel = 'Linked'; + $statusTone = 'success'; + } elseif ($pushStatus === 'failed') { + $statusLabel = 'Failed'; + $statusTone = 'danger'; + } elseif ($pushStatus !== '') { + $statusLabel = ucwords(str_replace('_', ' ', $pushStatus)); + $statusTone = 'info'; + } + + if ($linkStatus === 'linked') { + $statusLabel = 'Linked'; + $statusTone = 'success'; + } elseif ($linkStatus === 'failed') { + $statusLabel = 'Link Failed'; + $statusTone = 'danger'; + } + + $row['record_status_label'] = $statusLabel; + $row['record_status_tone'] = $statusTone; + $row['push_status'] = $pushStatus; + $row['link_status'] = $linkStatus; + $row['queue_id'] = trim((string) ($hr['abdm_txn_id'] ?? '')); + $row['bridge_record_id'] = (int) ($hr['bridge_record_id'] ?? 0); + $row['care_context_reference'] = trim((string) ($hr['care_context_reference'] ?? '')); + } + unset($row); + + usort($rows, static function (array $left, array $right): int { + $dateCompare = strcmp((string) ($right['bill_date'] ?? ''), (string) ($left['bill_date'] ?? '')); + return $dateCompare !== 0 ? $dateCompare : ((int) ($right['bill_id'] ?? 0) <=> (int) ($left['bill_id'] ?? 0)); + }); + + return array_slice($rows, 0, 300); + } + + /** + * @return array + */ + private function getDashboardMetrics(string $dateFrom, string $dateTo): array + { + $metrics = [ + 'total_patients' => 0, + 'without_abha' => 0, + 'abha_verified' => 0, + 'verified_in_range' => 0, + 'records_pushed' => 0, + 'records_pushed_in_range' => 0, + 'opd_tokens' => 0, + 'opd_tokens_in_range' => 0, + 'verification_date_source' => '', + ]; + + if ($this->db->tableExists('patient_master')) { + $fields = $this->db->getFieldNames('patient_master') ?? []; + $abhaCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha']); + $verifiedCol = $this->resolveExistingColumn($fields, ['abha_verified_status']); + $verifiedDateCol = $this->resolveExistingColumn($fields, ['abha_verified_at', 'abdm_linked_at', 'last_update']); + + $metrics['total_patients'] = $this->db->table('patient_master')->countAllResults(); + if ($abhaCol !== null) { + $validAbhaSql = $abhaCol . " REGEXP '^[0-9]{14}$'"; + $metrics['without_abha'] = $this->db->table('patient_master') + ->where("COALESCE(" . $abhaCol . ", '') NOT REGEXP '^[0-9]{14}$'", null, false) + ->countAllResults(); + + $verifiedBuilder = $this->db->table('patient_master')->where($validAbhaSql, null, false); + if ($verifiedCol !== null) { + $verifiedBuilder->where($verifiedCol, 'VERIFIED'); + } + $metrics['abha_verified'] = $verifiedBuilder->countAllResults(); + + if ($verifiedDateCol !== null) { + $rangeBuilder = $this->db->table('patient_master') + ->where($validAbhaSql, null, false) + ->where($verifiedDateCol . ' >=', $dateFrom . ' 00:00:00') + ->where($verifiedDateCol . ' <=', $dateTo . ' 23:59:59'); + if ($verifiedCol !== null) { + $rangeBuilder->where($verifiedCol, 'VERIFIED'); + } + $metrics['verified_in_range'] = $rangeBuilder->countAllResults(); + $metrics['verification_date_source'] = $verifiedDateCol; + } + } else { + $metrics['without_abha'] = $metrics['total_patients']; + } + } + + if ($this->db->tableExists('health_records')) { + $fields = $this->db->getFieldNames('health_records') ?? []; + $dateCol = $this->resolveExistingColumn($fields, ['push_at', 'linked_at', 'updated_at', 'created_at']); + $pushedStatuses = ['queued', 'pushed', 'linked']; + $metrics['records_pushed'] = $this->db->table('health_records') + ->whereIn('push_status', $pushedStatuses) + ->countAllResults(); + if ($dateCol !== null) { + $metrics['records_pushed_in_range'] = $this->db->table('health_records') + ->whereIn('push_status', $pushedStatuses) + ->where($dateCol . ' >=', $dateFrom . ' 00:00:00') + ->where($dateCol . ' <=', $dateTo . ' 23:59:59') + ->countAllResults(); + } + } + + if ($this->db->tableExists('abdm_opd_tokens')) { + $metrics['opd_tokens'] = $this->db->table('abdm_opd_tokens')->countAllResults(); + $metrics['opd_tokens_in_range'] = $this->db->table('abdm_opd_tokens') + ->where('queue_date >=', $dateFrom) + ->where('queue_date <=', $dateTo) + ->countAllResults(); + } + + return $metrics; + } + + /** + * OPD Book rows: locally synced ABDM OPD tokens only. * * @return array> */ private function getOpdBookRows(): array { - if (! $this->db->tableExists('opd_master') || ! $this->db->tableExists('patient_master')) { + if (! $this->db->tableExists('abdm_opd_tokens')) { return []; } - $patientFields = $this->db->getFieldNames('patient_master') ?? []; - $abhaCol = $this->resolveExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']); - if ($abhaCol === null) { - return []; - } - - $rows = $this->db->table('opd_master o') - ->select('o.opd_id, o.p_id, o.P_name, o.apointment_date, o.opd_status, o.doc_name, p.' . $abhaCol . ' as abha_id', false) - ->join('patient_master p', 'p.id = o.p_id', 'left') - ->where('DATE(o.apointment_date) >=', date('Y-m-d', strtotime('-7 days')), false) - ->where('p.' . $abhaCol . ' !=', '') - ->orderBy('o.opd_id', 'DESC') + return $this->db->table('abdm_opd_tokens') + ->where('queue_date >=', date('Y-m-d', strtotime('-7 days'))) + ->orderBy('queue_date', 'DESC') + ->orderBy('gateway_token_id', 'DESC') ->limit(200) ->get() ->getResultArray(); - - return array_values(array_filter($rows, static fn ($r) => preg_match('/^\d{14}$/', trim((string) ($r['abha_id'] ?? ''))) === 1)); } /** diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index b17a1d2..404dee2 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -1293,7 +1293,106 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $body[$optional] = (string) $data[$optional]; } - return $this->post('/v3/records/push', $body); + return self::normalizePushRecordResponse($this->post('/v3/records/push', $body)); + } + + /** + * @param array $result + * @return array + */ + private static function normalizePushRecordResponse(array $result): array + { + $httpCode = (int) ($result['http_code'] ?? 0); + $error = $result['error'] ?? null; + $responseError = $result['response']['error'] ?? null; + $dataError = $result['data']['error'] ?? null; + $errorCode = strtoupper(trim((string) ( + $result['error_code'] + ?? (is_array($error) ? ($error['code'] ?? null) : $error) + ?? ($result['response']['error_code'] ?? null) + ?? (is_array($responseError) ? ($responseError['code'] ?? null) : $responseError) + ?? ($result['data']['error_code'] ?? null) + ?? (is_array($dataError) ? ($dataError['code'] ?? null) : $dataError) + ?? '' + ))); + $duplicate = $httpCode === 409 && $errorCode === 'DUPLICATE_RECORD'; + + $recordId = self::firstPositiveInteger([ + $result['record_id'] ?? null, + $result['existing_record_id'] ?? null, + $result['response']['record_id'] ?? null, + $result['response']['existing_record_id'] ?? null, + $result['data']['record_id'] ?? null, + $result['data']['existing_record_id'] ?? null, + ]); + $queueId = self::firstNonEmptyString([ + $result['queue_id'] ?? null, + $result['existing_queue_id'] ?? null, + $result['response']['queue_id'] ?? null, + $result['response']['existing_queue_id'] ?? null, + $result['data']['queue_id'] ?? null, + $result['data']['existing_queue_id'] ?? null, + ]); + $firstPushedAt = self::firstNonEmptyString([ + $result['first_pushed_at'] ?? null, + $result['response']['first_pushed_at'] ?? null, + $result['data']['first_pushed_at'] ?? null, + ]); + + if ($recordId > 0) { + $result['record_id'] = $recordId; + } + if ($queueId !== '') { + $result['queue_id'] = $queueId; + } + if ($firstPushedAt !== '') { + $result['first_pushed_at'] = $firstPushedAt; + } + $result['error_code'] = $errorCode !== '' ? $errorCode : ($result['error_code'] ?? null); + $result['duplicate'] = $duplicate ? 1 : 0; + $result['submitted'] = ($httpCode === 201 || $duplicate) ? 1 : 0; + + if ($httpCode === 201 || $duplicate) { + $result['ok'] = 1; + return $result; + } + + if ($httpCode === 422) { + $result['ok'] = 0; + $message = trim((string) ($result['message'] ?? $result['error_text'] ?? 'Validation failed')); + $details = []; + foreach ((array) ($result['errors'] ?? []) as $error) { + $details[] = is_array($error) + ? trim(implode(' ', array_filter([$error['field'] ?? '', $error['message'] ?? '']))) + : trim((string) $error); + } + $details = array_values(array_filter($details)); + $result['error_text'] = $message . ($details !== [] ? ' | ' . implode(' ; ', $details) : ''); + } + + return $result; + } + + /** @param array $values */ + private static function firstPositiveInteger(array $values): int + { + foreach ($values as $value) { + if ((int) $value > 0) { + return (int) $value; + } + } + return 0; + } + + /** @param array $values */ + private static function firstNonEmptyString(array $values): string + { + foreach ($values as $value) { + if (trim((string) $value) !== '') { + return trim((string) $value); + } + } + return ''; } // ------------------------------------------------------------------------- diff --git a/app/Libraries/Abdm/M2LinkStatusResolver.php b/app/Libraries/Abdm/M2LinkStatusResolver.php new file mode 100644 index 0000000..ef883f5 --- /dev/null +++ b/app/Libraries/Abdm/M2LinkStatusResolver.php @@ -0,0 +1,45 @@ + $payload + * @return array{request_id:string,abha_id:string,care_context_reference:string,linked_at:string,status:string} + */ + public static function parse(array $payload, string $headerRequestId = ''): array + { + return [ + 'request_id' => trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? $headerRequestId)), + 'abha_id' => trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')), + 'care_context_reference' => trim((string) ($payload['care_context_reference'] ?? $payload['careContextReference'] ?? '')), + 'linked_at' => trim((string) ($payload['linked_at'] ?? $payload['linkedAt'] ?? '')), + 'status' => self::normalize((string) ($payload['status'] ?? '')), + ]; + } + + public static function normalize(string $status): string + { + $status = strtolower(trim($status)); + if (in_array($status, ['linked', 'success', 'completed'], true)) { + return 'linked'; + } + if (in_array($status, ['failed', 'error', 'rejected'], true)) { + return 'failed'; + } + + return 'pending'; + } + + public static function merge(string $currentStatus, string $incomingStatus): string + { + $currentStatus = self::normalize($currentStatus); + $incomingStatus = self::normalize($incomingStatus); + if ($currentStatus === 'linked' || ($currentStatus === 'failed' && $incomingStatus === 'pending')) { + return $currentStatus; + } + + return $incomingStatus; + } +} diff --git a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php index 1d6610e..d2053cc 100644 --- a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php +++ b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php @@ -434,14 +434,26 @@ class AbdmGatewayPushClient } } + $error = $body['error'] ?? null; + $responseError = $body['response']['error'] ?? null; + $dataError = $body['data']['error'] ?? null; $errorCode = strtoupper(trim((string) ( $body['error_code'] + ?? (is_array($error) ? ($error['code'] ?? null) : $error) ?? ($body['response']['error_code'] ?? null) - ?? ($body['data']['error_code'] ?? '') + ?? (is_array($responseError) ? ($responseError['code'] ?? null) : $responseError) + ?? ($body['data']['error_code'] ?? null) + ?? (is_array($dataError) ? ($dataError['code'] ?? null) : $dataError) + ?? '' ))); - $isDuplicate = $httpStatus === 409 || $errorCode === 'DUPLICATE_RECORD'; + $isDuplicate = $httpStatus === 409 && $errorCode === 'DUPLICATE_RECORD'; $isSubmitted = $httpStatus === 201 || $isDuplicate; $message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus)); + $firstPushedAt = trim((string) ( + $body['first_pushed_at'] + ?? ($body['response']['first_pushed_at'] ?? null) + ?? ($body['data']['first_pushed_at'] ?? '') + )); // The bridge returns field-level FHIR errors here; without them a 422 is undiagnosable. $validationErrors = is_array($body['errors'] ?? null) ? $body['errors'] : []; @@ -472,6 +484,7 @@ class AbdmGatewayPushClient 'request_id' => $requestId, 'gateway_record_id' => $recordId > 0 ? $recordId : null, 'gateway_queue_id' => $queueId !== '' ? $queueId : null, + 'first_pushed_at' => $firstPushedAt !== '' ? $firstPushedAt : null, 'gateway_record_source' => $recordSource, 'gateway_queue_source' => $queueSource, 'submitted' => $isSubmitted ? 1 : 0, @@ -499,7 +512,7 @@ class AbdmGatewayPushClient ]; } - if ($httpStatus === 400 || $httpStatus === 401 || $httpStatus === 403 || $httpStatus === 422) { + if ($httpStatus === 400 || $httpStatus === 401 || $httpStatus === 403 || $httpStatus === 409 || $httpStatus === 422) { return [ 'ok' => false, 'status' => 'failed', diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index dbe48c4..049310a 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -1689,6 +1689,10 @@ class FhirR4Builder (string) ($immunization['given_date'] ?? $immunization['occurrenceDateTime'] ?? $immunization['due_date'] ?? ''), $issuedAt ); + [$vaccineCodeSystem, $vaccineCode] = $this->normalizeVaccineCoding( + (string) ($immunization['vaccine_code_system'] ?? ''), + (string) ($immunization['vaccine_code'] ?? '') + ); $resource = [ 'resourceType' => 'Immunization', @@ -1700,8 +1704,8 @@ class FhirR4Builder ]], 'status' => $status, 'vaccineCode' => $this->buildSimpleCodeableConcept( - (string) ($immunization['vaccine_code_system'] ?? ''), - (string) ($immunization['vaccine_code'] ?? ''), + $vaccineCodeSystem, + $vaccineCode, $vaccineName, $vaccineName ), @@ -1779,6 +1783,7 @@ class FhirR4Builder } $immunizationRefs[] = ['reference' => $immunizationRef, 'display' => $vaccineName]; + $immunizationRefs[array_key_last($immunizationRefs)]['type'] = 'Immunization'; $resourceEntries[] = ['fullUrl' => $immunizationRef, 'resource' => $resource]; } @@ -1818,12 +1823,42 @@ class FhirR4Builder ]], ]; if ($practitionerRef !== '') { - $composition['author'] = [['reference' => $practitionerRef]]; + $composition['author'] = [[ + 'reference' => $practitionerRef, + 'display' => trim((string) ($practitioner['name'] ?? '')), + ]]; } elseif ($organizationRef !== '') { - $composition['author'] = [['reference' => $organizationRef]]; + $composition['author'] = [[ + 'reference' => $organizationRef, + 'display' => trim((string) ($organization['name'] ?? '')), + ]]; + } + if ($organizationRef !== '') { + $composition['custodian'] = [ + 'reference' => $organizationRef, + 'display' => trim((string) ($organization['name'] ?? '')), + ]; } - $careContextReference = trim((string) ($context['care_context_reference'] ?? ('IMM-' . (string) ($patient['id'] ?? 'unknown') . '-' . date('YmdHis')))); + $careContextReference = trim((string) ($context['care_context_reference'] ?? '')); + if ($careContextReference === '') { + $sourceIds = []; + foreach ($immunizations as $immunization) { + $sourceId = trim((string) ($immunization['id'] ?? '')); + if ($sourceId !== '') { + $sourceIds[] = $sourceId; + } + } + $sourceIds = array_values(array_unique($sourceIds)); + sort($sourceIds, SORT_STRING); + if (count($sourceIds) === 1) { + $careContextReference = 'IMM-' . $sourceIds[0]; + } elseif ($sourceIds !== []) { + $careContextReference = 'IMM-SET-' . substr(hash('sha256', implode(',', $sourceIds)), 0, 20); + } else { + $careContextReference = 'IMM-PAT-' . trim((string) ($patient['id'] ?? 'unknown')); + } + } $hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? '')); return [ @@ -1857,6 +1892,34 @@ class FhirR4Builder }; } + /** @return array{0:string,1:string} */ + private function normalizeVaccineCoding(string $system, string $code): array + { + $system = trim($system); + $code = trim($code); + if ($system !== 'https://hms.local/immunization/uip') { + return [$system, $code]; + } + + $cvxCodes = [ + 'UIP-BCG' => '19', + 'UIP-OPV' => '02', + 'UIP-HEPB' => '45', + 'UIP-ROTA' => '122', + 'UIP-FIPV' => '10', + 'UIP-PCV' => '152', + 'UIP-MR' => '04', + 'UIP-JE' => '39', + 'UIP-DPT' => '01', + 'UIP-TD' => '09', + ]; + if (! isset($cvxCodes[$code])) { + return [$system, $code]; + } + + return ['http://hl7.org/fhir/sid/cvx', $cvxCodes[$code]]; + } + /** * @return array */ diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index cde440a..69fadb3 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -17,22 +17,63 @@

ABDM Work Task Board

-
Unupdated ABHA Patient List + Separate ABDM Task Queues
+
ABDM operational dashboard and work queues
- - + - + +
+ +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+ +
Date-range verification uses the stored ABHA link/verification timestamp.
+ +
Date-range verification uses the patient last-updated date because an ABHA timestamp is not available.
+ +
+
@@ -82,7 +123,9 @@ $opdSessionId = (int) ($meta['opd_session_id'] ?? 0); $showSandbox = ($type === 'opd_prescription_publish' && $opdId > 0); $showPreview = ($type === 'opd_prescription_publish' && $opdId > 0) - || in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish'], true); + || in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish', 'immunization_record_publish'], true); + $bridgeSubmitted = (int) ($t['bridge_submitted'] ?? 0) === 1; + $bridgeCareContext = trim((string) ($t['bridge_care_context_reference'] ?? '')); ?> @@ -108,13 +152,28 @@
- + + + LINKED + + BRIDGE SUBMITTED +
+ + + +
+ + + + + + @@ -222,7 +281,7 @@
- OPD Book — ABDM Patients + OPD Book — ABDM Tokens
@@ -245,29 +304,30 @@
- HMS Direct OPD with ABHA + HMS ABDM OPD Token List - Last 7 days · patient has 14-digit ABHA + Last 7 days · synced and manual ABDM tokens only
- + - - - - - - + + + + + + + - +
OPD IDPatientABHADateDoctorStatus
TokenPatientABHADateSourceStatus
#= 2 ? 'Done' : 'Pending' ?>
No OPD with ABHA found in last 7 days.
No ABDM OPD tokens found in the last 7 days.
@@ -366,6 +426,72 @@
+ +
+
+ Invoice — ABDM Billing Records + + OPD, Charges and IPD Billing +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
SourceBill No.PatientDateAmountPush StatusLink StatusGateway ReferenceAction
+
+ # +
-' ?> +
Queue:
+ 0): ?>
Bridge: #
+
CC:
+ - +
+ + +
No billing records found.
+
+
+
+
Ready
@@ -499,19 +625,29 @@ var opdBookCard = document.getElementById('opdBookCard'); var opdConsultCard = document.getElementById('opdConsultCard'); + var invoiceCard = document.getElementById('invoiceCard'); var taskTableCard = document.getElementById('taskTableCard'); + var dashboardCard = document.getElementById('dashboardCard'); function applyFilter(filter) { // Hide dedicated section cards if (opdBookCard) opdBookCard.classList.add('d-none'); if (opdConsultCard) opdConsultCard.classList.add('d-none'); + if (invoiceCard) invoiceCard.classList.add('d-none'); + if (dashboardCard) dashboardCard.classList.add('d-none'); - if (filter === 'opd_book') { + if (filter === 'dashboard') { + if (taskTableCard) taskTableCard.classList.add('d-none'); + if (dashboardCard) dashboardCard.classList.remove('d-none'); + } else if (filter === 'opd_book') { if (taskTableCard) taskTableCard.classList.add('d-none'); if (opdBookCard) opdBookCard.classList.remove('d-none'); } else if (filter === 'opd_consult_publish') { if (taskTableCard) taskTableCard.classList.add('d-none'); if (opdConsultCard) opdConsultCard.classList.remove('d-none'); + } else if (filter === 'invoice') { + if (taskTableCard) taskTableCard.classList.add('d-none'); + if (invoiceCard) invoiceCard.classList.remove('d-none'); } else { if (taskTableCard) taskTableCard.classList.remove('d-none'); var rows = document.querySelectorAll('#taskTable tbody tr'); @@ -946,6 +1082,20 @@ + '&patient_id=' + encodeURIComponent(ipdPatientId) + '&abha_id=' + encodeURIComponent(abhaId); title = 'IPD Discharge FHIR Preview — IPD #' + ipdId; + } else if (taskType === 'immunization_record_publish') { + var immunizationRecordId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0; + var immunizationPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0; + + if (immunizationRecordId <= 0 || immunizationPatientId <= 0) { + setStatus('Task data missing for Immunization FHIR preview.', true); + return true; + } + + previewUrl = '' + + '?record_id=' + encodeURIComponent(immunizationRecordId) + + '&patient_id=' + encodeURIComponent(immunizationPatientId) + + '&abha_id=' + encodeURIComponent(abhaId); + title = 'ImmunizationRecord FHIR Preview — Record #' + immunizationRecordId; } else { return false; } @@ -986,6 +1136,50 @@ }); }); + document.querySelectorAll('.immunization-bridge-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr'); + var abhaInput = row ? row.querySelector('.abha-input') : null; + var abhaId = (abhaInput ? abhaInput.value : '').trim(); + var patientId = row ? (parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0) : 0; + var recordId = row ? (parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0) : 0; + var taskId = row ? (parseInt(row.getAttribute('data-task-id') || '0', 10) || 0) : 0; + + if (!/^\d{14}$/.test(abhaId) || patientId <= 0 || recordId <= 0) { + setStatus('A 14-digit ABHA and valid Immunization record are required.', true); + return; + } + + var original = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Pushing'; + post('', { + task_id: taskId, + patient_id: patientId, + record_id: recordId, + abha_id: abhaId, + push_to_gateway: 1 + }, function (res) { + btn.disabled = false; + btn.innerHTML = original; + if (!res || parseInt(res.ok || '0', 10) !== 1) { + setStatus((res && (res.error_text || res.error || res.message)) || 'ABDM Bridge push failed.', true); + return; + } + + var badge = row.querySelector('.status-pill'); + if (badge) { + badge.textContent = 'BRIDGE SUBMITTED'; + badge.className = 'badge bg-info status-pill'; + } + btn.disabled = true; + btn.textContent = 'Submitted to Bridge'; + btn.title = 'Submitted to ABDM Bridge: ' + (res.care_context_reference || '-'); + setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-')); + }); + }); + }); + document.querySelectorAll('.filter-btn').forEach(function (btn) { btn.addEventListener('click', function () { applyFilter(btn.getAttribute('data-filter') || 'all'); @@ -1032,7 +1226,7 @@ }); }); - applyFilter('patient_abha_create'); + applyFilter('dashboard'); modalConfirmBtn.addEventListener('click', function () { if (!selectedRow || !selectedAction) { @@ -1468,6 +1662,9 @@ var _fhirSubmitMode = 'opd'; var _fhirSubmitTaskRow = null; var _fhirSubmitAbha = ''; + var _fhirInvoiceSource = ''; + var _fhirInvoiceBillId = 0; + var _fhirInvoicePatientId = 0; // ── Inline complaint SNOMED edit (event delegation on fhirFormView) ─ var _editTimer = null; @@ -1775,10 +1972,16 @@ liveDiagnoses = liveDiagnoses || []; var resources = (bundle.entry || []).map(function(e) { return e.resource; }).filter(Boolean); function byType(t) { return resources.filter(function(r){ return r.resourceType === t; }); } + var resourcesByUrl = {}; + (bundle.entry || []).forEach(function(entry) { + if (entry.fullUrl && entry.resource) resourcesByUrl[entry.fullUrl] = entry.resource; + }); + var composition = byType('Composition')[0]; var patient = byType('Patient')[0]; var encounter = byType('Encounter')[0]; var practitioner = byType('Practitioner')[0]; var organization = byType('Organization')[0]; + var invoice = byType('Invoice')[0]; var allConditions = byType('Condition'); var observations = byType('Observation'); var medications = byType('MedicationRequest'); @@ -1786,6 +1989,24 @@ var serviceReqs = byType('ServiceRequest'); var allergies = byType('AllergyIntolerance'); var carePlans = byType('CarePlan'); + var documentRefs = byType('DocumentReference'); + var immunizations = byType('Immunization'); + + (window._abdmFhirObjectUrls || []).forEach(function(url) { URL.revokeObjectURL(url); }); + window._abdmFhirObjectUrls = []; + function attachmentObjectUrl(attachment) { + if (!attachment || !attachment.data) return ''; + try { + var bytes = atob(attachment.data); + var buffer = new Uint8Array(bytes.length); + for (var i = 0; i < bytes.length; i++) buffer[i] = bytes.charCodeAt(i); + var url = URL.createObjectURL(new Blob([buffer], { type: attachment.contentType || 'application/octet-stream' })); + window._abdmFhirObjectUrls.push(url); + return url; + } catch (error) { + return ''; + } + } var coursePlans = []; var advicePlans = []; @@ -1830,6 +2051,117 @@ html += '
'; } + // ── Invoice Record document metadata + if (composition && invoice) { + var compositionCoding = ((((composition.type || {}).coding) || [])[0]) || {}; + var compositionIdentifier = (composition.identifier || {}).value || ''; + var bundleIdentifier = (bundle.identifier || {}).value || ''; + html += '
Invoice Record
'; + html += '
'; + html += '
Document
' + hesc(composition.title || 'Invoice Record') + '
'; + html += '
Status
' + hesc(composition.status || '-') + '
'; + html += '
Created
' + hesc(composition.date || bundle.timestamp || '-') + '
'; + html += '
Composition Type
' + hesc(compositionCoding.display || (composition.type || {}).text || '-') + '
'; + html += '
LOINC
' + hesc(compositionCoding.code || '-') + '
'; + html += '
Bundle / Care Context Identifier
' + hesc(bundleIdentifier || compositionIdentifier || '-') + '
'; + html += '
'; + } + + // ── Invoice details and line items + if (invoice) { + var invoiceIdentifier = ((invoice.identifier || [])[0] || {}).value || ''; + var invoiceCoding = ((((invoice.type || {}).coding) || [])[0]) || {}; + var totalRate = (((invoice.totalPriceComponent || [])[0] || {}).amount || {}); + var totalNet = invoice.totalNet || {}; + var totalGross = invoice.totalGross || {}; + function money(amount) { + if (amount.value === undefined || amount.value === null || amount.value === '') return '-'; + var numeric = Number(amount.value); + return (amount.currency || 'INR') + ' ' + (isNaN(numeric) ? hesc(amount.value) : numeric.toFixed(2)); + } + html += '
Invoice Details'; + html += '' + hesc(invoice.status || 'issued') + '
'; + html += '
'; + html += '
Invoice Number
' + hesc(invoiceIdentifier || invoice.id || '-') + '
'; + html += '
Invoice Type
' + hesc(invoiceCoding.display || (invoice.type || {}).text || '-') + (invoiceCoding.code ? ' ' + hesc(invoiceCoding.code) + '' : '') + '
'; + html += '
Invoice Date
' + hesc(fmtDate(invoice.date) || '-') + '
'; + html += '
Total Price Component
' + money(totalRate) + '
'; + html += '
Net Total
' + money(totalNet) + '
'; + html += '
Gross Total
' + money(totalGross) + '
'; + html += '
'; + var invoiceItems = invoice.lineItem || []; + if (invoiceItems.length) { + html += '
'; + invoiceItems.forEach(function(item, index) { + var component = ((item.priceComponent || [])[0]) || {}; + var componentCoding = ((((component.code || {}).coding) || [])[0]) || {}; + var chargeReference = (item.chargeItemReference || {}).reference || ''; + var chargeItem = resourcesByUrl[chargeReference] || {}; + var itemName = (chargeItem.productCodeableConcept || {}).text + || (chargeItem.code || {}).text + || (item.chargeItemReference || {}).display + || (item.chargeItemCodeableConcept || {}).text + || 'Item'; + var itemQuantity = chargeItem.quantity || {}; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
#ItemComponentAmount
' + hesc(item.sequence || index + 1) + '
' + hesc(itemName) + '
'; + if (itemQuantity.value !== undefined) html += 'Qty: ' + hesc(itemQuantity.value) + ' ' + hesc(itemQuantity.unit || '') + ''; + html += '
' + hesc(componentCoding.display || component.type || '-') + (componentCoding.code ? ' ' + hesc(componentCoding.code) + '' : '') + '' + money(component.amount || {}) + '
'; + } else { + html += '
No invoice line items found.
'; + } + html += '
'; + } + + // ── Embedded invoice PDF + documentRefs.forEach(function(documentReference) { + var attachment = (((documentReference.content || [])[0] || {}).attachment) || {}; + if (attachment.contentType !== 'application/pdf') return; + var pdfUrl = attachmentObjectUrl(attachment); + html += '
'; + html += '
' + hesc(attachment.title || 'Invoice PDF') + '
'; + html += 'Attached to this FHIR InvoiceRecord
'; + if (pdfUrl) { + html += 'Open PDF'; + html += 'Download'; + } else { + html += 'Invalid PDF attachment'; + } + html += '
'; + }); + + // ── Immunization Record + if (immunizations.length) { + html += '
Vaccination Details
'; + html += '
'; + html += ''; + immunizations.forEach(function(immunization) { + var vaccineCoding = ((((immunization.vaccineCode || {}).coding) || [])[0]) || {}; + var performerReference = ((((immunization.performer || [])[0] || {}).actor) || {}); + var performerResource = resourcesByUrl[performerReference.reference || ''] || {}; + var performerName = performerReference.display || (((performerResource.name || [])[0] || {}).text) || '-'; + var locationReference = immunization.location || {}; + var locationResource = resourcesByUrl[locationReference.reference || ''] || {}; + var facilityName = locationReference.display || locationResource.name || '-'; + var protocol = ((immunization.protocolApplied || [])[0]) || {}; + var targetDisease = ((protocol.targetDisease || [])[0]) || {}; + var targetCoding = (((targetDisease.coding || [])[0]) || {}); + var dose = protocol.doseNumberPositiveInt || protocol.doseNumberString || '-'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
VaccineDateDoctor / FacilityDose / SeriesLotTarget Disease
' + hesc((immunization.vaccineCode || {}).text || vaccineCoding.display || '-') + '
'; + if (vaccineCoding.code) html += '' + hesc(vaccineCoding.system || 'Code') + ': ' + hesc(vaccineCoding.code) + ''; + html += '
' + hesc(fmtDate(immunization.occurrenceDateTime) || '-') + '
' + hesc(performerName) + '
' + hesc(facilityName) + '
Dose ' + hesc(dose) + '
' + hesc(protocol.series || '-') + '
' + hesc(immunization.lotNumber || '-') + '' + hesc(targetDisease.text || targetCoding.display || '-') + (targetCoding.code ? '
' + hesc(targetCoding.code) + '' : '') + '
'; + } + // ── Complaints if (complaints.length) { html += '
Chief Complaints
'; @@ -2206,6 +2538,9 @@ _fhirSubmitMode = options.submitMode || 'opd'; _fhirSubmitTaskRow = options.taskRow || null; _fhirSubmitAbha = (options.abhaId || '').trim(); + _fhirInvoiceSource = (options.invoiceSource || '').trim(); + _fhirInvoiceBillId = parseInt(options.invoiceBillId || '0', 10) || 0; + _fhirInvoicePatientId = parseInt(options.invoicePatientId || '0', 10) || 0; var rowPushStatus = (options.pushStatus || '').toString().trim().toLowerCase(); _fhirOpdId = 0; _fhirSessionId = 0; @@ -2230,6 +2565,15 @@ btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-warning'; btnSubmitFhirToAbdm.innerHTML = ' Submit to Gateway'; btnSubmitFhirToAbdm.title = 'Force gateway push for this OPD consult'; + } else if (_fhirSubmitMode === 'invoice') { + btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-warning'; + btnSubmitFhirToAbdm.innerHTML = ' Push Invoice'; + btnSubmitFhirToAbdm.title = 'Push this InvoiceRecord to the ABDM gateway'; + if (['linked', 'queued', 'pushed'].indexOf(rowPushStatus) !== -1) { + btnSubmitFhirToAbdm.disabled = true; + btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-secondary'; + btnSubmitFhirToAbdm.innerHTML = ' Already Submitted'; + } } else { btnSubmitFhirToAbdm.className = defaultSubmitBtnClass; btnSubmitFhirToAbdm.innerHTML = defaultSubmitBtnHtml; @@ -2244,7 +2588,14 @@ } if (btnRegenerateFhirModal) { + btnRegenerateFhirModal.disabled = false; btnRegenerateFhirModal.style.display = _fhirSubmitMode === 'task' ? 'none' : ''; + btnRegenerateFhirModal.innerHTML = _fhirSubmitMode === 'invoice' + ? ' Rebuild Bundle' + : ' Regenerate'; + btnRegenerateFhirModal.title = _fhirSubmitMode === 'invoice' + ? 'Rebuild FHIR JSON from current billing data' + : 'Rebuild FHIR bundle from current prescription data'; } fhirPreviewModal.show(); @@ -2281,6 +2632,25 @@ // Regenerate FHIR bundle in modal btnRegenerateFhirModal.addEventListener('click', function () { var btn = this; + if (_fhirSubmitMode === 'invoice') { + if (!_fhirInvoiceSource || _fhirInvoiceBillId <= 0 || _fhirInvoicePatientId <= 0) { + alert('Invoice source data is incomplete.'); + return; + } + if (!confirm('Rebuild this FHIR InvoiceRecord from current billing data?')) return; + var invoiceUrl = '' + + '?source=' + encodeURIComponent(_fhirInvoiceSource) + + '&bill_id=' + encodeURIComponent(String(_fhirInvoiceBillId)) + + '&patient_id=' + encodeURIComponent(String(_fhirInvoicePatientId)); + openFhirModal(invoiceUrl, fhirModalTitle ? fhirModalTitle.textContent : 'FHIR Invoice Preview', { + submitMode: 'invoice', + taskRow: _fhirSubmitTaskRow, + invoiceSource: _fhirInvoiceSource, + invoiceBillId: _fhirInvoiceBillId, + invoicePatientId: _fhirInvoicePatientId + }); + return; + } if (_fhirOpdId <= 0) { alert('No FHIR bundle loaded.'); return; } if (!confirm('Rebuild FHIR bundle for OPD #' + _fhirOpdId + '?\nThis overwrites the stored bundle with fresh data.')) return; var origHtml = btn.innerHTML; @@ -2338,6 +2708,61 @@ btnSubmitFhirToAbdm.addEventListener('click', function () { var btn = this; + if (_fhirSubmitMode === 'invoice') { + if (!_fhirInvoiceSource || _fhirInvoiceBillId <= 0 || _fhirInvoicePatientId <= 0) { + alert('Invoice source data is incomplete.'); + return; + } + if (!confirm('Push this verified InvoiceRecord to the ABDM gateway now?')) { + return; + } + + var originalInvoiceHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Pushing...'; + post('', { + source: _fhirInvoiceSource, + bill_id: _fhirInvoiceBillId, + patient_id: _fhirInvoicePatientId, + consent_handle: '' + }, function (res) { + if (!res || parseInt(res.ok || '0', 10) !== 1) { + btn.disabled = false; + btn.innerHTML = originalInvoiceHtml; + var errorText = (res && (res.error_text || res.error || res.message)) || 'Invoice push failed'; + setStatus(errorText, true); + alert('Invoice push failed:\n' + errorText); + return; + } + + var invoiceRow = _fhirSubmitTaskRow; + if (invoiceRow) { + invoiceRow.setAttribute('data-push-status', res.status === 'local_stored' ? 'local_only' : 'queued'); + var statusBadge = invoiceRow.querySelector('.invoice-status-badge'); + if (statusBadge) { + statusBadge.textContent = res.status === 'local_stored' ? 'Local Only' : 'Submitted'; + statusBadge.className = 'badge ' + (res.status === 'local_stored' ? 'bg-info' : 'bg-warning text-dark') + ' invoice-status-badge'; + } + var details = invoiceRow.querySelector('.invoice-details'); + if (details) { + details.innerHTML = res.queue_id + ? '
Queue: ' + hesc(String(res.queue_id)) + '
' + : '' + hesc(res.message || '-') + ''; + } + var rowPushButton = invoiceRow.querySelector('.btn-invoice-push'); + if (rowPushButton && res.status !== 'local_stored') { + rowPushButton.disabled = true; + } + } + + btn.className = 'btn btn-sm btn-success'; + btn.innerHTML = ' ' + (res.status === 'local_stored' ? 'Stored Locally' : 'Submitted'); + setStatus(res.message || (res.status === 'local_stored' ? 'Invoice stored locally; patient ABHA is unavailable.' : 'Invoice submitted to ABDM gateway.')); + alert(res.message || (res.status === 'local_stored' ? 'Invoice stored locally; patient ABHA is unavailable.' : 'Invoice submitted to ABDM gateway.')); + }); + return; + } + if (_fhirSubmitMode === 'task') { if (!_fhirSubmitTaskRow) { alert('No ABDM task selected for submission.'); @@ -2449,6 +2874,36 @@ return; } + if (taskType === 'immunization_record_publish') { + var immunizationId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0; + var immunizationPatientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0; + if (immunizationId <= 0 || immunizationPatientId <= 0) { + alert('Task data missing (record_id/patient_id).'); + return; + } + + post('', { + task_id: parseInt(_fhirSubmitTaskRow.getAttribute('data-task-id') || '0', 10) || 0, + patient_id: immunizationPatientId, + record_id: immunizationId, + abha_id: _fhirSubmitAbha, + push_to_gateway: 1 + }, function (res) { + if (!res || parseInt(res.ok || '0', 10) !== 1) { + alert('ABDM Bridge push failed:\n' + ((res && (res.error_text || res.error || res.message)) || 'Unknown error')); + return; + } + var badge = _fhirSubmitTaskRow.querySelector('.status-pill'); + if (badge) { + badge.textContent = 'BRIDGE SUBMITTED'; + badge.className = 'badge bg-info status-pill'; + } + setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-')); + fhirPreviewModal.hide(); + }); + return; + } + runAction(_fhirSubmitTaskRow, 'submit', _fhirSubmitAbha); fhirPreviewModal.hide(); return; @@ -2570,6 +3025,59 @@ }); }); + function openInvoicePreview(row, pushIntent) { + var source = (row.getAttribute('data-invoice-source') || '').trim(); + var billId = parseInt(row.getAttribute('data-bill-id') || '0', 10) || 0; + var patientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0; + var pushStatus = (row.getAttribute('data-push-status') || '').trim(); + if (!source || billId <= 0 || patientId <= 0) { + setStatus('Invoice source data is incomplete.', true); + return; + } + var previewUrl = '' + + '?source=' + encodeURIComponent(source) + + '&bill_id=' + encodeURIComponent(String(billId)) + + '&patient_id=' + encodeURIComponent(String(patientId)); + openFhirModal(previewUrl, (pushIntent ? 'Review & Push' : 'FHIR Preview') + ' — Invoice #' + billId, { + submitMode: 'invoice', + taskRow: row, + pushStatus: pushStatus, + invoiceSource: source, + invoiceBillId: billId, + invoicePatientId: patientId + }); + } + + document.querySelectorAll('.btn-invoice-preview').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr'); + if (row) openInvoicePreview(row, false); + }); + }); + + document.querySelectorAll('.btn-invoice-push').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr'); + if (row) openInvoicePreview(row, true); + }); + }); + + var dashboardRangeForm = document.getElementById('dashboardRangeForm'); + if (dashboardRangeForm) { + dashboardRangeForm.addEventListener('submit', function (event) { + if (typeof window.load_form !== 'function') { + return; + } + event.preventDefault(); + var dateFrom = document.getElementById('dashboardDateFrom').value; + var dateTo = document.getElementById('dashboardDateTo').value; + var rangeUrl = refreshUrl + + '?date_from=' + encodeURIComponent(dateFrom) + + '&date_to=' + encodeURIComponent(dateTo); + window.load_form(rangeUrl, 'ABDM Task Board'); + }); + } + document.getElementById('btnRefresh').addEventListener('click', function () { if (typeof window.load_form === 'function') { window.load_form(refreshUrl, 'ABDM Task Board'); diff --git a/tests/unit/Abdm/AbdmGatewayPushClientTest.php b/tests/unit/Abdm/AbdmGatewayPushClientTest.php index d3f33c9..a75a4f3 100644 --- a/tests/unit/Abdm/AbdmGatewayPushClientTest.php +++ b/tests/unit/Abdm/AbdmGatewayPushClientTest.php @@ -1,6 +1,7 @@ assertTrue((bool) ($validation['valid'] ?? false)); } + + public function testDuplicateRecordConflictIsNormalizedAsAcceptedSuccess(): void + { + $method = new ReflectionMethod(AbdmGatewayPushClient::class, 'mapResponse'); + $client = (new ReflectionClass(AbdmGatewayPushClient::class))->newInstanceWithoutConstructor(); + $result = $method->invoke($client, 409, [ + 'error' => 'DUPLICATE_RECORD', + 'existing_record_id' => 91, + 'existing_queue_id' => 'QUEUE-OLD', + 'first_pushed_at' => '2026-08-10T09:00:00+05:30', + ], 'request-1'); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['duplicate']); + $this->assertSame(91, $result['gateway_record_id']); + $this->assertSame('QUEUE-OLD', $result['gateway_queue_id']); + $this->assertSame('2026-08-10T09:00:00+05:30', $result['first_pushed_at']); + } + + public function testCreatedRecordIsNormalizedAsSuccess(): void + { + $method = new ReflectionMethod(AbdmGatewayPushClient::class, 'mapResponse'); + $client = (new ReflectionClass(AbdmGatewayPushClient::class))->newInstanceWithoutConstructor(); + $result = $method->invoke($client, 201, [ + 'record_id' => 92, + 'queue_id' => 'QUEUE-NEW', + ], 'request-created'); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['submitted']); + $this->assertSame(92, $result['gateway_record_id']); + } + + public function testGenericConflictRemainsFailure(): void + { + $method = new ReflectionMethod(AbdmGatewayPushClient::class, 'mapResponse'); + $client = (new ReflectionClass(AbdmGatewayPushClient::class))->newInstanceWithoutConstructor(); + $result = $method->invoke($client, 409, ['error_code' => 'CONFLICT'], 'request-2'); + + $this->assertFalse($result['ok']); + $this->assertSame('failed', $result['status']); + } + + public function testDirectConnectorNormalizesDuplicateMetadataWithoutHttpCall(): void + { + $method = new ReflectionMethod(EAtriaBridgeConnector::class, 'normalizePushRecordResponse'); + $result = $method->invoke(null, [ + 'http_code' => 409, + 'error' => ['code' => 'DUPLICATE_RECORD'], + 'data' => [ + 'existing_record_id' => 77, + 'existing_queue_id' => 'QUEUE-77', + 'first_pushed_at' => '2026-08-09T08:00:00+05:30', + ], + ]); + + $this->assertSame(1, $result['ok']); + $this->assertSame(77, $result['record_id']); + $this->assertSame('QUEUE-77', $result['queue_id']); + $this->assertSame('2026-08-09T08:00:00+05:30', $result['first_pushed_at']); + } + + public function testValidationFailureIncludesGatewayFieldErrors(): void + { + $method = new ReflectionMethod(EAtriaBridgeConnector::class, 'normalizePushRecordResponse'); + $result = $method->invoke(null, [ + 'http_code' => 422, + 'message' => 'FHIR validation failed', + 'errors' => [['field' => 'fhir_bundle.entry', 'message' => 'must not be empty']], + ]); + + $this->assertSame(0, $result['ok']); + $this->assertStringContainsString('fhir_bundle.entry must not be empty', $result['error_text']); + } } diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index 8421ab8..feda632 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -1,6 +1,9 @@ factory = new FhirGeneratorFactory(); } + public function testImmunizationCareContextReferenceIsStableAcrossClinicalDateChanges(): void + { + $method = new ReflectionMethod(AbdmGateway::class, 'resolveImmunizationCareContextReference'); + + $first = $method->invoke(null, [[ + 'id' => 47, + 'given_date' => '2026-08-10 09:00:00', + 'abdm_care_context_reference' => null, + ]], 47, 12); + $retry = $method->invoke(null, [[ + 'id' => 47, + 'given_date' => '2026-08-15 11:30:00', + 'abdm_care_context_reference' => null, + ]], 47, 12); + + $this->assertSame('IMM-47', $first); + $this->assertSame($first, $retry); + $this->assertSame('LEGACY-IMM-47', $method->invoke(null, [[ + 'id' => 47, + 'abdm_care_context_reference' => 'LEGACY-IMM-47', + ]], 47, 12)); + } + public function testOpdGeneratorHappyPath(): void { $src = [ @@ -78,4 +104,298 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame($o1['care_context_reference'], $o2['care_context_reference']); $this->assertSame($o1['hi_type'], $o2['hi_type']); } + + public function testDischargeDocumentHasValidStructureAndResolvableReferences(): void + { + $src = [ + 'record_id' => 4, + 'bundle_identifier' => 'discharge-A26080000004', + 'session_id' => 4, + 'visit_date' => '2026-08-15', + 'completed_at' => '2026-08-15T12:00:00+05:30', + 'patient' => ['id' => 11, 'name' => 'Test Patient', 'gender' => 'male', 'dob' => '1979-03-28'], + 'encounter' => ['id' => 'A26080000004', 'class_code' => 'IMP', 'start' => '2026-08-14T09:00:00+05:30', 'end' => '2026-08-15T12:00:00+05:30'], + 'doctor' => ['id' => 9, 'name' => 'Test Doctor'], + 'organization' => ['id' => 'H1', 'name' => 'Test Hospital'], + 'conditions' => [['text' => 'Viral fever']], + 'procedures' => [['text' => 'Supportive care']], + 'medications' => [['name' => 'Paracetamol', 'dosage' => 'As directed']], + 'care_plans' => [['title' => 'Advice', 'description' => 'Review after three days']], + 'documents' => [[ + 'title' => 'Discharge Summary PDF', + 'content_type' => 'application/pdf', + 'data' => base64_encode('%PDF-test'), + 'created_at' => '2026-08-15T12:00:00+05:30', + ]], + ]; + + $bundle = $this->factory->discharge()->generate($src)['fhir_bundle']; + $composition = $bundle['entry'][0]['resource']; + $this->assertSame('discharge-A26080000004', $bundle['identifier']['value'] ?? null); + $this->assertSame('IPD Discharge Summary', $composition['title'] ?? null); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord', $composition['meta']['profile'] ?? []); + $this->assertNotEmpty($composition['author'] ?? []); + $this->assertNotEmpty($composition['custodian'] ?? []); + $this->assertNotEmpty($composition['section'] ?? []); + $this->assertNotEmpty($composition['section'][0]['text']['div'] ?? ''); + + $fullUrls = array_column($bundle['entry'], 'fullUrl'); + foreach ($fullUrls as $fullUrl) { + $this->assertMatchesRegularExpression('/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/', $fullUrl); + } + + $encounters = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'Encounter')); + $patients = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'Patient')); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient', $patients[0]['resource']['meta']['profile'] ?? []); + $this->assertSame('IMP', $encounters[0]['resource']['class']['code'] ?? null); + $this->assertSame('A26080000004', $encounters[0]['resource']['identifier'][0]['value'] ?? null); + $documents = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'DocumentReference')); + $this->assertCount(1, $documents); + $this->assertSame('%PDF-test', base64_decode((string) ($documents[0]['resource']['content'][0]['attachment']['data'] ?? ''), true)); + + $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES); + preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches); + foreach ($matches[1] as $reference) { + $this->assertContains($reference, $fullUrls, 'Unresolved document reference: ' . $reference); + } + $this->assertStringNotContainsString('UNMAPPED', (string) $encoded); + } + + public function testInvoiceRecordHasNrcesProfilesAndResolvableReferences(): void + { + $controller = new AbdmGateway(); + $method = new ReflectionMethod($controller, 'buildSimpleInvoiceBundle'); + $bundle = $method->invoke($controller, [ + 'resourceType' => 'Patient', + 'id' => 'patient-11', + 'identifier' => [[ + 'system' => 'https://healthid.ndhm.gov.in/abha-address', + 'value' => 'testpatient@sbx', + ]], + 'name' => [['text' => 'Test Patient']], + 'gender' => 'male', + 'birthDate' => '1990-01-01', + ], [ + 'id' => 19, + 'invoice_code' => 'OPD-19', + 'invoice_type_code' => '03', + 'invoice_type_display' => 'OPD', + 'encounter_class' => 'AMB', + 'practitioner_id' => 7, + 'practitioner_name' => 'Dr Test', + 'encounter_start' => '2026-08-14', + 'inv_date' => '2026-08-15', + 'net_amount' => 300, + 'total_amount' => 350, + ], [[ + 'item_name' => 'OPD Consultation Fee', + 'item_qty' => 1, + 'item_rate' => 300, + 'item_amount' => 300, + ]]); + + $this->assertSame('document', $bundle['type'] ?? null); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []); + $this->assertSame('OPD-19', $bundle['identifier']['value'] ?? null); + $this->assertSame('Composition', $bundle['entry'][0]['resource']['resourceType'] ?? null); + + $resources = []; + foreach ($bundle['entry'] as $entry) { + $fullUrl = (string) ($entry['fullUrl'] ?? ''); + $this->assertMatchesRegularExpression('/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/', $fullUrl); + $resources[(string) ($entry['resource']['resourceType'] ?? '')] = $entry['resource']; + } + + $composition = $resources['Composition']; + $invoice = $resources['Invoice']; + $patient = $resources['Patient']; + $organization = $resources['Organization']; + $encounter = $resources['Encounter']; + $practitioner = $resources['Practitioner']; + $documentReference = $resources['DocumentReference']; + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/InvoiceRecord', $composition['meta']['profile'] ?? []); + $this->assertSame('Invoice Record', $composition['type']['text'] ?? null); + $this->assertSame('69705-9', $composition['type']['coding'][0]['code'] ?? null); + $this->assertSame( + 'Invoice', + $composition['section'][0]['entry'][0]['type'] ?? null, + 'NRCeS InvoiceRecord 6.5.0 requires Reference.type to be Invoice' + ); + $this->assertCount(1, $composition['section'][0]['entry'] ?? []); + $this->assertNotEmpty($composition['author'][0]['reference'] ?? ''); + $this->assertNotEmpty($composition['custodian']['reference'] ?? ''); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient', $patient['meta']['profile'] ?? []); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization', $organization['meta']['profile'] ?? []); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter', $encounter['meta']['profile'] ?? []); + $this->assertSame('2026-08-14', $encounter['period']['start'] ?? null); + $this->assertNotEmpty($encounter['serviceProvider']['reference'] ?? ''); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner', $practitioner['meta']['profile'] ?? []); + $this->assertSame('MD', $practitioner['identifier'][0]['type']['coding'][0]['code'] ?? null); + $this->assertSame('7', $practitioner['identifier'][0]['value'] ?? null); + $this->assertSame('Dr Test', $practitioner['name'][0]['text'] ?? null); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Invoice', $invoice['meta']['profile'] ?? []); + $this->assertSame('Dr Test', $invoice['participant'][0]['actor']['display'] ?? null); + $this->assertSame('03', $invoice['type']['coding'][0]['code'] ?? null); + $this->assertSame('01', $invoice['lineItem'][0]['priceComponent'][0]['code']['coding'][0]['code'] ?? null); + $this->assertArrayHasKey('chargeItemReference', $invoice['lineItem'][0]); + $chargeItems = array_values(array_filter( + $bundle['entry'], + static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'ChargeItem' + )); + $this->assertCount(1, $chargeItems); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/ChargeItem', $chargeItems[0]['resource']['meta']['profile'] ?? []); + $this->assertSame('OPD Consultation Fee', $chargeItems[0]['resource']['code']['text'] ?? null); + $this->assertSame('OPD Consultation Fee', $chargeItems[0]['resource']['productCodeableConcept']['text'] ?? null); + $this->assertSame(1.0, $chargeItems[0]['resource']['quantity']['value'] ?? null); + $this->assertSame('unit', $chargeItems[0]['resource']['quantity']['unit'] ?? null); + $this->assertSame(300.0, $invoice['totalNet']['value'] ?? null, 'NRCeS Invoice requires totalNet'); + $this->assertSame(350.0, $invoice['totalGross']['value'] ?? null, 'NRCeS Invoice requires totalGross'); + $this->assertSame(300.0, $invoice['totalPriceComponent'][0]['amount']['value'] ?? null); + $this->assertContains( + 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference', + $documentReference['meta']['profile'] ?? [] + ); + $attachment = $documentReference['content'][0]['attachment'] ?? []; + $pdfBytes = base64_decode((string) ($attachment['data'] ?? ''), true); + $this->assertIsString($pdfBytes); + $this->assertSame('application/pdf', $attachment['contentType'] ?? null); + $this->assertStringStartsWith('%PDF-', $pdfBytes); + $this->assertSame(strlen($pdfBytes), $attachment['size'] ?? null); + $this->assertSame(base64_encode(sha1($pdfBytes, true)), $attachment['hash'] ?? null); + $this->assertSame( + $composition['section'][0]['entry'][0]['reference'] ?? null, + $documentReference['context']['related'][0]['reference'] ?? null + ); + + $fullUrls = array_column($bundle['entry'], 'fullUrl'); + $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES); + preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches); + foreach ($matches[1] as $reference) { + $this->assertContains($reference, $fullUrls, 'Unresolved invoice reference: ' . $reference); + } + } + + public function testImmunizationRecordHasM2DocumentStructureAndResolvableReferences(): void + { + $bundle = (new FhirR4Builder())->buildImmunizationRecordBundle([ + 'id' => '91', + 'name' => 'Test Patient', + 'gender' => 'female', + 'birthDate' => '2020-01-10', + 'abhaAddress' => 'testpatient@sbx', + ], [[ + 'id' => '17', + 'vaccine_name' => 'BCG Vaccine', + 'vaccine_code_system' => 'https://hms.local/immunization/uip', + 'vaccine_code' => 'UIP-BCG', + 'given_date' => '2026-08-15', + 'status' => 'completed', + 'lot_number' => 'LOT-42', + 'dose_number' => '1', + 'series_name' => 'Infant Immunization Schedule', + 'target_disease_code' => '56717001', + 'target_disease_name' => 'Tuberculosis', + ]], [ + 'care_context_reference' => 'IMM-17-91-20260815', + 'practitioner' => [ + 'id' => '7', + 'name' => 'Dr Test', + 'registration_number' => 'MED-7', + ], + 'organization' => ['name' => 'Test Hospital', 'hfr_id' => 'IN0000000001'], + 'encounter' => [ + 'id' => 'IMM-17', + 'status' => 'finished', + 'class_code' => 'AMB', + 'period_start' => '2026-08-15', + ], + ]); + + $this->assertSame('document', $bundle['type'] ?? null); + $this->assertSame('IMM-17-91-20260815', $bundle['identifier']['value'] ?? null); + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []); + + $resources = []; + $fullUrls = array_column($bundle['entry'], 'fullUrl'); + foreach ($bundle['entry'] as $entry) { + $resource = $entry['resource'] ?? []; + $resources[(string) ($resource['resourceType'] ?? '')] = $resource; + } + + $composition = $resources['Composition']; + $immunization = $resources['Immunization']; + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord', $composition['meta']['profile'] ?? []); + $this->assertSame('41000179103', $composition['type']['coding'][0]['code'] ?? null); + $this->assertSame('Dr Test', $composition['author'][0]['display'] ?? null); + $this->assertSame('Test Hospital', $composition['custodian']['display'] ?? null); + $this->assertSame('Immunization', $composition['section'][0]['entry'][0]['type'] ?? null); + $this->assertSame('BCG Vaccine', $immunization['vaccineCode']['text'] ?? null); + $this->assertSame('http://hl7.org/fhir/sid/cvx', $immunization['vaccineCode']['coding'][0]['system'] ?? null); + $this->assertSame('19', $immunization['vaccineCode']['coding'][0]['code'] ?? null); + $this->assertSame('2026-08-15T00:00:00+05:30', $immunization['occurrenceDateTime'] ?? null); + $this->assertNotEmpty($immunization['performer'][0]['actor']['reference'] ?? ''); + $this->assertNotEmpty($immunization['encounter']['reference'] ?? ''); + $this->assertSame('Test Hospital', $immunization['location']['display'] ?? null); + $this->assertSame('Infant Immunization Schedule', $immunization['protocolApplied'][0]['series'] ?? null); + $this->assertSame(1, $immunization['protocolApplied'][0]['doseNumberPositiveInt'] ?? null); + $this->assertSame('56717001', $immunization['protocolApplied'][0]['targetDisease'][0]['coding'][0]['code'] ?? null); + + $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES); + preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches); + foreach ($matches[1] as $reference) { + $this->assertContains($reference, $fullUrls, 'Unresolved ImmunizationRecord reference: ' . $reference); + } + } + + public function testImmunizationBuilderFallbackReferenceIsStableOnRetry(): void + { + $builder = new FhirR4Builder(); + $patient = ['id' => '91', 'name' => 'Test Patient']; + $first = $builder->buildImmunizationRecordBundle($patient, [[ + 'id' => '17', + 'vaccine_name' => 'BCG Vaccine', + 'given_date' => '2026-08-10', + 'status' => 'completed', + ]]); + $retry = $builder->buildImmunizationRecordBundle($patient, [[ + 'id' => '17', + 'vaccine_name' => 'BCG Vaccine', + 'given_date' => '2026-08-15', + 'status' => 'completed', + ]]); + + $this->assertSame('IMM-17', $first['identifier']['value'] ?? null); + $this->assertSame($first['identifier']['value'] ?? null, $retry['identifier']['value'] ?? null); + } + + public function testLegacyNumericGenderMapsToFhirGender(): void + { + $controller = new AbdmGateway(); + $method = new ReflectionMethod($controller, 'normalizeFhirGender'); + + $this->assertSame('male', $method->invoke($controller, '1')); + $this->assertSame('female', $method->invoke($controller, '2')); + $this->assertSame('other', $method->invoke($controller, '3')); + } + + public function testBlankDischargeTemplateUsesDefaultHospitalHeader(): void + { + $controller = new Ipd_discharge(); + $blankMethod = new ReflectionMethod($controller, 'isDischargeHeaderBlank'); + $headerMethod = new ReflectionMethod($controller, 'buildDefaultDischargeHeader'); + + $this->assertTrue($blankMethod->invoke($controller, '
 
')); + $this->assertFalse($blankMethod->invoke($controller, '
Custom Letterhead
')); + + $header = $headerMethod->invoke($controller, [ + 'H_Name' => 'Test Hospital', + 'hospital_address' => 'Hospital Road', + 'H_phone_No' => '1234567890', + 'H_Email' => 'care@example.test', + 'H_logo_abs' => '', + ]); + $this->assertStringContainsString('Test Hospital', $header); + $this->assertStringContainsString('Hospital Road', $header); + } } diff --git a/tests/unit/Abdm/M2LinkStatusResolverTest.php b/tests/unit/Abdm/M2LinkStatusResolverTest.php new file mode 100644 index 0000000..40bb4a3 --- /dev/null +++ b/tests/unit/Abdm/M2LinkStatusResolverTest.php @@ -0,0 +1,42 @@ + 'req-42', + 'abha_address' => 'patient@abdm', + 'careContextReference' => 'IMM-17-91-20260815', + 'linkedAt' => '2026-08-15 12:30:00', + 'status' => 'SUCCESS', + ]); + + $this->assertSame('req-42', $callback['request_id']); + $this->assertSame('IMM-17-91-20260815', $callback['care_context_reference']); + $this->assertSame('linked', $callback['status']); + } + + public function testHeaderRequestIdIsUsedAsFallback(): void + { + $callback = M2LinkStatusResolver::parse([ + 'abha_id' => '12345678901234', + 'care_context_reference' => 'OPD-10-S1-2026-08-15', + 'status' => 'linked', + ], 'header-request-id'); + + $this->assertSame('header-request-id', $callback['request_id']); + } + + public function testLinkedStateCannotBeDowngradedByLateCallback(): void + { + $this->assertSame('linked', M2LinkStatusResolver::merge('linked', 'failed')); + $this->assertSame('linked', M2LinkStatusResolver::merge('linked', 'pending')); + $this->assertSame('failed', M2LinkStatusResolver::merge('pending', 'rejected')); + $this->assertSame('failed', M2LinkStatusResolver::merge('failed', 'pending')); + $this->assertSame('linked', M2LinkStatusResolver::merge('failed', 'completed')); + } +}