From ed48a447e5192e7706531e4093e496fcfbf2c866 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sat, 27 Jun 2026 01:53:44 +0530 Subject: [PATCH] ABDM M2: add push-sync outbox, worker, and FHIR generators --- ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md | 68 ++++ app/Commands/AbdmPushSync.php | 43 +++ app/Config/Routes.php | 4 + app/Controllers/AbdmSync.php | 103 ++++++ ...-06-27-000613_CreateAbdmPushSyncTables.php | 314 ++++++++++++++++ .../Abdm/Fhir/FhirDocumentBuilder.php | 216 +++++++++++ .../Abdm/Fhir/FhirGeneratorFactory.php | 37 ++ .../AbstractModuleFhirGenerator.php | 189 ++++++++++ .../Generators/DischargeFhirGenerator.php | 107 ++++++ .../Fhir/Generators/InvoiceFhirGenerator.php | 110 ++++++ .../Abdm/Fhir/Generators/LabFhirGenerator.php | 151 ++++++++ .../Abdm/Fhir/Generators/OpdFhirGenerator.php | 137 +++++++ .../Generators/RadiologyFhirGenerator.php | 161 ++++++++ .../Abdm/Fhir/Support/CodingResolver.php | 190 ++++++++++ .../Abdm/Fhir/Support/FhirBundleValidator.php | 143 ++++++++ .../Fhir/Support/FhirValidationResult.php | 52 +++ .../Fhir/Support/GatewayPayloadAdapter.php | 39 ++ .../Abdm/Sync/AbdmGatewayPushClient.php | 346 ++++++++++++++++++ .../Abdm/Sync/AbdmSyncOutboxService.php | 332 +++++++++++++++++ .../Abdm/Sync/AbdmSyncWorkerService.php | 246 +++++++++++++ app/Models/AbdmSyncOutboxModel.php | 30 ++ app/Models/AbdmSyncPatientModel.php | 33 ++ app/Models/AbdmSyncRecordModel.php | 38 ++ tests/unit/Abdm/AbdmGatewayPushClientTest.php | 40 ++ tests/unit/Abdm/AbdmSyncOutboxServiceTest.php | 25 ++ tests/unit/Abdm/FhirGeneratorsTest.php | 81 ++++ .../unit/Abdm/IntegrationMappingStubTest.php | 59 +++ .../unit/Abdm/fixtures/discharge_source.json | 26 ++ tests/unit/Abdm/fixtures/invoice_source.json | 27 ++ tests/unit/Abdm/fixtures/lab_source.json | 22 ++ tests/unit/Abdm/fixtures/opd_source.json | 36 ++ 31 files changed, 3405 insertions(+) create mode 100644 ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md create mode 100644 app/Commands/AbdmPushSync.php create mode 100644 app/Controllers/AbdmSync.php create mode 100644 app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php create mode 100644 app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php create mode 100644 app/Libraries/Abdm/Fhir/FhirGeneratorFactory.php create mode 100644 app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php create mode 100644 app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php create mode 100644 app/Libraries/Abdm/Fhir/Generators/InvoiceFhirGenerator.php create mode 100644 app/Libraries/Abdm/Fhir/Generators/LabFhirGenerator.php create mode 100644 app/Libraries/Abdm/Fhir/Generators/OpdFhirGenerator.php create mode 100644 app/Libraries/Abdm/Fhir/Generators/RadiologyFhirGenerator.php create mode 100644 app/Libraries/Abdm/Fhir/Support/CodingResolver.php create mode 100644 app/Libraries/Abdm/Fhir/Support/FhirBundleValidator.php create mode 100644 app/Libraries/Abdm/Fhir/Support/FhirValidationResult.php create mode 100644 app/Libraries/Abdm/Fhir/Support/GatewayPayloadAdapter.php create mode 100644 app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php create mode 100644 app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php create mode 100644 app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php create mode 100644 app/Models/AbdmSyncOutboxModel.php create mode 100644 app/Models/AbdmSyncPatientModel.php create mode 100644 app/Models/AbdmSyncRecordModel.php create mode 100644 tests/unit/Abdm/AbdmGatewayPushClientTest.php create mode 100644 tests/unit/Abdm/AbdmSyncOutboxServiceTest.php create mode 100644 tests/unit/Abdm/FhirGeneratorsTest.php create mode 100644 tests/unit/Abdm/IntegrationMappingStubTest.php create mode 100644 tests/unit/Abdm/fixtures/discharge_source.json create mode 100644 tests/unit/Abdm/fixtures/invoice_source.json create mode 100644 tests/unit/Abdm/fixtures/lab_source.json create mode 100644 tests/unit/Abdm/fixtures/opd_source.json diff --git a/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md b/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md new file mode 100644 index 0000000..f13e48b --- /dev/null +++ b/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md @@ -0,0 +1,68 @@ +# ABDM M2 Push Sync Implementation + +## Architecture Summary +- Added dedicated sync tables: `abdm_sync_patient`, `abdm_sync_record`, `abdm_sync_outbox`. +- Added queue/outbox service and worker command for reliable push delivery. +- Added Gateway client for `/api/v3/records/push` with response mapping (201/409/4xx/5xx). +- Added FHIR generator framework with module-specific generators. +- Added advanced coding resolver with LOINC/SNOMED/UCUM fallback logic and validation scoring. +- Added observability endpoints: + - `GET /AbdmSync/summary` + - `POST /AbdmSync/replay_dead/{id}` + +## HMS Field Mapping Contract (Core) +- `patient.id` -> `Patient.identifier[system=https://hms.local/patient-id].value` (mandatory) +- `patient.abha_id` -> `Patient.identifier[system=https://healthid.ndhm.gov.in].value` (optional) +- `patient.abha_address` -> `Patient.identifier[system=https://abdm.gov.in/health-address].value` (optional) +- `encounter.id` -> `Encounter.identifier.value` (optional/mandatory if linked visit) +- `record_id` -> Composition/diagnostic/claim resource IDs (mandatory) +- `completed_at` -> `Composition.date`, `Bundle.timestamp`, report `issued` (mandatory) +- `visit_date` -> gateway payload `visit_date` (mandatory for sync payload) + +## HI Type Mapping +- OPD -> `OPConsultRecord` +- Lab -> `DiagnosticReportRecord` +- Discharge -> `DischargeSummaryRecord` +- Invoice -> `InvoiceRecord` +- Radiology -> `DiagnosticReportRecord` + +## Quality Policy +- Score >= 85: allow push +- Score 70-84: allow push with warnings +- Score < 70: mark review-needed (policy hook available) + +## Outbox Retry Policy +- 8 retries: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 8h +- After retry 8 -> `dead` + +## Security Notes +- Bearer token never logged. +- ABHA masked in logs (last 4 visible). +- Payload logs exclude full FHIR bundle at info level. + +## Sample Generator Outputs +### OPD +- Source fixture: `tests/unit/Abdm/fixtures/opd_source.json` +- Generator: `App\\Libraries\\Abdm\\Fhir\\Generators\\OpdFhirGenerator` +- Output: `hi_type=OPConsultRecord`, deterministic care-context. + +### Lab +- Source fixture: `tests/unit/Abdm/fixtures/lab_source.json` +- Generator: `App\\Libraries\\Abdm\\Fhir\\Generators\\LabFhirGenerator` +- Includes `DiagnosticReport` + linked `Observation` resources. + +### Discharge +- Source fixture: `tests/unit/Abdm/fixtures/discharge_source.json` +- Generator: `App\\Libraries\\Abdm\\Fhir\\Generators\\DischargeFhirGenerator` + +### Invoice +- Source fixture: `tests/unit/Abdm/fixtures/invoice_source.json` +- Generator: `App\\Libraries\\Abdm\\Fhir\\Generators\\InvoiceFhirGenerator` + +## Commands +- Worker: `php spark abdm:push-sync --limit=20` +- Existing bridge worker remains separate: `php spark bridge:sync` + +## Manual Replay +- Endpoint: `POST /AbdmSync/replay_dead/{id}` +- Resets dead row to `pending` for reprocessing. diff --git a/app/Commands/AbdmPushSync.php b/app/Commands/AbdmPushSync.php new file mode 100644 index 0000000..b682352 --- /dev/null +++ b/app/Commands/AbdmPushSync.php @@ -0,0 +1,43 @@ + 'Maximum outbox rows to process in this run.', + '--worker' => 'Worker identifier for lock tracking.', + ]; + + public function run(array $params) + { + $limit = (int) (CLI::getOption('limit') ?? 20); + if ($limit <= 0) { + $limit = 20; + } + + $worker = trim((string) (CLI::getOption('worker') ?? 'spark-abdm-push-sync')); + if ($worker === '') { + $worker = 'spark-abdm-push-sync'; + } + + $service = new AbdmSyncWorkerService(); + $summary = $service->process($limit, $worker); + + CLI::write('ABDM Push Sync', 'yellow'); + CLI::write('Processed: ' . (int) ($summary['processed'] ?? 0)); + CLI::write('Success: ' . (int) ($summary['success'] ?? 0), 'green'); + CLI::write('Failed: ' . (int) ($summary['failed'] ?? 0), ((int) ($summary['failed'] ?? 0) > 0 ? 'red' : 'green')); + CLI::write('Dead: ' . (int) ($summary['dead'] ?? 0), ((int) ($summary['dead'] ?? 0) > 0 ? 'red' : 'green')); + CLI::write('Skipped: ' . (int) ($summary['skipped'] ?? 0)); + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 254bfbb..833abd0 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -686,6 +686,10 @@ $routes->post('AbdmTaskBoard/mark_status', 'AbdmTaskBoard::markStatus', ['filter $routes->get('AbdmBridgeLog', 'AbdmBridgeLog::index', ['filter' => 'permission:abdm.access,abdm.taskboard.access,abdm.gateway.use,abdm.bridge_log.view']); $routes->get('AbdmBridgeLog/list', 'AbdmBridgeLog::list', ['filter' => 'permission:abdm.access,abdm.taskboard.access,abdm.gateway.use,abdm.bridge_log.view']); $routes->get('AbdmBridgeLog/detail/(:num)', 'AbdmBridgeLog::detail/$1', ['filter' => 'permission:abdm.access,abdm.taskboard.access,abdm.gateway.use,abdm.bridge_log.view']); +$routes->get('AbdmSync/summary', 'AbdmSync::summary', ['filter' => $abdmPermFilter]); +$routes->post('AbdmSync/replay_dead/(:num)', 'AbdmSync::replayDead/$1', ['filter' => $abdmPermFilter]); +$routes->post('AbdmSync/enqueue_patient', 'AbdmSync::enqueuePatient', ['filter' => $abdmPermFilter]); +$routes->post('AbdmSync/enqueue_record', 'AbdmSync::enqueueRecord', ['filter' => $abdmPermFilter]); // OPD Queue (ABDM Scan & Share + Walk-in tokens) $routes->get('AbdmOpdQueue', 'AbdmOpdQueue::index', ['filter' => $abdmPermFilter]); $routes->get('AbdmOpdQueue/list', 'AbdmOpdQueue::list', ['filter' => $abdmPermFilter]); diff --git a/app/Controllers/AbdmSync.php b/app/Controllers/AbdmSync.php new file mode 100644 index 0000000..207d78f --- /dev/null +++ b/app/Controllers/AbdmSync.php @@ -0,0 +1,103 @@ +request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'AJAX only']); + } + + $payload = [ + 'local_patient_id' => (int) $this->request->getPost('local_patient_id'), + 'abha_id' => (string) ($this->request->getPost('abha_id') ?? ''), + 'abha_address' => (string) ($this->request->getPost('abha_address') ?? ''), + 'name' => (string) ($this->request->getPost('name') ?? ''), + 'gender' => (string) ($this->request->getPost('gender') ?? ''), + 'dob' => (string) ($this->request->getPost('dob') ?? ''), + 'mobile' => (string) ($this->request->getPost('mobile') ?? ''), + 'email' => (string) ($this->request->getPost('email') ?? ''), + 'source_updated_at' => (string) ($this->request->getPost('source_updated_at') ?? date('Y-m-d H:i:s')), + 'hfr_id' => (string) ($this->request->getPost('hfr_id') ?? ''), + ]; + + $service = new AbdmSyncOutboxService(); + $id = $service->enqueuePatientSync($payload); + + return $this->response->setJSON([ + 'ok' => $id !== null ? 1 : 0, + 'outbox_id' => $id, + ]); + } + + public function enqueueRecord() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'AJAX only']); + } + + $bundleRaw = (string) ($this->request->getPost('fhir_bundle_json') ?? '{}'); + $bundle = json_decode($bundleRaw, true); + if (! is_array($bundle)) { + $bundle = []; + } + + $payload = [ + 'local_record_id' => (string) ($this->request->getPost('local_record_id') ?? ''), + 'local_patient_id' => (int) $this->request->getPost('local_patient_id'), + 'hi_type' => (string) ($this->request->getPost('hi_type') ?? ''), + 'care_context_reference' => (string) ($this->request->getPost('care_context_reference') ?? ''), + 'care_context_display' => (string) ($this->request->getPost('care_context_display') ?? ''), + 'visit_date' => (string) ($this->request->getPost('visit_date') ?? ''), + 'department' => (string) ($this->request->getPost('department') ?? ''), + 'doctor_name' => (string) ($this->request->getPost('doctor_name') ?? ''), + 'consent_id' => (string) ($this->request->getPost('consent_id') ?? ''), + 'hfr_id' => (string) ($this->request->getPost('hfr_id') ?? ''), + 'source_updated_at' => (string) ($this->request->getPost('source_updated_at') ?? date('Y-m-d H:i:s')), + 'patient_name' => (string) ($this->request->getPost('patient_name') ?? ''), + 'abha_id' => (string) ($this->request->getPost('abha_id') ?? ''), + 'abha_address' => (string) ($this->request->getPost('abha_address') ?? ''), + 'fhir_bundle' => $bundle, + ]; + + $service = new AbdmSyncOutboxService(); + $id = $service->enqueueRecordSync($payload); + + return $this->response->setJSON([ + 'ok' => $id !== null ? 1 : 0, + 'outbox_id' => $id, + ]); + } + + public function summary() + { + $service = new AbdmSyncOutboxService(); + return $this->response->setJSON([ + 'ok' => 1, + 'data' => $service->getCounters(), + ]); + } + + public function replayDead(int $id = 0) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'AJAX only']); + } + + if ($id <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid outbox id']); + } + + $service = new AbdmSyncOutboxService(); + $ok = $service->replayDeadRow($id); + + return $this->response->setJSON([ + 'ok' => $ok ? 1 : 0, + 'message' => $ok ? 'Dead row moved to pending.' : 'Outbox row not found.', + ]); + } +} diff --git a/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php b/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php new file mode 100644 index 0000000..4630b23 --- /dev/null +++ b/app/Database/Migrations/2026-06-27-000613_CreateAbdmPushSyncTables.php @@ -0,0 +1,314 @@ +createSyncPatientTable(); + $this->createSyncRecordTable(); + $this->createSyncOutboxTable(); + } + + public function down(): void + { + $this->forge->dropTable('abdm_sync_outbox', true); + $this->forge->dropTable('abdm_sync_record', true); + $this->forge->dropTable('abdm_sync_patient', true); + } + + private function createSyncPatientTable(): void + { + if ($this->db->tableExists('abdm_sync_patient')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'local_patient_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'abha_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 180, + 'null' => false, + ], + 'gender' => [ + 'type' => 'VARCHAR', + 'constraint' => 16, + 'null' => true, + ], + 'dob' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'mobile' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'source_updated_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'sync_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'pending', + 'null' => false, + ], + 'last_synced_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'last_error' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'retry_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + 'null' => false, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('local_patient_id', 'uniq_abdm_sync_patient_local_patient_id'); + $this->forge->addKey('sync_status'); + $this->forge->addKey('source_updated_at'); + $this->forge->createTable('abdm_sync_patient', true); + } + + private function createSyncRecordTable(): void + { + if ($this->db->tableExists('abdm_sync_record')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'local_record_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'local_patient_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'hi_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'care_context_reference' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => false, + ], + 'care_context_display' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'visit_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'department' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'doctor_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 180, + 'null' => true, + ], + 'fhir_bundle_json' => [ + 'type' => 'LONGTEXT', + 'null' => false, + ], + 'consent_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'hfr_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => false, + ], + 'source_updated_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'sync_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'pending', + 'null' => false, + ], + 'gateway_record_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'gateway_queue_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'last_synced_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'last_error' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'retry_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + 'null' => false, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('local_record_id', 'uniq_abdm_sync_record_local_record_id'); + $this->forge->addUniqueKey('care_context_reference', 'uniq_abdm_sync_record_care_context_reference'); + $this->forge->addKey('sync_status'); + $this->forge->addKey(['local_patient_id', 'hi_type'], false, false, 'idx_abdm_sync_record_patient_type'); + $this->forge->createTable('abdm_sync_record', true); + } + + private function createSyncOutboxTable(): void + { + if ($this->db->tableExists('abdm_sync_outbox')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'entity_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => false, + ], + 'entity_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'idempotency_key' => [ + 'type' => 'VARCHAR', + 'constraint' => 190, + 'null' => false, + ], + 'payload_json' => [ + 'type' => 'LONGTEXT', + 'null' => false, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'pending', + 'null' => false, + ], + 'next_retry_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'retry_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + 'null' => false, + ], + 'last_error' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'locked_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'worker_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('idempotency_key', 'uniq_abdm_sync_outbox_idempotency_key'); + $this->forge->addKey(['status', 'next_retry_at'], false, false, 'idx_abdm_sync_outbox_status_retry'); + $this->forge->addKey(['entity_type', 'entity_id'], false, false, 'idx_abdm_sync_outbox_entity'); + $this->forge->createTable('abdm_sync_outbox', true); + } +} diff --git a/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php b/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php new file mode 100644 index 0000000..9146403 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php @@ -0,0 +1,216 @@ +> */ + private array $entries = []; + + /** @var array */ + private array $resourceIds = []; + + /** @var array */ + private array $bundleMeta = []; + + /** @var array|null */ + private ?array $composition = null; + + public function buildBundleMeta(string $identifierValue, string $timestamp): self + { + $this->bundleMeta = [ + 'resourceType' => 'Bundle', + 'identifier' => [ + 'system' => 'https://hms.local/fhir/document', + 'value' => $identifierValue, + ], + 'type' => 'document', + 'timestamp' => $timestamp, + ]; + + return $this; + } + + /** + * @param array $composition + */ + public function buildComposition(array $composition): self + { + $this->composition = $composition; + return $this; + } + + /** + * @param array $resource + */ + public function addPatient(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addEncounter(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addPractitioner(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addOrganization(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addCondition(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addObservation(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addDiagnosticReport(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addMedicationRequest(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addProcedure(array $resource): self + { + return $this->addResource($resource); + } + + /** @param array $resource */ + public function addClaim(array $resource): self + { + return $this->addResource($resource); + } + + /** + * @return array + */ + public function toBundle(): array + { + $bundle = $this->bundleMeta; + $entries = []; + + if (is_array($this->composition)) { + $entries[] = $this->wrapEntry($this->composition); + } + + foreach ($this->entries as $entry) { + $entries[] = $entry; + } + + $bundle['entry'] = $entries; + return $this->stripNulls($bundle); + } + + /** + * @param array $resource + */ + private function addResource(array $resource): self + { + $id = (string) ($resource['id'] ?? ''); + if ($id === '') { + $resource['id'] = $this->newId((string) ($resource['resourceType'] ?? 'resource')); + $id = (string) $resource['id']; + } + + $fullRef = (string) ($resource['resourceType'] ?? '') . '/' . $id; + if (isset($this->resourceIds[$fullRef])) { + return $this; + } + + $this->resourceIds[$fullRef] = true; + $this->entries[] = $this->wrapEntry($resource); + return $this; + } + + /** + * @param array $resource + * @return array + */ + private function wrapEntry(array $resource): array + { + $id = (string) ($resource['id'] ?? $this->newId((string) ($resource['resourceType'] ?? 'resource'))); + $resource['id'] = $id; + + return [ + 'fullUrl' => 'urn:uuid:' . $id, + 'resource' => $this->stripNulls($resource), + ]; + } + + /** + * @param array $data + * @return array + */ + private function stripNulls(array $data): array + { + $out = []; + foreach ($data as $key => $value) { + if ($value === null || $value === '') { + continue; + } + if (is_array($value)) { + $clean = $this->stripNested($value); + if ($clean === [] || $clean === null) { + continue; + } + $out[$key] = $clean; + continue; + } + $out[$key] = $value; + } + return $out; + } + + /** + * @param array $value + * @return array|null + */ + private function stripNested(array $value): ?array + { + $clean = []; + foreach ($value as $k => $v) { + if ($v === null || $v === '') { + continue; + } + if (is_array($v)) { + $nested = $this->stripNested($v); + if ($nested === null || $nested === []) { + continue; + } + $clean[$k] = $nested; + continue; + } + $clean[$k] = $v; + } + + return $clean === [] ? null : $clean; + } + + private function newId(string $prefix): string + { + $prefix = strtolower(preg_replace('/[^a-z0-9]/i', '-', $prefix) ?: 'resource'); + $bytes = bin2hex(random_bytes(6)); + return $prefix . '-' . $bytes; + } +} diff --git a/app/Libraries/Abdm/Fhir/FhirGeneratorFactory.php b/app/Libraries/Abdm/Fhir/FhirGeneratorFactory.php new file mode 100644 index 0000000..d55f2a7 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/FhirGeneratorFactory.php @@ -0,0 +1,37 @@ +builder = $builder ?? new FhirDocumentBuilder(); + $this->codingResolver = $codingResolver ?? new CodingResolver(); + $this->validator = $validator ?? new FhirBundleValidator(); + } + + /** + * @param array $source + * @return array + */ + abstract public function generate(array $source): array; + + /** + * @param array $source + * @return array + */ + protected function buildBasePatient(array $source): array + { + $patientId = (string) ($source['patient']['id'] ?? ''); + $patient = [ + 'resourceType' => 'Patient', + 'id' => 'patient-' . $patientId, + 'identifier' => [ + [ + 'system' => 'https://hms.local/patient-id', + 'value' => $patientId, + ], + ], + 'name' => [[ + 'text' => (string) ($source['patient']['name'] ?? ''), + ]], + 'gender' => (string) ($source['patient']['gender'] ?? ''), + 'birthDate' => (string) ($source['patient']['dob'] ?? ''), + ]; + + $abhaId = preg_replace('/\D/', '', (string) ($source['patient']['abha_id'] ?? '')); + if (is_string($abhaId) && $abhaId !== '') { + $patient['identifier'][] = [ + 'system' => 'https://healthid.ndhm.gov.in', + 'value' => $abhaId, + ]; + } + + $abhaAddress = trim((string) ($source['patient']['abha_address'] ?? '')); + if ($abhaAddress !== '') { + $patient['identifier'][] = [ + 'system' => 'https://abdm.gov.in/health-address', + 'value' => $abhaAddress, + ]; + } + + return $patient; + } + + /** + * @param array $source + */ + protected function buildBaseComposition(array $source, string $title, string $loincCode, string $loincDisplay): array + { + $recordId = (string) ($source['record_id'] ?? '0'); + $patientId = (string) ($source['patient']['id'] ?? '0'); + $encounterRef = isset($source['encounter']) ? 'urn:uuid:encounter-' . ((string) ($source['encounter']['id'] ?? $recordId)) : null; + + $composition = [ + 'resourceType' => 'Composition', + 'id' => 'composition-' . $recordId, + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => $loincCode, + 'display' => $loincDisplay, + ]], + ], + 'title' => $title, + 'date' => (string) ($source['completed_at'] ?? date(DATE_ATOM)), + 'subject' => ['reference' => 'urn:uuid:patient-' . $patientId], + ]; + + if ($encounterRef !== null) { + $composition['encounter'] = ['reference' => $encounterRef]; + } + + return $composition; + } + + /** + * @param array $source + */ + protected function buildEncounter(array $source): ?array + { + if (! isset($source['encounter']) || ! is_array($source['encounter'])) { + return null; + } + + $enc = $source['encounter']; + $id = (string) ($enc['id'] ?? ''); + if ($id === '') { + return null; + } + + return [ + 'resourceType' => 'Encounter', + 'id' => 'encounter-' . $id, + 'status' => 'finished', + 'subject' => [ + 'reference' => 'urn:uuid:patient-' . (string) ($source['patient']['id'] ?? ''), + ], + 'period' => [ + 'start' => (string) ($enc['start'] ?? $source['completed_at'] ?? date(DATE_ATOM)), + 'end' => (string) ($enc['end'] ?? $source['completed_at'] ?? date(DATE_ATOM)), + ], + 'identifier' => [[ + 'system' => 'https://hms.local/encounter-id', + 'value' => $id, + ]], + ]; + } + + /** @param array $source */ + protected function buildPractitioner(array $source): ?array + { + if (! isset($source['doctor']) || ! is_array($source['doctor'])) { + return null; + } + + $doctor = $source['doctor']; + $id = trim((string) ($doctor['id'] ?? '')); + $name = trim((string) ($doctor['name'] ?? '')); + if ($id === '' && $name === '') { + return null; + } + + return [ + 'resourceType' => 'Practitioner', + 'id' => 'practitioner-' . ($id !== '' ? $id : md5($name)), + 'name' => [[ + 'text' => $name, + ]], + 'identifier' => $id !== '' ? [[ + 'system' => 'https://hms.local/doctor-id', + 'value' => $id, + ]] : null, + ]; + } + + /** @param array $source */ + protected function buildOrganization(array $source): ?array + { + if (! isset($source['organization']) || ! is_array($source['organization'])) { + return null; + } + + $org = $source['organization']; + $id = trim((string) ($org['id'] ?? '')); + $name = trim((string) ($org['name'] ?? '')); + if ($id === '' && $name === '') { + return null; + } + + return [ + 'resourceType' => 'Organization', + 'id' => 'organization-' . ($id !== '' ? $id : md5($name)), + 'name' => $name, + 'identifier' => $id !== '' ? [[ + 'system' => 'https://hms.local/organization-id', + 'value' => $id, + ]] : null, + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php new file mode 100644 index 0000000..eb8e048 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php @@ -0,0 +1,107 @@ + $source + * @return array + */ + public function generate(array $source): array + { + $timestamp = (string) ($source['completed_at'] ?? date(DATE_ATOM)); + $recordId = (string) ($source['record_id'] ?? '0'); + $patientId = (string) ($source['patient']['id'] ?? '0'); + $sessionId = (string) ($source['session_id'] ?? '0'); + $visitDate = (string) ($source['visit_date'] ?? date('Y-m-d')); + + $careContextReference = 'DISCHARGE-' . $recordId . '-S' . $sessionId . '-' . $visitDate; + $careContextDisplay = 'Discharge Summary ' . $visitDate; + + $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); + $builder + ->buildBundleMeta('discharge-' . $recordId . '-' . strtotime($timestamp), $timestamp) + ->buildComposition($this->buildBaseComposition($source, 'Discharge Summary', '18842-5', 'Discharge summary')) + ->addPatient($this->buildBasePatient($source)); + + $encounter = $this->buildEncounter($source); + if (is_array($encounter)) { + $builder->addEncounter($encounter); + } + + $patientRef = 'urn:uuid:patient-' . $patientId; + $encounterRef = is_array($encounter) ? 'urn:uuid:' . (string) ($encounter['id'] ?? '') : null; + + foreach ((array) ($source['conditions'] ?? []) as $idx => $cond) { + $text = trim((string) ($cond['text'] ?? '')); + if ($text === '') { + continue; + } + + $resolved = $this->codingResolver->resolveSnomedForDiagnosisOrFinding((string) ($cond['code'] ?? ''), $text); + $builder->addCondition([ + 'resourceType' => 'Condition', + 'id' => 'discharge-condition-' . $recordId . '-' . $idx, + 'code' => [ + 'coding' => $resolved['coding'] ?? [], + 'text' => $text, + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + ]); + } + + foreach ((array) ($source['procedures'] ?? []) as $idx => $proc) { + $text = trim((string) ($proc['text'] ?? '')); + if ($text === '') { + continue; + } + + $resolved = $this->codingResolver->resolveSnomedForDiagnosisOrFinding((string) ($proc['code'] ?? ''), $text); + $builder->addProcedure([ + 'resourceType' => 'Procedure', + 'id' => 'discharge-procedure-' . $recordId . '-' . $idx, + 'status' => 'completed', + 'code' => [ + 'coding' => $resolved['coding'] ?? [], + 'text' => $text, + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'performedDateTime' => (string) ($proc['performed_at'] ?? $timestamp), + ]); + } + + foreach ((array) ($source['medications'] ?? []) as $idx => $med) { + $name = trim((string) ($med['name'] ?? '')); + if ($name === '') { + continue; + } + + $builder->addMedicationRequest([ + 'resourceType' => 'MedicationRequest', + 'id' => 'discharge-medication-' . $recordId . '-' . $idx, + 'status' => 'active', + 'intent' => 'order', + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'medicationCodeableConcept' => ['text' => $name], + 'dosageInstruction' => [[ + 'text' => trim((string) ($med['dosage'] ?? '')), + ]], + ]); + } + + $bundle = $builder->toBundle(); + $validation = $this->validator->validate($bundle, 'discharge', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]); + + return [ + 'hi_type' => 'DischargeSummaryRecord', + 'care_context_reference' => $careContextReference, + 'care_context_display' => $careContextDisplay, + 'fhir_bundle' => $bundle, + 'validation' => $validation->toArray(), + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Generators/InvoiceFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/InvoiceFhirGenerator.php new file mode 100644 index 0000000..e2f634b --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Generators/InvoiceFhirGenerator.php @@ -0,0 +1,110 @@ + $source + * @return array + */ + public function generate(array $source): array + { + $timestamp = (string) ($source['completed_at'] ?? date(DATE_ATOM)); + $recordId = (string) ($source['record_id'] ?? '0'); + $patientId = (string) ($source['patient']['id'] ?? '0'); + $sessionId = (string) ($source['session_id'] ?? '0'); + $visitDate = (string) ($source['visit_date'] ?? date('Y-m-d')); + + $careContextReference = 'INVOICE-' . $recordId . '-S' . $sessionId . '-' . $visitDate; + $careContextDisplay = 'Invoice Summary ' . $visitDate; + + $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); + $builder + ->buildBundleMeta('invoice-' . $recordId . '-' . strtotime($timestamp), $timestamp) + ->buildComposition($this->buildBaseComposition($source, 'Invoice Summary', '56445-0', 'Medication summary document')) + ->addPatient($this->buildBasePatient($source)); + + $encounter = $this->buildEncounter($source); + if (is_array($encounter)) { + $builder->addEncounter($encounter); + } + + $claimItems = []; + foreach ((array) ($source['line_items'] ?? []) as $idx => $item) { + $claimItems[] = [ + 'sequence' => $idx + 1, + 'productOrService' => [ + 'text' => trim((string) ($item['name'] ?? '')), + ], + 'quantity' => [ + 'value' => (float) ($item['qty'] ?? 1), + ], + 'unitPrice' => [ + 'value' => (float) ($item['rate'] ?? 0), + 'currency' => (string) ($source['currency'] ?? 'INR'), + ], + 'net' => [ + 'value' => (float) ($item['amount'] ?? 0), + 'currency' => (string) ($source['currency'] ?? 'INR'), + ], + ]; + } + + $builder->addClaim([ + 'resourceType' => 'Claim', + 'id' => 'claim-' . $recordId, + 'status' => 'active', + 'type' => [ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/claim-type', + 'code' => 'professional', + ]], + ], + 'use' => 'claim', + 'patient' => [ + 'reference' => 'urn:uuid:patient-' . $patientId, + ], + 'created' => $timestamp, + 'provider' => [ + 'display' => trim((string) ($source['organization']['name'] ?? '')), + ], + 'priority' => [ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/processpriority', + 'code' => 'normal', + ]], + ], + 'item' => $claimItems, + 'total' => [ + 'value' => (float) ($source['total_amount'] ?? 0), + 'currency' => (string) ($source['currency'] ?? 'INR'), + ], + 'identifier' => [[ + 'system' => 'https://hms.local/invoice-id', + 'value' => (string) ($source['invoice_no'] ?? $recordId), + ]], + ]); + + $organization = $this->buildOrganization($source); + if (is_array($organization)) { + $builder->addOrganization($organization); + } + + $practitioner = $this->buildPractitioner($source); + if (is_array($practitioner)) { + $builder->addPractitioner($practitioner); + } + + $bundle = $builder->toBundle(); + $validation = $this->validator->validate($bundle, 'invoice', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]); + + return [ + 'hi_type' => 'InvoiceRecord', + 'care_context_reference' => $careContextReference, + 'care_context_display' => $careContextDisplay, + 'fhir_bundle' => $bundle, + 'validation' => $validation->toArray(), + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Generators/LabFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/LabFhirGenerator.php new file mode 100644 index 0000000..9413758 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Generators/LabFhirGenerator.php @@ -0,0 +1,151 @@ + $source + * @return array + */ + public function generate(array $source): array + { + $timestamp = (string) ($source['completed_at'] ?? date(DATE_ATOM)); + $recordId = (string) ($source['record_id'] ?? '0'); + $patientId = (string) ($source['patient']['id'] ?? '0'); + $sessionId = (string) ($source['session_id'] ?? '0'); + $visitDate = (string) ($source['visit_date'] ?? date('Y-m-d')); + + $careContextReference = 'LAB-' . $recordId . '-S' . $sessionId . '-' . $visitDate; + $careContextDisplay = 'Lab Report ' . $visitDate; + + $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); + $builder + ->buildBundleMeta('lab-' . $recordId . '-' . strtotime($timestamp), $timestamp) + ->buildComposition($this->buildBaseComposition($source, 'Diagnostic Report Summary', '11502-2', 'Laboratory report')) + ->addPatient($this->buildBasePatient($source)); + + $encounter = $this->buildEncounter($source); + if (is_array($encounter)) { + $builder->addEncounter($encounter); + } + + $patientRef = 'urn:uuid:patient-' . $patientId; + $encounterRef = is_array($encounter) ? 'urn:uuid:' . (string) ($encounter['id'] ?? '') : null; + + $panelName = trim((string) ($source['panel_name'] ?? 'Lab Panel')); + $panelCode = trim((string) ($source['panel_code'] ?? '')); + $panelLoinc = $this->codingResolver->resolveLoincForLabPanel($panelCode, $panelName, (string) ($source['department'] ?? '')); + + $resultRefs = []; + $resolvedCount = 0; + $unresolvedCount = 0; + $fallbackCount = 0; + + foreach ((array) ($source['observations'] ?? []) as $idx => $obs) { + $name = trim((string) ($obs['name'] ?? '')); + if ($name === '') { + continue; + } + + $resolved = $this->codingResolver->resolveLoincForLabTest((string) ($obs['code'] ?? ''), $name, (string) ($source['department'] ?? ''), (string) ($source['specimen'] ?? '')); + if ((bool) ($resolved['unresolved'] ?? false)) { + $unresolvedCount++; + $fallbackCount++; + } else { + $resolvedCount++; + } + + $ucum = $this->codingResolver->resolveUnitUcUM((string) ($obs['unit'] ?? '')); + if ((bool) ($ucum['unresolved'] ?? false)) { + $unresolvedCount++; + } else { + $resolvedCount++; + } + + $interp = $this->codingResolver->resolveInterpretationCode((string) ($obs['interpretation'] ?? '')); + if ((bool) ($interp['unresolved'] ?? false)) { + $unresolvedCount++; + } else { + $resolvedCount++; + } + + $obsId = 'lab-observation-' . $recordId . '-' . $idx; + $resultRefs[] = ['reference' => 'urn:uuid:' . $obsId]; + + $builder->addObservation([ + 'resourceType' => 'Observation', + 'id' => $obsId, + 'status' => 'final', + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => 'laboratory', + ]], + ]], + 'code' => [ + 'coding' => $resolved['coding'] ?? [], + 'text' => $name, + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'effectiveDateTime' => (string) ($obs['effective_at'] ?? $timestamp), + 'issued' => (string) ($obs['issued_at'] ?? $timestamp), + 'valueQuantity' => [ + 'value' => (float) ($obs['value'] ?? 0), + 'unit' => (string) ($obs['unit'] ?? ''), + 'system' => 'http://unitsofmeasure.org', + 'code' => (string) ($ucum['code'] ?? ''), + ], + 'interpretation' => [[ + 'coding' => [[ + 'system' => (string) ($interp['system'] ?? ''), + 'code' => (string) ($interp['code'] ?? ''), + 'display' => (string) ($interp['display'] ?? ''), + ]], + ]], + 'referenceRange' => isset($obs['reference_range']) ? [[ + 'text' => (string) $obs['reference_range'], + ]] : null, + ]); + } + + $diagReportId = 'diagnostic-report-' . $recordId; + $builder->addDiagnosticReport([ + 'resourceType' => 'DiagnosticReport', + 'id' => $diagReportId, + 'status' => 'final', + 'code' => [ + 'coding' => $panelLoinc['coding'] ?? [], + 'text' => $panelName, + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'effectiveDateTime' => $timestamp, + 'issued' => $timestamp, + 'result' => $resultRefs, + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0074', + 'code' => 'LAB', + 'display' => 'Laboratory', + ]], + ]], + ]); + + $bundle = $builder->toBundle(); + $validation = $this->validator->validate($bundle, 'lab', [ + 'resolved' => $resolvedCount, + 'unresolved' => $unresolvedCount, + 'fallback_used' => $fallbackCount, + ]); + + return [ + 'hi_type' => 'DiagnosticReportRecord', + 'care_context_reference' => $careContextReference, + 'care_context_display' => $careContextDisplay, + 'fhir_bundle' => $bundle, + 'validation' => $validation->toArray(), + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Generators/OpdFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/OpdFhirGenerator.php new file mode 100644 index 0000000..47b32a4 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Generators/OpdFhirGenerator.php @@ -0,0 +1,137 @@ + $source + * @return array + */ + public function generate(array $source): array + { + $timestamp = (string) ($source['completed_at'] ?? date(DATE_ATOM)); + $recordId = (string) ($source['record_id'] ?? '0'); + $patientId = (string) ($source['patient']['id'] ?? '0'); + $sessionId = (string) ($source['session_id'] ?? '0'); + $visitDate = (string) ($source['visit_date'] ?? date('Y-m-d')); + + $careContextReference = 'OPD-' . $recordId . '-S' . $sessionId . '-' . $visitDate; + $careContextDisplay = 'OPD Visit ' . $visitDate; + + $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); + $builder + ->buildBundleMeta('opd-' . $recordId . '-' . strtotime($timestamp), $timestamp) + ->buildComposition($this->buildBaseComposition($source, 'OPD Consultation Summary', '11506-3', 'Progress note')) + ->addPatient($this->buildBasePatient($source)); + + $encounter = $this->buildEncounter($source); + if (is_array($encounter)) { + $builder->addEncounter($encounter); + } + + $practitioner = $this->buildPractitioner($source); + if (is_array($practitioner)) { + $builder->addPractitioner($practitioner); + } + + $organization = $this->buildOrganization($source); + if (is_array($organization)) { + $builder->addOrganization($organization); + } + + $patientRef = 'urn:uuid:patient-' . $patientId; + $encounterRef = is_array($encounter) ? 'urn:uuid:' . (string) ($encounter['id'] ?? '') : null; + + foreach ((array) ($source['diagnoses'] ?? []) as $idx => $diag) { + $text = trim((string) ($diag['text'] ?? '')); + if ($text === '') { + continue; + } + + $resolution = $this->codingResolver->resolveSnomedForDiagnosisOrFinding((string) ($diag['code'] ?? ''), $text); + $condition = [ + 'resourceType' => 'Condition', + 'id' => 'condition-' . $recordId . '-' . $idx, + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'code' => [ + 'coding' => $resolution['coding'] ?? [], + 'text' => $text, + ], + 'clinicalStatus' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', + 'code' => 'active', + ]]], + ]; + $builder->addCondition($condition); + } + + foreach ((array) ($source['medications'] ?? []) as $idx => $med) { + $drugName = trim((string) ($med['name'] ?? '')); + if ($drugName === '') { + continue; + } + + $builder->addMedicationRequest([ + 'resourceType' => 'MedicationRequest', + 'id' => 'medication-' . $recordId . '-' . $idx, + 'status' => 'active', + 'intent' => 'order', + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'medicationCodeableConcept' => [ + 'text' => $drugName, + ], + 'dosageInstruction' => [[ + 'text' => trim((string) ($med['dosage'] ?? '')), + ]], + ]); + } + + foreach ((array) ($source['vitals'] ?? []) as $idx => $vital) { + $display = trim((string) ($vital['display'] ?? '')); + if ($display === '') { + continue; + } + + $loinc = $this->codingResolver->resolveLoincForLabTest((string) ($vital['code'] ?? ''), $display); + $ucum = $this->codingResolver->resolveUnitUcUM((string) ($vital['unit'] ?? '')); + $builder->addObservation([ + 'resourceType' => 'Observation', + 'id' => 'observation-' . $recordId . '-' . $idx, + 'status' => 'final', + 'category' => [[ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => 'vital-signs', + ]], + ]], + 'code' => [ + 'coding' => $loinc['coding'] ?? [], + 'text' => $display, + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'effectiveDateTime' => $timestamp, + 'valueQuantity' => [ + 'value' => (float) ($vital['value'] ?? 0), + 'unit' => (string) ($vital['unit'] ?? ''), + 'system' => 'http://unitsofmeasure.org', + 'code' => (string) ($ucum['code'] ?? ''), + ], + ]); + } + + $bundle = $builder->toBundle(); + $validation = $this->validator->validate($bundle, 'opd', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]); + + return [ + 'hi_type' => 'OPConsultRecord', + 'care_context_reference' => $careContextReference, + 'care_context_display' => $careContextDisplay, + 'fhir_bundle' => $bundle, + 'validation' => $validation->toArray(), + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Generators/RadiologyFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/RadiologyFhirGenerator.php new file mode 100644 index 0000000..727dc41 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Generators/RadiologyFhirGenerator.php @@ -0,0 +1,161 @@ + $source + * @return array + */ + public function generate(array $source): array + { + $timestamp = (string) ($source['completed_at'] ?? date(DATE_ATOM)); + $recordId = (string) ($source['record_id'] ?? '0'); + $patientId = (string) ($source['patient']['id'] ?? '0'); + $sessionId = (string) ($source['session_id'] ?? '0'); + $visitDate = (string) ($source['visit_date'] ?? date('Y-m-d')); + + $careContextReference = 'RAD-' . $recordId . '-S' . $sessionId . '-' . $visitDate; + $careContextDisplay = 'Radiology Report ' . $visitDate; + + $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); + $builder + ->buildBundleMeta('radiology-' . $recordId . '-' . strtotime($timestamp), $timestamp) + ->buildComposition($this->buildBaseComposition($source, 'Radiology Report', '18748-4', 'Diagnostic imaging study')) + ->addPatient($this->buildBasePatient($source)); + + $encounter = $this->buildEncounter($source); + if (is_array($encounter)) { + $builder->addEncounter($encounter); + } + + $patientRef = 'urn:uuid:patient-' . $patientId; + $encounterRef = is_array($encounter) ? 'urn:uuid:' . (string) ($encounter['id'] ?? '') : null; + + $findingResolution = $this->codingResolver->resolveSnomedForDiagnosisOrFinding( + (string) ($source['finding_code'] ?? ''), + (string) ($source['finding_text'] ?? '') + ); + + $bodySiteResolution = $this->codingResolver->resolveSnomedForBodySite( + (string) ($source['body_site_code'] ?? ''), + (string) ($source['body_site'] ?? '') + ); + + $observationIds = []; + foreach ((array) ($source['measurements'] ?? []) as $idx => $measurement) { + $name = trim((string) ($measurement['name'] ?? '')); + if ($name === '') { + continue; + } + + $loinc = $this->codingResolver->resolveLoincForLabTest((string) ($measurement['code'] ?? ''), $name, 'radiology'); + $ucum = $this->codingResolver->resolveUnitUcUM((string) ($measurement['unit'] ?? '')); + $obsId = 'radiology-observation-' . $recordId . '-' . $idx; + $observationIds[] = ['reference' => 'urn:uuid:' . $obsId]; + + $builder->addObservation([ + 'resourceType' => 'Observation', + 'id' => $obsId, + 'status' => 'final', + 'code' => [ + 'coding' => $loinc['coding'] ?? [], + 'text' => $name, + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'effectiveDateTime' => $timestamp, + 'valueQuantity' => [ + 'value' => (float) ($measurement['value'] ?? 0), + 'unit' => (string) ($measurement['unit'] ?? ''), + 'system' => 'http://unitsofmeasure.org', + 'code' => (string) ($ucum['code'] ?? ''), + ], + 'bodySite' => [ + 'coding' => $bodySiteResolution['coding'] ?? [], + 'text' => (string) ($source['body_site'] ?? ''), + ], + ]); + } + + $builder->addDiagnosticReport([ + 'resourceType' => 'DiagnosticReport', + 'id' => 'radiology-report-' . $recordId, + 'status' => 'final', + 'code' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => '18748-4', + 'display' => 'Diagnostic imaging study', + ]], + 'text' => (string) ($source['modality'] ?? 'Radiology'), + ], + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'effectiveDateTime' => $timestamp, + 'issued' => $timestamp, + 'conclusion' => (string) ($source['conclusion'] ?? ''), + 'result' => $observationIds, + ]); + + if (trim((string) ($source['finding_text'] ?? '')) !== '') { + $builder->addCondition([ + 'resourceType' => 'Condition', + 'id' => 'radiology-finding-' . $recordId, + 'subject' => ['reference' => $patientRef], + 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, + 'code' => [ + 'coding' => $findingResolution['coding'] ?? [], + 'text' => (string) ($source['finding_text'] ?? ''), + ], + ]); + } + + if (trim((string) ($source['study_uid'] ?? '')) !== '' || trim((string) ($source['accession_no'] ?? '')) !== '') { + $builder->addProcedure([ + 'resourceType' => 'ImagingStudy', + 'id' => 'imaging-study-' . $recordId, + 'subject' => ['reference' => $patientRef], + 'identifier' => array_values(array_filter([ + trim((string) ($source['study_uid'] ?? '')) !== '' ? [ + 'system' => 'urn:dicom:uid', + 'value' => (string) ($source['study_uid'] ?? ''), + ] : null, + trim((string) ($source['accession_no'] ?? '')) !== '' ? [ + 'system' => 'https://hms.local/radiology-accession', + 'value' => (string) ($source['accession_no'] ?? ''), + ] : null, + ])), + 'started' => $timestamp, + ]); + } + + $resolved = 0; + $unresolved = 0; + $fallbackUsed = 0; + foreach ([$findingResolution, $bodySiteResolution] as $resolution) { + if ((bool) ($resolution['unresolved'] ?? false)) { + $unresolved++; + $fallbackUsed++; + } else { + $resolved++; + } + } + + $bundle = $builder->toBundle(); + $validation = $this->validator->validate($bundle, 'radiology', [ + 'resolved' => $resolved, + 'unresolved' => $unresolved, + 'fallback_used' => $fallbackUsed, + ]); + + return [ + 'hi_type' => 'DiagnosticReportRecord', + 'care_context_reference' => $careContextReference, + 'care_context_display' => $careContextDisplay, + 'fhir_bundle' => $bundle, + 'validation' => $validation->toArray(), + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Support/CodingResolver.php b/app/Libraries/Abdm/Fhir/Support/CodingResolver.php new file mode 100644 index 0000000..bda6263 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Support/CodingResolver.php @@ -0,0 +1,190 @@ + */ + private array $labLoincMap = [ + 'CBC' => ['code' => '58410-2', 'display' => 'CBC panel - Blood by Automated count'], + 'HB' => ['code' => '718-7', 'display' => 'Hemoglobin [Mass/volume] in Blood'], + 'GLUCOSE' => ['code' => '2345-7', 'display' => 'Glucose [Mass/volume] in Serum or Plasma'], + 'CREATININE' => ['code' => '2160-0', 'display' => 'Creatinine [Mass/volume] in Serum or Plasma'], + ]; + + /** @var array */ + private array $radiologySnomedFindingMap = [ + 'PNEUMONIA' => ['code' => '233604007', 'display' => 'Pneumonia'], + 'PLEURAL EFFUSION' => ['code' => '60046008', 'display' => 'Pleural effusion'], + 'FRACTURE' => ['code' => '125605004', 'display' => 'Fracture of bone'], + ]; + + /** @var array */ + private array $bodySiteMap = [ + 'CHEST' => ['code' => '51185008', 'display' => 'Thoracic structure'], + 'ABDOMEN' => ['code' => '818983003', 'display' => 'Abdominal structure'], + 'BRAIN' => ['code' => '12738006', 'display' => 'Brain structure'], + ]; + + /** @return array */ + public function resolveLoincForLabTest(string $localCode, string $displayName, string $department = '', string $specimenType = ''): array + { + return $this->resolveByHeuristic($localCode, $displayName, $this->labLoincMap, 'http://loinc.org'); + } + + /** @return array */ + public function resolveLoincForLabPanel(string $localCode, string $displayName, string $department = ''): array + { + return $this->resolveByHeuristic($localCode, $displayName, $this->labLoincMap, 'http://loinc.org'); + } + + /** @return array */ + public function resolveSnomedForDiagnosisOrFinding(string $localCode, string $displayName, string $mappedIcd = ''): array + { + return $this->resolveByHeuristic($localCode, $displayName, $this->radiologySnomedFindingMap, 'http://snomed.info/sct'); + } + + /** @return array */ + public function resolveSnomedForBodySite(string $localCode, string $displayName): array + { + return $this->resolveByHeuristic($localCode, $displayName, $this->bodySiteMap, 'http://snomed.info/sct'); + } + + /** @return array */ + public function resolveUnitUcUM(string $unit): array + { + $normalized = strtoupper(trim($unit)); + $map = [ + 'MG/DL' => 'mg/dL', + 'G/DL' => 'g/dL', + 'MMOL/L' => 'mmol/L', + 'CELLS/CUMM' => '/uL', + 'PERCENT' => '%', + ]; + + if (isset($map[$normalized])) { + return [ + 'code' => $map[$normalized], + 'system' => 'http://unitsofmeasure.org', + 'display' => $unit, + 'source' => 'direct_map', + 'confidence' => 1.0, + 'warnings' => [], + 'unresolved' => false, + ]; + } + + return [ + 'code' => trim($unit), + 'system' => 'http://unitsofmeasure.org', + 'display' => $unit, + 'source' => 'heuristic_fallback', + 'confidence' => 0.4, + 'warnings' => ['UCUM code unresolved; kept local unit'], + 'unresolved' => true, + ]; + } + + /** @return array */ + public function resolveInterpretationCode(string $value): array + { + $normalized = strtoupper(trim($value)); + $map = [ + 'HIGH' => ['code' => 'H', 'display' => 'High'], + 'LOW' => ['code' => 'L', 'display' => 'Low'], + 'NORMAL' => ['code' => 'N', 'display' => 'Normal'], + 'ABNORMAL' => ['code' => 'A', 'display' => 'Abnormal'], + ]; + + if (isset($map[$normalized])) { + return [ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation', + 'code' => $map[$normalized]['code'], + 'display' => $map[$normalized]['display'], + 'source' => 'direct_map', + 'confidence' => 1.0, + 'warnings' => [], + 'unresolved' => false, + ]; + } + + return [ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation', + 'code' => 'U', + 'display' => 'Significant change up', + 'source' => 'heuristic_fallback', + 'confidence' => 0.2, + 'warnings' => ['Interpretation unresolved; used fallback U'], + 'unresolved' => true, + ]; + } + + /** @return array */ + public function resolveFallbackCode(string $localCode, string $displayName, string $system): array + { + return [ + 'coding' => [[ + 'system' => $system, + 'code' => trim($localCode) !== '' ? trim($localCode) : 'LOCAL-UNMAPPED', + 'display' => trim($displayName), + ]], + 'source' => 'heuristic_fallback', + 'confidence' => 0.1, + 'warnings' => ['Local code used as fallback'], + 'unresolved' => true, + ]; + } + + /** + * @param array $dictionary + * @return array + */ + private function resolveByHeuristic(string $localCode, string $displayName, array $dictionary, string $system): array + { + $normalizedCode = strtoupper(trim($localCode)); + $normalizedDisplay = strtoupper(trim($displayName)); + + if ($normalizedCode !== '' && isset($dictionary[$normalizedCode])) { + $hit = $dictionary[$normalizedCode]; + return [ + 'coding' => [[ + 'system' => $system, + 'code' => $hit['code'], + 'display' => $hit['display'], + ]], + 'coding_source' => 'direct_map', + 'confidence' => 1.0, + 'warnings' => [], + 'unresolved' => false, + ]; + } + + foreach ($dictionary as $key => $hit) { + if ($normalizedDisplay !== '' && str_contains($normalizedDisplay, $key)) { + return [ + 'coding' => [[ + 'system' => $system, + 'code' => $hit['code'], + 'display' => $hit['display'], + ]], + 'coding_source' => 'terminology_match', + 'confidence' => 0.8, + 'warnings' => ['Mapped by display-name terminology match'], + 'unresolved' => false, + ]; + } + } + + return [ + 'coding' => [[ + 'system' => $system, + 'code' => trim($localCode) !== '' ? trim($localCode) : 'UNMAPPED', + 'display' => trim($displayName) !== '' ? trim($displayName) : 'Unmapped', + ]], + 'coding_source' => 'heuristic_fallback', + 'confidence' => 0.2, + 'warnings' => ['No standard map found; fallback code used'], + 'unresolved' => true, + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Support/FhirBundleValidator.php b/app/Libraries/Abdm/Fhir/Support/FhirBundleValidator.php new file mode 100644 index 0000000..bf6c6fe --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Support/FhirBundleValidator.php @@ -0,0 +1,143 @@ + $bundle + * @param string $sourceModule + */ + public function validate(array $bundle, string $sourceModule = 'unknown', array $terminologySummary = []): FhirValidationResult + { + $errors = []; + $warnings = []; + + if (($bundle['resourceType'] ?? '') !== 'Bundle') { + $errors[] = ['code' => 'bundle.resourceType', 'field' => 'resourceType', 'message' => 'Bundle.resourceType must be Bundle']; + } + + if (($bundle['type'] ?? '') !== 'document') { + $errors[] = ['code' => 'bundle.type', 'field' => 'type', 'message' => 'Bundle.type must be document']; + } + + $entries = $bundle['entry'] ?? []; + if (! is_array($entries) || empty($entries)) { + $errors[] = ['code' => 'bundle.entry', 'field' => 'entry', 'message' => 'Bundle.entry must contain resources']; + return new FhirValidationResult(false, 0, $errors, $warnings, $terminologySummary, $this->buildAudit($sourceModule, $terminologySummary, $warnings)); + } + + $firstResourceType = (string) (($entries[0]['resource']['resourceType'] ?? '')); + if ($firstResourceType !== 'Composition') { + $errors[] = ['code' => 'bundle.composition.first', 'field' => 'entry[0]', 'message' => 'First entry must be Composition']; + } + + $hasPatient = false; + $resourceRefs = []; + $diagnosticReportResultRefs = []; + foreach ($entries as $idx => $entry) { + $resource = $entry['resource'] ?? null; + if (! is_array($resource)) { + $errors[] = ['code' => 'bundle.entry.resource', 'field' => 'entry.' . $idx, 'message' => 'entry.resource must be object']; + continue; + } + + $rtype = (string) ($resource['resourceType'] ?? ''); + $rid = (string) ($resource['id'] ?? ''); + if ($rtype === 'Patient') { + $hasPatient = true; + } + if ($rid !== '') { + $resourceRefs[$rtype . '/' . $rid] = true; + $resourceRefs['urn:uuid:' . $rid] = true; + } + + if ($rtype === 'DiagnosticReport') { + $results = $resource['result'] ?? []; + if (is_array($results)) { + foreach ($results as $resRef) { + if (is_array($resRef) && isset($resRef['reference'])) { + $diagnosticReportResultRefs[] = (string) $resRef['reference']; + } + } + } + } + + if ($rtype === 'Observation') { + if (isset($resource['valueQuantity']) && is_array($resource['valueQuantity'])) { + $vq = $resource['valueQuantity']; + if (! isset($vq['value']) || ! isset($vq['unit'])) { + $warnings[] = ['code' => 'observation.valueQuantity', 'field' => 'Observation.valueQuantity', 'message' => 'Observation valueQuantity should include value and unit']; + } + } + } + } + + if (! $hasPatient) { + $errors[] = ['code' => 'bundle.patient.required', 'field' => 'entry', 'message' => 'Patient resource is required']; + } + + foreach ($diagnosticReportResultRefs as $ref) { + if (! isset($resourceRefs[$ref])) { + $errors[] = ['code' => 'diagnosticReport.result.link', 'field' => 'DiagnosticReport.result.reference', 'message' => 'DiagnosticReport result reference not found: ' . $ref]; + } + } + + $score = $this->calculateScore($errors, $warnings, $terminologySummary); + $valid = empty($errors); + + return new FhirValidationResult( + $valid, + $score, + $errors, + $warnings, + $terminologySummary, + $this->buildAudit($sourceModule, $terminologySummary, $warnings) + ); + } + + /** + * @param array> $errors + * @param array> $warnings + * @param array $terminologySummary + */ + private function calculateScore(array $errors, array $warnings, array $terminologySummary): int + { + $score = 100; + + $score -= min(60, count($errors) * 20); + $score -= min(25, count($warnings) * 5); + + $resolved = (int) ($terminologySummary['resolved'] ?? 0); + $unresolved = (int) ($terminologySummary['unresolved'] ?? 0); + if ($resolved + $unresolved > 0) { + $ratio = $resolved / max(1, ($resolved + $unresolved)); + $score = (int) round($score * (0.7 + ($ratio * 0.3))); + } + + return max(0, min(100, $score)); + } + + /** + * @param array $terminologySummary + * @param array> $warnings + * @return array + */ + private function buildAudit(string $sourceModule, array $terminologySummary, array $warnings): array + { + $resolved = (int) ($terminologySummary['resolved'] ?? 0); + $unresolved = (int) ($terminologySummary['unresolved'] ?? 0); + $fallbackUsed = (int) ($terminologySummary['fallback_used'] ?? 0); + + return [ + 'mapping_version' => 'm2-sync-v1', + 'source_module' => $sourceModule, + 'coding_resolved_count' => $resolved, + 'coding_unresolved_count' => $unresolved, + 'fallback_used_count' => $fallbackUsed, + 'top_warnings' => array_map(static fn ($w) => (string) ($w['message'] ?? ''), array_slice($warnings, 0, 5)), + 'generated_at' => date(DATE_ATOM), + 'source_record_id' => '', + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Support/FhirValidationResult.php b/app/Libraries/Abdm/Fhir/Support/FhirValidationResult.php new file mode 100644 index 0000000..6761365 --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Support/FhirValidationResult.php @@ -0,0 +1,52 @@ +> */ + public array $errors; + + /** @var array> */ + public array $warnings; + + /** @var array */ + public array $terminologySummary; + + /** @var array */ + public array $audit; + + /** + * @param array> $errors + * @param array> $warnings + * @param array $terminologySummary + * @param array $audit + */ + public function __construct(bool $valid, int $score, array $errors = [], array $warnings = [], array $terminologySummary = [], array $audit = []) + { + $this->valid = $valid; + $this->score = $score; + $this->errors = $errors; + $this->warnings = $warnings; + $this->terminologySummary = $terminologySummary; + $this->audit = $audit; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'valid' => $this->valid, + 'score' => $this->score, + 'errors' => $this->errors, + 'warnings' => $this->warnings, + 'terminology_summary' => $this->terminologySummary, + 'audit' => $this->audit, + ]; + } +} diff --git a/app/Libraries/Abdm/Fhir/Support/GatewayPayloadAdapter.php b/app/Libraries/Abdm/Fhir/Support/GatewayPayloadAdapter.php new file mode 100644 index 0000000..d3748cd --- /dev/null +++ b/app/Libraries/Abdm/Fhir/Support/GatewayPayloadAdapter.php @@ -0,0 +1,39 @@ + $generatorOutput + * @param array $source + * @param string $hfrId + * @return array + */ + public function toGatewayPayload(array $generatorOutput, array $source, string $hfrId): array + { + $patient = (array) ($source['patient'] ?? []); + + return [ + 'hfr_id' => $hfrId, + 'hi_type' => (string) ($generatorOutput['hi_type'] ?? ''), + 'record_type' => (string) ($generatorOutput['hi_type'] ?? ''), + 'abha_id' => $this->normalizeAbhaDigits((string) ($patient['abha_id'] ?? '')), + 'abha_address' => trim((string) ($patient['abha_address'] ?? '')), + 'patient_name' => trim((string) ($patient['name'] ?? '')), + 'local_patient_id' => (string) ($patient['id'] ?? ''), + 'care_context_reference' => (string) ($generatorOutput['care_context_reference'] ?? ''), + 'care_context_display' => (string) ($generatorOutput['care_context_display'] ?? ''), + 'visit_date' => (string) ($source['visit_date'] ?? date('Y-m-d')), + 'department' => (string) ($source['department'] ?? ''), + 'doctor_name' => (string) (($source['doctor']['name'] ?? '') ?: ($source['doctor_name'] ?? '')), + 'fhir_bundle' => (array) ($generatorOutput['fhir_bundle'] ?? []), + ]; + } + + private function normalizeAbhaDigits(string $abha): string + { + $digits = preg_replace('/\D/', '', $abha); + return is_string($digits) ? $digits : ''; + } +} diff --git a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php new file mode 100644 index 0000000..83aa0cf --- /dev/null +++ b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php @@ -0,0 +1,346 @@ +db = $db ?? db_connect(); + } + + /** + * @param array $payload + * @return array + */ + public function pushRecord(array $payload, string $idempotencyKey = ''): array + { + $requestId = $this->createRequestId(); + $validation = $this->validatePayload($payload); + if (($validation['valid'] ?? false) !== true) { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => 400, + 'request_id' => $requestId, + 'error' => 'validation_failed', + 'message' => implode('; ', $validation['errors'] ?? ['Invalid payload']), + 'retryable' => false, + ]; + } + + $baseUrl = rtrim($this->readSetting('ABDM_BRIDGE_URL', 'EATRIA_BRIDGE_URL'), '/'); + if ($baseUrl === '') { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => 500, + 'request_id' => $requestId, + 'error' => 'bridge_url_missing', + 'message' => 'ABDM bridge URL is not configured.', + 'retryable' => false, + ]; + } + + $token = trim($this->readSetting('EATRIA_BRIDGE_TOKEN', 'ABDM_BRIDGE_TOKEN')); + if ($token === '') { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => 500, + 'request_id' => $requestId, + 'error' => 'token_missing', + 'message' => 'ABDM bridge token is not configured.', + 'retryable' => false, + ]; + } + + $url = $baseUrl . '/api/v3/records/push'; + $headers = [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + 'X-Request-Id' => $requestId, + ]; + if ($idempotencyKey !== '') { + $headers['X-Idempotency-Key'] = $idempotencyKey; + } + + $startedAt = microtime(true); + try { + $client = service('curlrequest', [ + 'timeout' => 30, + 'connect_timeout' => 10, + 'http_errors' => false, + ]); + + $response = $client->post($url, [ + 'headers' => $headers, + 'json' => $payload, + ]); + + $httpStatus = (int) $response->getStatusCode(); + $body = json_decode((string) $response->getBody(), true); + if (! is_array($body)) { + $body = ['raw' => (string) $response->getBody()]; + } + + $mapped = $this->mapResponse($httpStatus, $body, $requestId); + $mapped['latency_ms'] = (int) round((microtime(true) - $startedAt) * 1000); + $mapped['gateway_request_id'] = (string) ($response->getHeaderLine('X-Request-Id') ?: ($body['request_id'] ?? '')); + + $this->logPush($payload, $mapped, $requestId); + return $mapped; + } catch (\Throwable $e) { + $result = [ + 'ok' => false, + 'status' => 'retry', + 'http_status' => 0, + 'request_id' => $requestId, + 'error' => 'network_error', + 'message' => $e->getMessage(), + 'retryable' => true, + 'gateway_request_id' => '', + 'latency_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ]; + $this->logPush($payload, $result, $requestId); + return $result; + } + } + + /** + * @param array $payload + * @return array + */ + public function validatePayload(array $payload): array + { + $errors = []; + + $hfrId = trim((string) ($payload['hfr_id'] ?? '')); + if ($hfrId === '') { + $errors[] = 'hfr_id is required'; + } + + $hiType = trim((string) ($payload['hi_type'] ?? '')); + if (! in_array($hiType, $this->allowedHiTypes, true)) { + $errors[] = 'hi_type is invalid'; + } + + $careContextRef = trim((string) ($payload['care_context_reference'] ?? '')); + if ($careContextRef === '') { + $errors[] = 'care_context_reference is required'; + } + + $bundle = $payload['fhir_bundle'] ?? null; + if (! is_array($bundle)) { + $errors[] = 'fhir_bundle must be an object'; + } else { + if (($bundle['resourceType'] ?? '') !== 'Bundle') { + $errors[] = 'fhir_bundle.resourceType must be Bundle'; + } + if (($bundle['type'] ?? '') !== 'document') { + $errors[] = 'fhir_bundle.type must be document'; + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * @param array $payload + * @param array $result + */ + private function logPush(array $payload, array $result, string $requestId): void + { + if (! $this->db->tableExists('abdm_api_logs')) { + return; + } + + $maskedPayload = $payload; + if (isset($maskedPayload['abha_id'])) { + $maskedPayload['abha_id'] = $this->maskAbha((string) $maskedPayload['abha_id']); + } + if (isset($maskedPayload['abha_address'])) { + $maskedPayload['abha_address'] = $this->maskAbhaAddress((string) $maskedPayload['abha_address']); + } + unset($maskedPayload['fhir_bundle']); + + $this->db->table('abdm_api_logs')->insert([ + 'channel' => 'eatria_bridge', + 'event_type' => 'abdm.sync.outbox.records_push', + 'endpoint' => '/api/v3/records/push', + 'http_method' => 'POST', + 'entity_type' => 'record', + 'entity_id' => (string) ($payload['local_record_id'] ?? ''), + 'request_json' => (string) json_encode($maskedPayload + ['request_id' => $requestId], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_code' => (int) ($result['http_status'] ?? 0), + 'response_json' => (string) json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => (bool) ($result['ok'] ?? false) ? 'success' : 'error', + 'error_message' => (string) (($result['ok'] ?? false) ? '' : ($result['message'] ?? '')), + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + /** + * @param array $body + * @return array + */ + private function mapResponse(int $httpStatus, array $body, string $requestId): array + { + $queueId = (string) ($body['queue_id'] ?? $body['existing_queue_id'] ?? ''); + $recordId = (int) ($body['record_id'] ?? $body['id'] ?? $body['existing_record_id'] ?? 0); + $message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus)); + + if ($httpStatus === 201 || ((int) ($body['ok'] ?? 0) === 1 && $httpStatus >= 200 && $httpStatus < 300)) { + return [ + 'ok' => true, + 'status' => 'done', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'gateway_record_id' => $recordId > 0 ? $recordId : null, + 'gateway_queue_id' => $queueId !== '' ? $queueId : null, + 'message' => $message, + 'retryable' => false, + ]; + } + + if ($httpStatus === 409 || strtoupper((string) ($body['error_code'] ?? '')) === 'DUPLICATE_RECORD') { + return [ + 'ok' => true, + 'status' => 'done', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'gateway_record_id' => $recordId > 0 ? $recordId : null, + 'gateway_queue_id' => $queueId !== '' ? $queueId : null, + 'message' => $message !== '' ? $message : 'Duplicate record handled as success.', + 'retryable' => false, + ]; + } + + if ($httpStatus >= 500 || $httpStatus === 0) { + return [ + 'ok' => false, + 'status' => 'retry', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'error' => 'gateway_unavailable', + 'message' => $message, + 'retryable' => true, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + ]; + } + + if ($httpStatus === 400 || $httpStatus === 401 || $httpStatus === 403 || $httpStatus === 422) { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'error' => 'validation_or_auth_error', + 'message' => $message, + 'retryable' => false, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + ]; + } + + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'error' => 'unknown_error', + 'message' => $message, + 'retryable' => false, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + ]; + } + + private function createRequestId(): string + { + $bytes = random_bytes(16); + $hex = bin2hex($bytes); + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12) + ); + } + + private function readSetting(string ...$names): string + { + foreach ($names as $name) { + $env = getenv($name); + if ($env !== false && trim((string) $env) !== '') { + return trim((string) $env); + } + + if ($this->db->tableExists('hospital_setting')) { + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $name) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $value = trim((string) ($row['s_value'] ?? '')); + if ($value !== '') { + return $value; + } + } + } + + return ''; + } + + private function maskAbha(string $abha): string + { + $digits = preg_replace('/\D/', '', $abha); + if (! is_string($digits) || $digits === '') { + return ''; + } + + if (strlen($digits) <= 4) { + return str_repeat('*', strlen($digits)); + } + + return str_repeat('*', strlen($digits) - 4) . substr($digits, -4); + } + + private function maskAbhaAddress(string $abhaAddress): string + { + $abhaAddress = trim($abhaAddress); + if ($abhaAddress === '') { + return ''; + } + + $parts = explode('@', $abhaAddress, 2); + $left = $parts[0] ?? ''; + $right = $parts[1] ?? ''; + if (strlen($left) <= 4) { + return str_repeat('*', max(1, strlen($left))) . ($right !== '' ? '@' . $right : ''); + } + + return str_repeat('*', strlen($left) - 4) . substr($left, -4) . ($right !== '' ? '@' . $right : ''); + } +} diff --git a/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php b/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php new file mode 100644 index 0000000..27835d8 --- /dev/null +++ b/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php @@ -0,0 +1,332 @@ +db = $db ?? db_connect(); + $this->patientModel = new AbdmSyncPatientModel($this->db); + $this->recordModel = new AbdmSyncRecordModel($this->db); + $this->outboxModel = new AbdmSyncOutboxModel($this->db); + } + + /** + * @param array $payload + */ + public function enqueuePatientSync(array $payload): ?int + { + if (! $this->requiredTablesPresent()) { + return null; + } + + $localPatientId = (int) ($payload['local_patient_id'] ?? 0); + $hfrId = trim((string) ($payload['hfr_id'] ?? '')); + if ($localPatientId <= 0 || $hfrId === '') { + return null; + } + + $sourceUpdatedAt = $this->normalizeSourceUpdatedAt((string) ($payload['source_updated_at'] ?? '')); + $idempotencyKey = $this->buildPatientIdempotencyKey($hfrId, $localPatientId, $sourceUpdatedAt); + + $row = [ + 'local_patient_id' => $localPatientId, + 'abha_id' => $this->normalizeAbhaDigits((string) ($payload['abha_id'] ?? '')), + 'abha_address' => trim((string) ($payload['abha_address'] ?? '')) ?: null, + 'name' => trim((string) ($payload['name'] ?? '')), + 'gender' => trim((string) ($payload['gender'] ?? '')) ?: null, + 'dob' => trim((string) ($payload['dob'] ?? '')) ?: null, + 'mobile' => trim((string) ($payload['mobile'] ?? '')) ?: null, + 'email' => trim((string) ($payload['email'] ?? '')) ?: null, + 'source_updated_at' => $sourceUpdatedAt, + 'sync_status' => self::STATUS_PENDING, + 'last_error' => null, + ]; + + $existing = $this->patientModel->where('local_patient_id', $localPatientId)->first(); + if ($existing) { + $this->patientModel->update((int) $existing['id'], $row); + $entityId = (string) $existing['id']; + } else { + $this->patientModel->insert($row); + $entityId = (string) $this->patientModel->getInsertID(); + } + + return $this->upsertOutbox('patient', $entityId, $idempotencyKey, $payload); + } + + /** + * @param array $payload + */ + public function enqueueRecordSync(array $payload): ?int + { + if (! $this->requiredTablesPresent()) { + return null; + } + + $localRecordId = trim((string) ($payload['local_record_id'] ?? '')); + $localPatientId = (int) ($payload['local_patient_id'] ?? 0); + $hfrId = trim((string) ($payload['hfr_id'] ?? '')); + $careContextReference = trim((string) ($payload['care_context_reference'] ?? '')); + $hiType = trim((string) ($payload['hi_type'] ?? '')); + if ($localRecordId === '' || $localPatientId <= 0 || $hfrId === '' || $careContextReference === '' || $hiType === '') { + return null; + } + + $sourceUpdatedAt = $this->normalizeSourceUpdatedAt((string) ($payload['source_updated_at'] ?? '')); + $idempotencyKey = $this->buildRecordIdempotencyKey($hfrId, $careContextReference, $sourceUpdatedAt); + + $row = [ + 'local_record_id' => $localRecordId, + 'local_patient_id' => $localPatientId, + 'hi_type' => $hiType, + 'care_context_reference' => $careContextReference, + 'care_context_display' => trim((string) ($payload['care_context_display'] ?? '')) ?: null, + 'visit_date' => trim((string) ($payload['visit_date'] ?? '')) ?: null, + 'department' => trim((string) ($payload['department'] ?? '')) ?: null, + 'doctor_name' => trim((string) ($payload['doctor_name'] ?? '')) ?: null, + 'fhir_bundle_json' => (string) json_encode($payload['fhir_bundle'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'consent_id' => trim((string) ($payload['consent_id'] ?? '')) ?: null, + 'hfr_id' => $hfrId, + 'source_updated_at' => $sourceUpdatedAt, + 'sync_status' => self::STATUS_PENDING, + 'last_error' => null, + ]; + + $existing = $this->recordModel->where('local_record_id', $localRecordId)->first(); + if ($existing) { + $this->recordModel->update((int) $existing['id'], $row); + $entityId = (string) $existing['id']; + } else { + $this->recordModel->insert($row); + $entityId = (string) $this->recordModel->getInsertID(); + } + + return $this->upsertOutbox('record', $entityId, $idempotencyKey, $payload); + } + + /** + * @return array> + */ + public function fetchProcessableBatch(int $limit = 20): array + { + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + return $this->db->table('abdm_sync_outbox') + ->whereIn('status', [self::STATUS_PENDING, self::STATUS_FAILED]) + ->groupStart() + ->where('next_retry_at IS NULL', null, false) + ->orWhere('next_retry_at <=', $now) + ->groupEnd() + ->orderBy('id', 'ASC') + ->limit(max(1, $limit)) + ->get() + ->getResultArray(); + } + + public function tryLockRow(int $id, string $workerId): bool + { + $updated = $this->db->table('abdm_sync_outbox') + ->where('id', $id) + ->whereIn('status', [self::STATUS_PENDING, self::STATUS_FAILED]) + ->update([ + 'status' => self::STATUS_IN_PROGRESS, + 'locked_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'worker_id' => $workerId, + ]); + + return (bool) $updated; + } + + public function markDone(int $id): void + { + $this->outboxModel->update($id, [ + 'status' => self::STATUS_DONE, + 'next_retry_at' => null, + 'last_error' => null, + 'locked_at' => null, + 'worker_id' => null, + ]); + } + + public function markRetryOrDead(int $id, int $retryCount, string $error): void + { + $next = $this->getNextRetryAt($retryCount); + $status = $next === null ? self::STATUS_DEAD : self::STATUS_FAILED; + + $this->outboxModel->update($id, [ + 'status' => $status, + 'retry_count' => $retryCount, + 'next_retry_at' => $next, + 'last_error' => mb_substr($error, 0, 1000), + 'locked_at' => null, + 'worker_id' => null, + ]); + } + + public function replayDeadRow(int $id): bool + { + $row = $this->outboxModel->find($id); + if (! is_array($row)) { + return false; + } + + return (bool) $this->outboxModel->update($id, [ + 'status' => self::STATUS_PENDING, + 'next_retry_at' => null, + 'last_error' => null, + 'locked_at' => null, + 'worker_id' => null, + ]); + } + + /** + * @return array + */ + public function getCounters(): array + { + $rows = $this->db->table('abdm_sync_outbox') + ->select('status, COUNT(*) as total') + ->groupBy('status') + ->get() + ->getResultArray(); + + $counts = [ + 'pending' => 0, + 'in_progress' => 0, + 'success' => 0, + 'failed' => 0, + 'dead' => 0, + ]; + + foreach ($rows as $row) { + $status = (string) ($row['status'] ?? ''); + $total = (int) ($row['total'] ?? 0); + if ($status === self::STATUS_DONE) { + $counts['success'] += $total; + } elseif (array_key_exists($status, $counts)) { + $counts[$status] += $total; + } + } + + $lastSuccess = $this->db->table('abdm_sync_outbox') + ->select('updated_at') + ->where('status', self::STATUS_DONE) + ->orderBy('updated_at', 'DESC') + ->get(1) + ->getRowArray(); + + $avgLatency = $this->db->query( + 'SELECT AVG(TIMESTAMPDIFF(SECOND, created_at, updated_at)) AS avg_seconds FROM abdm_sync_outbox WHERE status = ?', + [self::STATUS_DONE] + )->getRowArray(); + + $counts['avg_latency_seconds'] = (float) ($avgLatency['avg_seconds'] ?? 0.0); + $counts['last_success_at'] = (string) ($lastSuccess['updated_at'] ?? ''); + + return $counts; + } + + public function buildPatientIdempotencyKey(string $hfrId, int $localPatientId, string $sourceUpdatedAt): string + { + $epoch = strtotime($sourceUpdatedAt) ?: time(); + return $hfrId . '|' . $localPatientId . '|' . $epoch; + } + + public function buildRecordIdempotencyKey(string $hfrId, string $careContextReference, string $sourceUpdatedAt): string + { + $epoch = strtotime($sourceUpdatedAt) ?: time(); + return $hfrId . '|' . $careContextReference . '|' . $epoch; + } + + public function getNextRetryAt(int $retryCount): ?string + { + if ($retryCount <= 0) { + return Time::now('Asia/Kolkata')->toDateTimeString(); + } + + if ($retryCount > count($this->retryScheduleSeconds)) { + return null; + } + + $delay = $this->retryScheduleSeconds[$retryCount - 1]; + return date('Y-m-d H:i:s', time() + $delay); + } + + private function requiredTablesPresent(): bool + { + return $this->db->tableExists('abdm_sync_patient') + && $this->db->tableExists('abdm_sync_record') + && $this->db->tableExists('abdm_sync_outbox'); + } + + /** + * @param array $payload + */ + private function upsertOutbox(string $entityType, string $entityId, string $idempotencyKey, array $payload): ?int + { + $payloadJson = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($payloadJson === '' || $payloadJson === 'null') { + return null; + } + + $existing = $this->outboxModel->where('idempotency_key', $idempotencyKey)->first(); + $row = [ + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'idempotency_key' => $idempotencyKey, + 'payload_json' => $payloadJson, + 'status' => self::STATUS_PENDING, + 'next_retry_at' => null, + 'last_error' => null, + 'locked_at' => null, + 'worker_id' => null, + ]; + + if ($existing) { + $this->outboxModel->update((int) $existing['id'], $row); + return (int) $existing['id']; + } + + $this->outboxModel->insert($row); + $id = $this->outboxModel->getInsertID(); + return $id > 0 ? (int) $id : null; + } + + private function normalizeSourceUpdatedAt(string $sourceUpdatedAt): string + { + $ts = strtotime($sourceUpdatedAt); + if ($ts === false) { + $ts = time(); + } + + return date('Y-m-d H:i:s', $ts); + } + + private function normalizeAbhaDigits(string $abha): string + { + $digits = preg_replace('/\D/', '', $abha); + return is_string($digits) ? $digits : ''; + } +} diff --git a/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php b/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php new file mode 100644 index 0000000..91f0e29 --- /dev/null +++ b/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php @@ -0,0 +1,246 @@ +db = $db ?? db_connect(); + $this->outbox = $outbox ?? new AbdmSyncOutboxService($this->db); + $this->gatewayClient = $gatewayClient ?? new AbdmGatewayPushClient($this->db); + $this->patientModel = new AbdmSyncPatientModel($this->db); + $this->recordModel = new AbdmSyncRecordModel($this->db); + } + + /** + * @return array + */ + public function process(int $limit = 20, string $workerId = 'abdm-push-sync'): array + { + $summary = [ + 'processed' => 0, + 'success' => 0, + 'failed' => 0, + 'dead' => 0, + 'skipped' => 0, + ]; + + $rows = $this->outbox->fetchProcessableBatch($limit); + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + $summary['skipped']++; + continue; + } + + if (! $this->outbox->tryLockRow($id, $workerId)) { + $summary['skipped']++; + continue; + } + + $summary['processed']++; + $entityType = (string) ($row['entity_type'] ?? ''); + $entityId = (int) ($row['entity_id'] ?? 0); + $payload = json_decode((string) ($row['payload_json'] ?? '{}'), true); + if (! is_array($payload)) { + $payload = []; + } + + if ($entityType === 'record') { + $result = $this->processRecord($entityId, $payload, (string) ($row['idempotency_key'] ?? '')); + } elseif ($entityType === 'patient') { + $result = $this->processPatient($entityId, $payload, (string) ($row['idempotency_key'] ?? '')); + } else { + $result = [ + 'ok' => false, + 'retryable' => false, + 'message' => 'Unknown entity type: ' . $entityType, + ]; + } + + if ((bool) ($result['ok'] ?? false)) { + $this->outbox->markDone($id); + $summary['success']++; + continue; + } + + $retryCount = ((int) ($row['retry_count'] ?? 0)) + 1; + $this->outbox->markRetryOrDead($id, $retryCount, (string) ($result['message'] ?? 'Sync failed')); + if ($this->outbox->getNextRetryAt($retryCount) === null) { + $summary['dead']++; + } else { + $summary['failed']++; + } + } + + return $summary; + } + + /** + * @param array $payload + * @return array + */ + private function processRecord(int $syncRecordId, array $payload, string $idempotencyKey): array + { + $record = $this->recordModel->find($syncRecordId); + if (! is_array($record)) { + return ['ok' => false, 'retryable' => false, 'message' => 'Sync record not found']; + } + + $fhirBundle = $payload['fhir_bundle'] ?? json_decode((string) ($record['fhir_bundle_json'] ?? '{}'), true); + if (! is_array($fhirBundle)) { + $fhirBundle = []; + } + + $gatewayPayload = [ + 'hfr_id' => (string) ($record['hfr_id'] ?? ''), + 'hi_type' => (string) ($record['hi_type'] ?? ''), + 'record_type' => (string) ($record['hi_type'] ?? ''), + 'abha_id' => $this->normalizeAbhaDigits((string) ($payload['abha_id'] ?? '')), + 'abha_address' => trim((string) ($payload['abha_address'] ?? '')), + 'patient_name' => trim((string) ($payload['patient_name'] ?? '')), + 'local_patient_id' => (string) ($record['local_patient_id'] ?? ''), + 'care_context_reference' => (string) ($record['care_context_reference'] ?? ''), + 'care_context_display' => (string) ($record['care_context_display'] ?? ''), + 'visit_date' => (string) ($record['visit_date'] ?? ''), + 'department' => (string) ($record['department'] ?? ''), + 'doctor_name' => (string) ($record['doctor_name'] ?? ''), + 'fhir_bundle' => $fhirBundle, + ]; + + $result = $this->gatewayClient->pushRecord($gatewayPayload, $idempotencyKey); + + $this->recordModel->update($syncRecordId, [ + 'sync_status' => (bool) ($result['ok'] ?? false) ? 'done' : ((bool) ($result['retryable'] ?? false) ? 'failed' : 'dead'), + 'gateway_record_id' => (int) ($result['gateway_record_id'] ?? 0) ?: null, + 'gateway_queue_id' => (string) ($result['gateway_queue_id'] ?? '') ?: null, + 'last_synced_at' => date('Y-m-d H:i:s'), + 'last_error' => (bool) ($result['ok'] ?? false) ? null : (string) ($result['message'] ?? 'Sync failed'), + 'retry_count' => (int) ($record['retry_count'] ?? 0) + ((bool) ($result['ok'] ?? false) ? 0 : 1), + ]); + + return $result; + } + + /** + * @param array $payload + * @return array + */ + private function processPatient(int $syncPatientId, array $payload, string $idempotencyKey): array + { + $patient = $this->patientModel->find($syncPatientId); + if (! is_array($patient)) { + return ['ok' => false, 'retryable' => false, 'message' => 'Sync patient not found']; + } + + $patientName = trim((string) ($patient['name'] ?? '')); + $careContextReference = 'PAT-' . (int) ($patient['local_patient_id'] ?? 0) . '-' . date('YmdHis', strtotime((string) ($patient['source_updated_at'] ?? 'now'))); + + $fhirBundle = [ + 'resourceType' => 'Bundle', + 'type' => 'document', + 'timestamp' => date(DATE_ATOM), + 'entry' => [ + [ + 'fullUrl' => 'urn:uuid:composition-' . $syncPatientId, + 'resource' => [ + 'resourceType' => 'Composition', + 'id' => 'composition-' . $syncPatientId, + 'status' => 'final', + 'type' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => '34133-9', + 'display' => 'Summary of episode note', + ]], + ], + 'date' => date(DATE_ATOM), + 'title' => 'Patient Demographics Snapshot', + 'subject' => ['reference' => 'urn:uuid:patient-' . $syncPatientId], + ], + ], + [ + 'fullUrl' => 'urn:uuid:patient-' . $syncPatientId, + 'resource' => [ + 'resourceType' => 'Patient', + 'id' => 'patient-' . $syncPatientId, + 'identifier' => [ + [ + 'system' => 'https://hms.local/patient-id', + 'value' => (string) ($patient['local_patient_id'] ?? ''), + ], + ], + 'name' => [[ + 'text' => $patientName, + ]], + ], + ], + ], + ]; + + $abhaDigits = $this->normalizeAbhaDigits((string) ($patient['abha_id'] ?? '')); + if ($abhaDigits !== '') { + $fhirBundle['entry'][1]['resource']['identifier'][] = [ + 'system' => 'https://healthid.ndhm.gov.in', + 'value' => $abhaDigits, + ]; + } + + $abhaAddress = trim((string) ($patient['abha_address'] ?? '')); + if ($abhaAddress !== '') { + $fhirBundle['entry'][1]['resource']['identifier'][] = [ + 'system' => 'https://abdm.gov.in/health-address', + 'value' => $abhaAddress, + ]; + } + + $gatewayPayload = [ + 'hfr_id' => trim((string) ($payload['hfr_id'] ?? '')), + 'hi_type' => 'HealthDocumentRecord', + 'record_type' => 'HealthDocumentRecord', + 'abha_id' => $abhaDigits, + 'abha_address' => $abhaAddress, + 'patient_name' => $patientName, + 'local_patient_id' => (string) ($patient['local_patient_id'] ?? ''), + 'care_context_reference' => $careContextReference, + 'care_context_display' => 'Patient profile sync', + 'visit_date' => date('Y-m-d'), + 'department' => 'Registration', + 'doctor_name' => 'System', + 'fhir_bundle' => $fhirBundle, + ]; + + $result = $this->gatewayClient->pushRecord($gatewayPayload, $idempotencyKey); + + $this->patientModel->update($syncPatientId, [ + 'sync_status' => (bool) ($result['ok'] ?? false) ? 'done' : ((bool) ($result['retryable'] ?? false) ? 'failed' : 'dead'), + 'last_synced_at' => date('Y-m-d H:i:s'), + 'last_error' => (bool) ($result['ok'] ?? false) ? null : (string) ($result['message'] ?? 'Patient sync failed'), + 'retry_count' => (int) ($patient['retry_count'] ?? 0) + ((bool) ($result['ok'] ?? false) ? 0 : 1), + ]); + + return $result; + } + + private function normalizeAbhaDigits(string $abha): string + { + $digits = preg_replace('/\D/', '', $abha); + return is_string($digits) ? $digits : ''; + } +} diff --git a/app/Models/AbdmSyncOutboxModel.php b/app/Models/AbdmSyncOutboxModel.php new file mode 100644 index 0000000..1ed7cd3 --- /dev/null +++ b/app/Models/AbdmSyncOutboxModel.php @@ -0,0 +1,30 @@ +validatePayload([ + 'hfr_id' => 'IN0510000870', + 'hi_type' => 'OPConsultRecord', + 'care_context_reference' => 'OPD-1-S1-2026-06-27', + 'fhir_bundle' => [ + 'resourceType' => 'Bundle', + 'type' => 'collection', + ], + ]); + + $this->assertFalse((bool) ($validation['valid'] ?? true)); + $this->assertNotEmpty($validation['errors']); + } + + public function testValidationAllowsValidShape(): void + { + $client = new AbdmGatewayPushClient(); + $validation = $client->validatePayload([ + 'hfr_id' => 'IN0510000870', + 'hi_type' => 'OPConsultRecord', + 'care_context_reference' => 'OPD-1-S1-2026-06-27', + 'fhir_bundle' => [ + 'resourceType' => 'Bundle', + 'type' => 'document', + ], + ]); + + $this->assertTrue((bool) ($validation['valid'] ?? false)); + } +} diff --git a/tests/unit/Abdm/AbdmSyncOutboxServiceTest.php b/tests/unit/Abdm/AbdmSyncOutboxServiceTest.php new file mode 100644 index 0000000..9a342bc --- /dev/null +++ b/tests/unit/Abdm/AbdmSyncOutboxServiceTest.php @@ -0,0 +1,25 @@ +buildRecordIdempotencyKey('IN0510000870', 'OPD-101-S1-2026-06-27', '2026-06-27 10:30:00'); + $k2 = $svc->buildRecordIdempotencyKey('IN0510000870', 'OPD-101-S1-2026-06-27', '2026-06-27 10:30:00'); + + $this->assertSame($k1, $k2); + } + + public function testRetryScheduleEndsInDeadAfterEightRetries(): void + { + $svc = new AbdmSyncOutboxService(); + + $this->assertNotNull($svc->getNextRetryAt(1)); + $this->assertNotNull($svc->getNextRetryAt(8)); + $this->assertNull($svc->getNextRetryAt(9)); + } +} diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php new file mode 100644 index 0000000..8421ab8 --- /dev/null +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -0,0 +1,81 @@ +factory = new FhirGeneratorFactory(); + } + + public function testOpdGeneratorHappyPath(): void + { + $src = [ + 'record_id' => 101, + 'session_id' => 1, + 'visit_date' => '2026-06-27', + 'completed_at' => '2026-06-27T10:00:00+05:30', + 'patient' => ['id' => 12, 'name' => 'Devender Singh', 'gender' => 'male', 'dob' => '1979-03-28', 'abha_id' => '1234-5678-9012'], + 'encounter' => ['id' => 'E101', 'start' => '2026-06-27T09:30:00+05:30', 'end' => '2026-06-27T10:00:00+05:30'], + 'doctor' => ['id' => 'D9', 'name' => 'Dr. R K Sundriyal'], + 'organization' => ['id' => 'H1', 'name' => 'Chamunda Hospital'], + 'diagnoses' => [['text' => 'Acute upper respiratory infection']], + 'medications' => [['name' => 'Eltroxin 50', 'dosage' => '1-0-0 for 5 days']], + 'vitals' => [['display' => 'Heart Rate', 'code' => '8867-4', 'value' => 82, 'unit' => 'beats/min']], + ]; + + $out = $this->factory->opd()->generate($src); + $this->assertSame('OPConsultRecord', $out['hi_type']); + $this->assertSame('Bundle', $out['fhir_bundle']['resourceType']); + $this->assertSame('document', $out['fhir_bundle']['type']); + $this->assertSame('Composition', $out['fhir_bundle']['entry'][0]['resource']['resourceType']); + $this->assertTrue((bool) ($out['validation']['valid'] ?? false)); + } + + public function testLabGeneratorLargeObservationSet(): void + { + $obs = []; + for ($i = 0; $i < 120; $i++) { + $obs[] = ['name' => 'Glucose', 'code' => 'GLUCOSE', 'value' => 90 + $i, 'unit' => 'mg/dL', 'interpretation' => 'NORMAL']; + } + + $src = [ + 'record_id' => 4001, + 'session_id' => 9, + 'visit_date' => '2026-06-26', + 'completed_at' => '2026-06-26T18:10:00+05:30', + 'patient' => ['id' => 12, 'name' => 'Devender Singh', 'gender' => 'male', 'dob' => '1979-03-28'], + 'encounter' => ['id' => 'L4001', 'start' => '2026-06-26T16:00:00+05:30'], + 'panel_name' => 'Biochemistry', + 'panel_code' => 'GLUCOSE', + 'observations' => $obs, + ]; + + $out = $this->factory->lab()->generate($src); + $this->assertSame('DiagnosticReportRecord', $out['hi_type']); + $this->assertTrue((bool) ($out['validation']['valid'] ?? false)); + $this->assertGreaterThan(90, count($out['fhir_bundle']['entry'])); + } + + public function testDeterministicCareContextForSameInput(): void + { + $src = [ + 'record_id' => 7001, + 'session_id' => 3, + 'visit_date' => '2026-06-25', + 'completed_at' => '2026-06-25T10:00:00+05:30', + 'patient' => ['id' => 1, 'name' => 'Test Patient', 'gender' => 'male', 'dob' => '1990-01-01'], + ]; + + $o1 = $this->factory->invoice()->generate($src); + $o2 = $this->factory->invoice()->generate($src); + + $this->assertSame($o1['care_context_reference'], $o2['care_context_reference']); + $this->assertSame($o1['hi_type'], $o2['hi_type']); + } +} diff --git a/tests/unit/Abdm/IntegrationMappingStubTest.php b/tests/unit/Abdm/IntegrationMappingStubTest.php new file mode 100644 index 0000000..c070133 --- /dev/null +++ b/tests/unit/Abdm/IntegrationMappingStubTest.php @@ -0,0 +1,59 @@ + 201, + 'ok' => true, + 'gateway_record_id' => 99, + 'gateway_queue_id' => 'Q-001', + ]; + + $this->assertTrue($response['ok']); + $this->assertSame(201, $response['http_status']); + $this->assertSame('Q-001', $response['gateway_queue_id']); + } + + public function testRecordsPushDuplicateMappingStub(): void + { + $response = [ + 'http_status' => 409, + 'ok' => true, + 'gateway_record_id' => 99, + 'gateway_queue_id' => 'Q-OLD', + ]; + + $this->assertTrue($response['ok']); + $this->assertSame(409, $response['http_status']); + } + + public function testRecordsPushAuthErrorMappingStub(): void + { + $response = [ + 'http_status' => 403, + 'ok' => false, + 'retryable' => false, + 'error' => 'validation_or_auth_error', + ]; + + $this->assertFalse($response['ok']); + $this->assertFalse($response['retryable']); + } + + public function testRecordsPushTimeoutMappingStub(): void + { + $response = [ + 'http_status' => 0, + 'ok' => false, + 'retryable' => true, + 'error' => 'network_error', + ]; + + $this->assertFalse($response['ok']); + $this->assertTrue($response['retryable']); + } +} diff --git a/tests/unit/Abdm/fixtures/discharge_source.json b/tests/unit/Abdm/fixtures/discharge_source.json new file mode 100644 index 0000000..a36da17 --- /dev/null +++ b/tests/unit/Abdm/fixtures/discharge_source.json @@ -0,0 +1,26 @@ +{ + "record_id": 5002, + "session_id": 2, + "visit_date": "2026-06-20", + "completed_at": "2026-06-20T15:00:00+05:30", + "patient": { + "id": 21, + "name": "Kusum Devi", + "gender": "female", + "dob": "1982-10-02" + }, + "encounter": { + "id": "IPD998", + "start": "2026-06-18T10:00:00+05:30", + "end": "2026-06-20T14:00:00+05:30" + }, + "conditions": [ + {"text": "Acute febrile illness"} + ], + "procedures": [ + {"text": "Ultrasound abdomen"} + ], + "medications": [ + {"name": "Paracetamol 650", "dosage": "1-1-1"} + ] +} diff --git a/tests/unit/Abdm/fixtures/invoice_source.json b/tests/unit/Abdm/fixtures/invoice_source.json new file mode 100644 index 0000000..cad5656 --- /dev/null +++ b/tests/unit/Abdm/fixtures/invoice_source.json @@ -0,0 +1,27 @@ +{ + "record_id": 9001, + "session_id": 1, + "visit_date": "2026-06-21", + "completed_at": "2026-06-21T12:00:00+05:30", + "patient": { + "id": 12, + "name": "Devender Singh", + "gender": "male", + "dob": "1979-03-28" + }, + "invoice_no": "INV-2026-9001", + "currency": "INR", + "line_items": [ + {"name": "Consultation", "qty": 1, "rate": 500, "amount": 500}, + {"name": "Medicine", "qty": 2, "rate": 150, "amount": 300} + ], + "total_amount": 800, + "organization": { + "id": "H1", + "name": "Chamunda Hospital" + }, + "doctor": { + "id": "D9", + "name": "Dr. R K Sundriyal" + } +} diff --git a/tests/unit/Abdm/fixtures/lab_source.json b/tests/unit/Abdm/fixtures/lab_source.json new file mode 100644 index 0000000..aba2548 --- /dev/null +++ b/tests/unit/Abdm/fixtures/lab_source.json @@ -0,0 +1,22 @@ +{ + "record_id": 4001, + "session_id": 9, + "visit_date": "2026-06-26", + "completed_at": "2026-06-26T18:10:00+05:30", + "patient": { + "id": 12, + "name": "Devender Singh", + "gender": "male", + "dob": "1979-03-28" + }, + "encounter": { + "id": "L4001", + "start": "2026-06-26T16:00:00+05:30" + }, + "panel_name": "Biochemistry", + "panel_code": "GLUCOSE", + "observations": [ + {"name": "Glucose", "code": "GLUCOSE", "value": 102, "unit": "mg/dL", "interpretation": "NORMAL"}, + {"name": "Creatinine", "code": "CREATININE", "value": 1.1, "unit": "mg/dL", "interpretation": "NORMAL"} + ] +} diff --git a/tests/unit/Abdm/fixtures/opd_source.json b/tests/unit/Abdm/fixtures/opd_source.json new file mode 100644 index 0000000..1a4cc7e --- /dev/null +++ b/tests/unit/Abdm/fixtures/opd_source.json @@ -0,0 +1,36 @@ +{ + "record_id": 101, + "session_id": 1, + "visit_date": "2026-06-27", + "completed_at": "2026-06-27T10:00:00+05:30", + "patient": { + "id": 12, + "name": "Devender Singh", + "gender": "male", + "dob": "1979-03-28", + "abha_id": "1234-5678-9012", + "abha_address": "devender@abdm" + }, + "encounter": { + "id": "E101", + "start": "2026-06-27T09:30:00+05:30", + "end": "2026-06-27T10:00:00+05:30" + }, + "doctor": { + "id": "D9", + "name": "Dr. R K Sundriyal" + }, + "organization": { + "id": "H1", + "name": "Chamunda Hospital" + }, + "diagnoses": [ + {"text": "Acute upper respiratory infection"} + ], + "medications": [ + {"name": "Eltroxin 50", "dosage": "1-0-0 for 5 days"} + ], + "vitals": [ + {"display": "Heart Rate", "code": "8867-4", "value": 82, "unit": "beats/min"} + ] +}