ABDM M2: add push-sync outbox, worker, and FHIR generators

This commit is contained in:
DevSoftTech Server 2026-06-27 01:53:44 +05:30
parent e651b18a01
commit ed48a447e5
31 changed files with 3405 additions and 0 deletions

View 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.

View 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));
}
}

View File

@ -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]);

View 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.',
]);
}
}

View File

@ -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);
}
}

View 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;
}
}

View 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();
}
}

View File

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

View 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(),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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(),
];
}
}

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

View 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' => '',
];
}
}

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

View 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 : '';
}
}

View 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 : '');
}
}

View 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 : '';
}
}

View 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 : '';
}
}

View 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';
}

View 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';
}

View 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';
}

View 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));
}
}

View 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));
}
}

View 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']);
}
}

View 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']);
}
}

View 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"}
]
}

View 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"
}
}

View 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"}
]
}

View 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"}
]
}