ABDM M2: add push-sync outbox, worker, and FHIR generators
This commit is contained in:
parent
e651b18a01
commit
ed48a447e5
68
ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md
Normal file
68
ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md
Normal file
|
|
@ -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.
|
||||
43
app/Commands/AbdmPushSync.php
Normal file
43
app/Commands/AbdmPushSync.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\Abdm\Sync\AbdmSyncWorkerService;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class AbdmPushSync extends BaseCommand
|
||||
{
|
||||
protected $group = 'ABDM';
|
||||
protected $name = 'abdm:push-sync';
|
||||
protected $description = 'Process ABDM M2 sync outbox and push records to gateway /api/v3/records/push.';
|
||||
protected $usage = 'abdm:push-sync [--limit 20] [--worker worker-name]';
|
||||
protected $arguments = [];
|
||||
protected $options = [
|
||||
'--limit' => '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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
103
app/Controllers/AbdmSync.php
Normal file
103
app/Controllers/AbdmSync.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\Abdm\Sync\AbdmSyncOutboxService;
|
||||
|
||||
class AbdmSync extends BaseController
|
||||
{
|
||||
public function enqueuePatient()
|
||||
{
|
||||
if (! $this->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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateAbdmPushSyncTables extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
216
app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php
Normal file
216
app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir;
|
||||
|
||||
class FhirDocumentBuilder
|
||||
{
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
private array $entries = [];
|
||||
|
||||
/** @var array<string,bool> */
|
||||
private array $resourceIds = [];
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
private array $bundleMeta = [];
|
||||
|
||||
/** @var array<string,mixed>|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<string,mixed> $composition
|
||||
*/
|
||||
public function buildComposition(array $composition): self
|
||||
{
|
||||
$this->composition = $composition;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $resource
|
||||
*/
|
||||
public function addPatient(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addEncounter(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addPractitioner(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addOrganization(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addCondition(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addObservation(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addDiagnosticReport(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addMedicationRequest(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addProcedure(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addClaim(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $resource
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $data
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<int|string,mixed> $value
|
||||
* @return array<int|string,mixed>|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;
|
||||
}
|
||||
}
|
||||
37
app/Libraries/Abdm/Fhir/FhirGeneratorFactory.php
Normal file
37
app/Libraries/Abdm/Fhir/FhirGeneratorFactory.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir;
|
||||
|
||||
use App\Libraries\Abdm\Fhir\Generators\DischargeFhirGenerator;
|
||||
use App\Libraries\Abdm\Fhir\Generators\InvoiceFhirGenerator;
|
||||
use App\Libraries\Abdm\Fhir\Generators\LabFhirGenerator;
|
||||
use App\Libraries\Abdm\Fhir\Generators\OpdFhirGenerator;
|
||||
use App\Libraries\Abdm\Fhir\Generators\RadiologyFhirGenerator;
|
||||
|
||||
class FhirGeneratorFactory
|
||||
{
|
||||
public function opd(): OpdFhirGenerator
|
||||
{
|
||||
return new OpdFhirGenerator();
|
||||
}
|
||||
|
||||
public function lab(): LabFhirGenerator
|
||||
{
|
||||
return new LabFhirGenerator();
|
||||
}
|
||||
|
||||
public function radiology(): RadiologyFhirGenerator
|
||||
{
|
||||
return new RadiologyFhirGenerator();
|
||||
}
|
||||
|
||||
public function discharge(): DischargeFhirGenerator
|
||||
{
|
||||
return new DischargeFhirGenerator();
|
||||
}
|
||||
|
||||
public function invoice(): InvoiceFhirGenerator
|
||||
{
|
||||
return new InvoiceFhirGenerator();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Generators;
|
||||
|
||||
use App\Libraries\Abdm\Fhir\FhirDocumentBuilder;
|
||||
use App\Libraries\Abdm\Fhir\Support\CodingResolver;
|
||||
use App\Libraries\Abdm\Fhir\Support\FhirBundleValidator;
|
||||
|
||||
abstract class AbstractModuleFhirGenerator
|
||||
{
|
||||
protected FhirDocumentBuilder $builder;
|
||||
protected CodingResolver $codingResolver;
|
||||
protected FhirBundleValidator $validator;
|
||||
|
||||
public function __construct(
|
||||
?FhirDocumentBuilder $builder = null,
|
||||
?CodingResolver $codingResolver = null,
|
||||
?FhirBundleValidator $validator = null
|
||||
) {
|
||||
$this->builder = $builder ?? new FhirDocumentBuilder();
|
||||
$this->codingResolver = $codingResolver ?? new CodingResolver();
|
||||
$this->validator = $validator ?? new FhirBundleValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
abstract public function generate(array $source): array;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
107
app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php
Normal file
107
app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Generators;
|
||||
|
||||
class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\AbstractModuleFhirGenerator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
110
app/Libraries/Abdm/Fhir/Generators/InvoiceFhirGenerator.php
Normal file
110
app/Libraries/Abdm/Fhir/Generators/InvoiceFhirGenerator.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Generators;
|
||||
|
||||
class InvoiceFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\AbstractModuleFhirGenerator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
151
app/Libraries/Abdm/Fhir/Generators/LabFhirGenerator.php
Normal file
151
app/Libraries/Abdm/Fhir/Generators/LabFhirGenerator.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Generators;
|
||||
|
||||
class LabFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\AbstractModuleFhirGenerator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Libraries/Abdm/Fhir/Generators/OpdFhirGenerator.php
Normal file
137
app/Libraries/Abdm/Fhir/Generators/OpdFhirGenerator.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Generators;
|
||||
|
||||
class OpdFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\AbstractModuleFhirGenerator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
161
app/Libraries/Abdm/Fhir/Generators/RadiologyFhirGenerator.php
Normal file
161
app/Libraries/Abdm/Fhir/Generators/RadiologyFhirGenerator.php
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Generators;
|
||||
|
||||
class RadiologyFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\AbstractModuleFhirGenerator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
190
app/Libraries/Abdm/Fhir/Support/CodingResolver.php
Normal file
190
app/Libraries/Abdm/Fhir/Support/CodingResolver.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Support;
|
||||
|
||||
class CodingResolver
|
||||
{
|
||||
/** @var array<string,array{code:string,display:string}> */
|
||||
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<string,array{code:string,display:string}> */
|
||||
private array $radiologySnomedFindingMap = [
|
||||
'PNEUMONIA' => ['code' => '233604007', 'display' => 'Pneumonia'],
|
||||
'PLEURAL EFFUSION' => ['code' => '60046008', 'display' => 'Pleural effusion'],
|
||||
'FRACTURE' => ['code' => '125605004', 'display' => 'Fracture of bone'],
|
||||
];
|
||||
|
||||
/** @var array<string,array{code:string,display:string}> */
|
||||
private array $bodySiteMap = [
|
||||
'CHEST' => ['code' => '51185008', 'display' => 'Thoracic structure'],
|
||||
'ABDOMEN' => ['code' => '818983003', 'display' => 'Abdominal structure'],
|
||||
'BRAIN' => ['code' => '12738006', 'display' => 'Brain structure'],
|
||||
];
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function resolveLoincForLabTest(string $localCode, string $displayName, string $department = '', string $specimenType = ''): array
|
||||
{
|
||||
return $this->resolveByHeuristic($localCode, $displayName, $this->labLoincMap, 'http://loinc.org');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function resolveLoincForLabPanel(string $localCode, string $displayName, string $department = ''): array
|
||||
{
|
||||
return $this->resolveByHeuristic($localCode, $displayName, $this->labLoincMap, 'http://loinc.org');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function resolveSnomedForDiagnosisOrFinding(string $localCode, string $displayName, string $mappedIcd = ''): array
|
||||
{
|
||||
return $this->resolveByHeuristic($localCode, $displayName, $this->radiologySnomedFindingMap, 'http://snomed.info/sct');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function resolveSnomedForBodySite(string $localCode, string $displayName): array
|
||||
{
|
||||
return $this->resolveByHeuristic($localCode, $displayName, $this->bodySiteMap, 'http://snomed.info/sct');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
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<string,mixed> */
|
||||
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<string,mixed> */
|
||||
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<string,array{code:string,display:string}> $dictionary
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
143
app/Libraries/Abdm/Fhir/Support/FhirBundleValidator.php
Normal file
143
app/Libraries/Abdm/Fhir/Support/FhirBundleValidator.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Support;
|
||||
|
||||
class FhirBundleValidator
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $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<int,array<string,string>> $errors
|
||||
* @param array<int,array<string,string>> $warnings
|
||||
* @param array<string,mixed> $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<string,mixed> $terminologySummary
|
||||
* @param array<int,array<string,string>> $warnings
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Libraries/Abdm/Fhir/Support/FhirValidationResult.php
Normal file
52
app/Libraries/Abdm/Fhir/Support/FhirValidationResult.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Support;
|
||||
|
||||
class FhirValidationResult
|
||||
{
|
||||
public bool $valid;
|
||||
public int $score;
|
||||
|
||||
/** @var array<int,array<string,string>> */
|
||||
public array $errors;
|
||||
|
||||
/** @var array<int,array<string,string>> */
|
||||
public array $warnings;
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
public array $terminologySummary;
|
||||
|
||||
/** @var array<string,mixed> */
|
||||
public array $audit;
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,string>> $errors
|
||||
* @param array<int,array<string,string>> $warnings
|
||||
* @param array<string,mixed> $terminologySummary
|
||||
* @param array<string,mixed> $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<string,mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'valid' => $this->valid,
|
||||
'score' => $this->score,
|
||||
'errors' => $this->errors,
|
||||
'warnings' => $this->warnings,
|
||||
'terminology_summary' => $this->terminologySummary,
|
||||
'audit' => $this->audit,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Libraries/Abdm/Fhir/Support/GatewayPayloadAdapter.php
Normal file
39
app/Libraries/Abdm/Fhir/Support/GatewayPayloadAdapter.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Fhir\Support;
|
||||
|
||||
class GatewayPayloadAdapter
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $generatorOutput
|
||||
* @param array<string,mixed> $source
|
||||
* @param string $hfrId
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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 : '';
|
||||
}
|
||||
}
|
||||
346
app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php
Normal file
346
app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Sync;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class AbdmGatewayPushClient
|
||||
{
|
||||
private BaseConnection $db;
|
||||
|
||||
/** @var string[] */
|
||||
private array $allowedHiTypes = [
|
||||
'OPConsultRecord',
|
||||
'DiagnosticReportRecord',
|
||||
'DischargeSummaryRecord',
|
||||
'InvoiceRecord',
|
||||
'PrescriptionRecord',
|
||||
'HealthDocumentRecord',
|
||||
];
|
||||
|
||||
public function __construct(?BaseConnection $db = null)
|
||||
{
|
||||
$this->db = $db ?? db_connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $payload
|
||||
* @param array<string,mixed> $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<string,mixed> $body
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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 : '');
|
||||
}
|
||||
}
|
||||
332
app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php
Normal file
332
app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Sync;
|
||||
|
||||
use App\Models\AbdmSyncOutboxModel;
|
||||
use App\Models\AbdmSyncPatientModel;
|
||||
use App\Models\AbdmSyncRecordModel;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class AbdmSyncOutboxService
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
public const STATUS_DONE = 'done';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_DEAD = 'dead';
|
||||
|
||||
private BaseConnection $db;
|
||||
private AbdmSyncPatientModel $patientModel;
|
||||
private AbdmSyncRecordModel $recordModel;
|
||||
private AbdmSyncOutboxModel $outboxModel;
|
||||
|
||||
/** @var int[] */
|
||||
private array $retryScheduleSeconds = [60, 300, 900, 1800, 3600, 7200, 14400, 28800];
|
||||
|
||||
public function __construct(?BaseConnection $db = null)
|
||||
{
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->patientModel = new AbdmSyncPatientModel($this->db);
|
||||
$this->recordModel = new AbdmSyncRecordModel($this->db);
|
||||
$this->outboxModel = new AbdmSyncOutboxModel($this->db);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $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<string,mixed> $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<int,array<string,mixed>>
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $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 : '';
|
||||
}
|
||||
}
|
||||
246
app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php
Normal file
246
app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm\Sync;
|
||||
|
||||
use App\Models\AbdmSyncPatientModel;
|
||||
use App\Models\AbdmSyncRecordModel;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use App\Libraries\Abdm\Sync\AbdmSyncOutboxService;
|
||||
use App\Libraries\Abdm\Sync\AbdmGatewayPushClient;
|
||||
|
||||
class AbdmSyncWorkerService
|
||||
{
|
||||
private BaseConnection $db;
|
||||
private AbdmSyncOutboxService $outbox;
|
||||
private AbdmGatewayPushClient $gatewayClient;
|
||||
private AbdmSyncPatientModel $patientModel;
|
||||
private AbdmSyncRecordModel $recordModel;
|
||||
|
||||
public function __construct(
|
||||
?BaseConnection $db = null,
|
||||
?AbdmSyncOutboxService $outbox = null,
|
||||
?AbdmGatewayPushClient $gatewayClient = null
|
||||
) {
|
||||
$this->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<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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 : '';
|
||||
}
|
||||
}
|
||||
30
app/Models/AbdmSyncOutboxModel.php
Normal file
30
app/Models/AbdmSyncOutboxModel.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class AbdmSyncOutboxModel extends Model
|
||||
{
|
||||
protected $table = 'abdm_sync_outbox';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'idempotency_key',
|
||||
'payload_json',
|
||||
'status',
|
||||
'next_retry_at',
|
||||
'retry_count',
|
||||
'last_error',
|
||||
'locked_at',
|
||||
'worker_id',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
}
|
||||
33
app/Models/AbdmSyncPatientModel.php
Normal file
33
app/Models/AbdmSyncPatientModel.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class AbdmSyncPatientModel extends Model
|
||||
{
|
||||
protected $table = 'abdm_sync_patient';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'local_patient_id',
|
||||
'abha_id',
|
||||
'abha_address',
|
||||
'name',
|
||||
'gender',
|
||||
'dob',
|
||||
'mobile',
|
||||
'email',
|
||||
'source_updated_at',
|
||||
'sync_status',
|
||||
'last_synced_at',
|
||||
'last_error',
|
||||
'retry_count',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
}
|
||||
38
app/Models/AbdmSyncRecordModel.php
Normal file
38
app/Models/AbdmSyncRecordModel.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class AbdmSyncRecordModel extends Model
|
||||
{
|
||||
protected $table = 'abdm_sync_record';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'local_record_id',
|
||||
'local_patient_id',
|
||||
'hi_type',
|
||||
'care_context_reference',
|
||||
'care_context_display',
|
||||
'visit_date',
|
||||
'department',
|
||||
'doctor_name',
|
||||
'fhir_bundle_json',
|
||||
'consent_id',
|
||||
'hfr_id',
|
||||
'source_updated_at',
|
||||
'sync_status',
|
||||
'gateway_record_id',
|
||||
'gateway_queue_id',
|
||||
'last_synced_at',
|
||||
'last_error',
|
||||
'retry_count',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
}
|
||||
40
tests/unit/Abdm/AbdmGatewayPushClientTest.php
Normal file
40
tests/unit/Abdm/AbdmGatewayPushClientTest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use App\Libraries\Abdm\Sync\AbdmGatewayPushClient;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class AbdmGatewayPushClientTest extends CIUnitTestCase
|
||||
{
|
||||
public function testValidationFailsWhenBundleTypeNotDocument(): 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' => '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));
|
||||
}
|
||||
}
|
||||
25
tests/unit/Abdm/AbdmSyncOutboxServiceTest.php
Normal file
25
tests/unit/Abdm/AbdmSyncOutboxServiceTest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use App\Libraries\Abdm\Sync\AbdmSyncOutboxService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class AbdmSyncOutboxServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testBuildRecordIdempotencyKeyIsDeterministic(): void
|
||||
{
|
||||
$svc = new AbdmSyncOutboxService();
|
||||
$k1 = $svc->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));
|
||||
}
|
||||
}
|
||||
81
tests/unit/Abdm/FhirGeneratorsTest.php
Normal file
81
tests/unit/Abdm/FhirGeneratorsTest.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use App\Libraries\Abdm\Fhir\FhirGeneratorFactory;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class FhirGeneratorsTest extends CIUnitTestCase
|
||||
{
|
||||
private FhirGeneratorFactory $factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
59
tests/unit/Abdm/IntegrationMappingStubTest.php
Normal file
59
tests/unit/Abdm/IntegrationMappingStubTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class IntegrationMappingStubTest extends CIUnitTestCase
|
||||
{
|
||||
public function testRecordsPushSuccessMappingStub(): void
|
||||
{
|
||||
$response = [
|
||||
'http_status' => 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']);
|
||||
}
|
||||
}
|
||||
26
tests/unit/Abdm/fixtures/discharge_source.json
Normal file
26
tests/unit/Abdm/fixtures/discharge_source.json
Normal file
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
27
tests/unit/Abdm/fixtures/invoice_source.json
Normal file
27
tests/unit/Abdm/fixtures/invoice_source.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
22
tests/unit/Abdm/fixtures/lab_source.json
Normal file
22
tests/unit/Abdm/fixtures/lab_source.json
Normal file
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
36
tests/unit/Abdm/fixtures/opd_source.json
Normal file
36
tests/unit/Abdm/fixtures/opd_source.json
Normal file
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user