feat(abdm-fhir): add dedicated Prescription and Immunization FHIR generators with NRCES validation

This commit is contained in:
DevSoftTech Server 2026-10-06 12:38:33 +05:30
parent 24819cb75c
commit 045f4c5116
5 changed files with 630 additions and 1 deletions

View File

@ -161,6 +161,12 @@ class FhirDocumentBuilder
return $this->addResource($resource);
}
/** @param array<string,mixed> $resource */
public function addImmunization(array $resource): self
{
return $this->addResource($resource);
}
/**
* @return array<string,mixed>
*/
@ -252,7 +258,7 @@ class FhirDocumentBuilder
/**
* @param array<string,mixed> $resource
*/
private function addResource(array $resource): self
public function addResource(array $resource): self
{
$id = (string) ($resource['id'] ?? '');
if ($id === '') {

View File

@ -4,9 +4,11 @@ namespace App\Libraries\Abdm\Fhir;
use App\Libraries\Abdm\Fhir\Generators\DischargeFhirGenerator;
use App\Libraries\Abdm\Fhir\Generators\HealthDocumentFhirGenerator;
use App\Libraries\Abdm\Fhir\Generators\ImmunizationFhirGenerator;
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\PrescriptionFhirGenerator;
use App\Libraries\Abdm\Fhir\Generators\RadiologyFhirGenerator;
use App\Libraries\Abdm\Fhir\Generators\WellnessFhirGenerator;
@ -42,6 +44,16 @@ class FhirGeneratorFactory
return new HealthDocumentFhirGenerator();
}
public function prescription(): PrescriptionFhirGenerator
{
return new PrescriptionFhirGenerator();
}
public function immunization(): ImmunizationFhirGenerator
{
return new ImmunizationFhirGenerator();
}
public function wellness(): WellnessFhirGenerator
{
return new WellnessFhirGenerator();

View File

@ -0,0 +1,214 @@
<?php
namespace App\Libraries\Abdm\Fhir\Generators;
class ImmunizationFhirGenerator extends 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');
$visitDate = (string) ($source['visit_date'] ?? date('Y-m-d'));
$careContextReference = 'IMMUNIZATION-' . $recordId . '-' . $visitDate;
$careContextDisplay = 'Immunization ' . $visitDate;
$builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder();
$builder
->buildBundleMeta('immunization-' . $recordId . '-' . strtotime($timestamp), $timestamp)
->addPatient($this->buildBasePatient($source));
$patientRef = 'urn:uuid:patient-' . $patientId;
$practitioner = $this->buildPractitioner($source);
if (! is_array($practitioner)) {
$fallbackDoctorName = trim((string) ($source['doctor_name'] ?? 'Dr. Attending Medical Officer'));
$docId = (string) ($source['practitioner']['id'] ?? $source['doctor']['id'] ?? '1');
$practitioner = [
'resourceType' => 'Practitioner',
'id' => 'practitioner-' . ($docId !== '' && $docId !== '0' ? $docId : '1'),
'name' => [[
'text' => $fallbackDoctorName !== '' ? $fallbackDoctorName : 'Dr. Attending Medical Officer',
]],
];
}
$builder->addPractitioner($practitioner);
$organization = $this->buildOrganization($source);
if (! is_array($organization)) {
$orgId = (string) ($source['organization']['id'] ?? $source['hfr_id'] ?? 'IN0510000871');
$orgName = (string) ($source['organization']['name'] ?? 'Hospital');
$organization = [
'resourceType' => 'Organization',
'id' => 'organization-' . ($orgId !== '' ? $orgId : 'IN0510000871'),
'name' => $orgName,
];
}
$builder->addOrganization($organization);
$immunizationRefs = [];
$resolvedCount = 0;
$unresolvedCount = 0;
$fallbackCount = 0;
foreach ((array) ($source['immunizations'] ?? $source['vaccines'] ?? []) as $idx => $imm) {
$vaccineName = trim((string) ($imm['vaccine_name'] ?? $imm['name'] ?? ''));
if (! $this->isMeaningfulValue($vaccineName)) {
continue;
}
$code = trim((string) ($imm['code'] ?? $imm['snomed_code'] ?? ''));
$coding = [];
if ($code !== '') {
$coding[] = [
'system' => 'http://snomed.info/sct',
'code' => $code,
'display' => $vaccineName,
];
$resolvedCount++;
} else {
$coding[] = [
'system' => 'http://snomed.info/sct',
'code' => '41000179103',
'display' => $vaccineName,
];
$fallbackCount++;
}
$immId = 'immunization-' . $recordId . '-' . $idx;
$immunizationRefs[] = [
'reference' => 'urn:uuid:' . $immId,
'type' => 'Immunization',
];
$immResource = [
'resourceType' => 'Immunization',
'id' => $immId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Immunization']],
'status' => 'completed',
'vaccineCode' => [
'coding' => $coding,
'text' => $vaccineName,
],
'patient' => ['reference' => $patientRef],
'occurrenceDateTime' => (string) ($imm['occurrence_date'] ?? $imm['given_date'] ?? $timestamp),
'primarySource' => true,
];
$builder->addResource($immResource);
}
$sectionEntries = $immunizationRefs;
// Support certificate / scan document reference
$docData = (string) ($source['certificate_pdf_base64'] ?? $source['document_data_base64'] ?? $source['pdf_base64'] ?? '');
if ($docData !== '') {
$docRefId = 'immunization-doc-' . $recordId;
$builder->addDocumentReference([
'resourceType' => 'DocumentReference',
'id' => $docRefId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
'status' => 'current',
'docStatus' => 'final',
'type' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '41000179103',
'display' => 'Immunization record',
]],
'text' => 'Immunization Certificate PDF',
],
'subject' => ['reference' => $patientRef],
'date' => $timestamp,
'description' => 'Vaccination Certificate PDF',
'content' => [[
'attachment' => [
'contentType' => 'application/pdf',
'language' => 'en-IN',
'data' => $docData,
'title' => 'Vaccine Certificate.pdf',
'creation' => $timestamp,
],
]],
]);
$sectionEntries[] = [
'reference' => 'urn:uuid:' . $docRefId,
'type' => 'DocumentReference',
];
}
$authorRef = 'urn:uuid:' . (string) $practitioner['id'];
$authorDisplay = (string) ($practitioner['name'][0]['text'] ?? 'Dr. Attending Medical Officer');
$custodianRef = 'urn:uuid:' . (string) $organization['id'];
$composition = [
'resourceType' => 'Composition',
'id' => 'composition-' . $recordId,
'meta' => [
'versionId' => '1',
'lastUpdated' => $timestamp,
'profile' => [
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord',
],
],
'status' => 'final',
'type' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '41000179103',
'display' => 'Immunization record',
]],
'text' => 'Immunization record',
],
'title' => 'Immunization record',
'date' => $timestamp,
'subject' => [
'reference' => $patientRef,
'display' => (string) ($source['patient']['name'] ?? 'Patient'),
],
'author' => [[
'reference' => $authorRef,
'display' => $authorDisplay,
]],
'custodian' => [
'reference' => $custodianRef,
'display' => (string) ($organization['name'] ?? 'Hospital'),
],
'section' => [
[
'title' => 'Immunization record',
'code' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '41000179103',
'display' => 'Immunization record',
]],
],
'entry' => $sectionEntries,
],
],
];
$builder->buildComposition($composition);
$bundle = $builder->toBundle();
$validation = $this->validator->validate($bundle, 'immunization', [
'resolved' => $resolvedCount,
'unresolved' => $unresolvedCount,
'fallback_used' => $fallbackCount,
]);
return [
'hi_type' => 'ImmunizationRecord',
'care_context_reference' => $careContextReference,
'care_context_display' => $careContextDisplay,
'fhir_bundle' => $bundle,
'validation' => $validation->toArray(),
];
}
}

View File

@ -0,0 +1,263 @@
<?php
namespace App\Libraries\Abdm\Fhir\Generators;
class PrescriptionFhirGenerator extends 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 = 'PRESCRIPTION-' . $recordId . ($sessionId !== '0' ? '-S' . $sessionId : '') . '-' . $visitDate;
$careContextDisplay = 'Prescription ' . $visitDate;
$builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder();
$builder
->buildBundleMeta('prescription-' . $recordId . '-' . strtotime($timestamp), $timestamp)
->addPatient($this->buildBasePatient($source));
$patientRef = 'urn:uuid:patient-' . $patientId;
$practitioner = $this->buildPractitioner($source);
if (! is_array($practitioner)) {
$fallbackDoctorName = trim((string) ($source['doctor_name'] ?? 'Dr. Attending Medical Officer'));
$docId = (string) ($source['practitioner']['id'] ?? $source['doctor']['id'] ?? '1');
$practitioner = [
'resourceType' => 'Practitioner',
'id' => 'practitioner-' . ($docId !== '' && $docId !== '0' ? $docId : '1'),
'name' => [[
'text' => $fallbackDoctorName !== '' ? $fallbackDoctorName : 'Dr. Attending Medical Officer',
]],
];
}
$builder->addPractitioner($practitioner);
$organization = $this->buildOrganization($source);
if (! is_array($organization)) {
$orgId = (string) ($source['organization']['id'] ?? $source['hfr_id'] ?? 'IN0510000871');
$orgName = (string) ($source['organization']['name'] ?? 'Hospital');
$organization = [
'resourceType' => 'Organization',
'id' => 'organization-' . ($orgId !== '' ? $orgId : 'IN0510000871'),
'name' => $orgName,
];
}
$builder->addOrganization($organization);
$encounter = $this->buildEncounter($source);
$encounterRef = null;
if (is_array($encounter)) {
$builder->addEncounter($encounter);
$encounterRef = 'urn:uuid:' . (string) $encounter['id'];
}
$medicationRefs = [];
$resolvedCount = 0;
$unresolvedCount = 0;
$fallbackCount = 0;
foreach ((array) ($source['medications'] ?? []) as $idx => $med) {
$drugName = trim((string) ($med['name'] ?? $med['drug_name'] ?? ''));
if (! $this->isMeaningfulValue($drugName)) {
continue;
}
$dosageText = trim((string) ($med['dosage'] ?? ''));
$medType = trim((string) ($med['formulation'] ?? $med['med_type'] ?? ''));
$routeText = trim((string) ($med['route_text'] ?? ''));
if (! $this->isMeaningfulValue($routeText)) {
$medTypeUpper = strtoupper($medType);
if (in_array($medTypeUpper, ['INJ', 'INJECTION', 'IV', 'IM'], true)) {
$routeText = 'Injection';
$routeCode = '47625008';
} elseif (in_array($medTypeUpper, ['CREAM', 'OINT', 'OINTMENT', 'GEL', 'LOTION'], true)) {
$routeText = 'Topical';
$routeCode = '6064005';
} else {
$routeText = 'Oral';
$routeCode = '260548002';
}
} else {
$routeCode = '260548002';
}
$dosageInstruction = [[
'text' => $dosageText,
'route' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => $routeCode,
'display' => $routeText,
]],
'text' => $routeText,
],
]];
$code = trim((string) ($med['code'] ?? $med['snomed_code'] ?? ''));
$coding = [];
if ($code !== '' && $code !== '371530004') {
$coding[] = [
'system' => 'http://snomed.info/sct',
'code' => $code,
'display' => $drugName,
];
$resolvedCount++;
} else {
$coding[] = [
'system' => 'http://snomed.info/sct',
'code' => '105904009',
'display' => $drugName,
];
$fallbackCount++;
}
$medId = 'medication-' . $recordId . '-' . $idx;
$medicationRefs[] = [
'reference' => 'urn:uuid:' . $medId,
'type' => 'MedicationRequest',
];
$medResource = [
'resourceType' => 'MedicationRequest',
'id' => $medId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/MedicationRequest']],
'status' => 'active',
'intent' => 'order',
'subject' => ['reference' => $patientRef],
'authoredOn' => $timestamp,
'requester' => ['reference' => 'urn:uuid:' . (string) $practitioner['id']],
'medicationCodeableConcept' => [
'coding' => $coding,
'text' => $drugName,
],
'dosageInstruction' => $dosageInstruction,
];
if ($encounterRef !== null) {
$medResource['encounter'] = ['reference' => $encounterRef];
}
$builder->addMedicationRequest($medResource);
}
$sectionEntries = $medicationRefs;
// Support attached scanned or digital prescription copy (Binary or DocumentReference)
$docData = (string) ($source['prescription_pdf_base64'] ?? $source['document_data_base64'] ?? $source['pdf_base64'] ?? '');
if ($docData !== '') {
$docRefId = 'prescription-doc-' . $recordId;
$builder->addDocumentReference([
'resourceType' => 'DocumentReference',
'id' => $docRefId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
'status' => 'current',
'docStatus' => 'final',
'type' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '440545006',
'display' => 'Prescription record',
]],
'text' => 'Prescription PDF',
],
'subject' => ['reference' => $patientRef],
'date' => $timestamp,
'description' => 'Prescription Copy PDF',
'content' => [[
'attachment' => [
'contentType' => 'application/pdf',
'language' => 'en-IN',
'data' => $docData,
'title' => 'Prescription.pdf',
'creation' => $timestamp,
],
]],
]);
$sectionEntries[] = [
'reference' => 'urn:uuid:' . $docRefId,
'type' => 'DocumentReference',
];
}
$authorRef = 'urn:uuid:' . (string) $practitioner['id'];
$authorDisplay = (string) ($practitioner['name'][0]['text'] ?? 'Dr. Attending Medical Officer');
$custodianRef = 'urn:uuid:' . (string) $organization['id'];
$composition = [
'resourceType' => 'Composition',
'id' => 'composition-' . $recordId,
'meta' => [
'versionId' => '1',
'lastUpdated' => $timestamp,
'profile' => [
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/PrescriptionRecord',
],
],
'status' => 'final',
'type' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '440545006',
'display' => 'Prescription record',
]],
'text' => 'Prescription record',
],
'title' => 'Prescription record',
'date' => $timestamp,
'subject' => [
'reference' => $patientRef,
'display' => (string) ($source['patient']['name'] ?? 'Patient'),
],
'author' => [[
'reference' => $authorRef,
'display' => $authorDisplay,
]],
'custodian' => [
'reference' => $custodianRef,
'display' => (string) ($organization['name'] ?? 'Hospital'),
],
'section' => [
[
'title' => 'Prescription record',
'code' => [
'coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '440545006',
'display' => 'Prescription record',
]],
],
'entry' => $sectionEntries,
],
],
];
if ($encounterRef !== null) {
$composition['encounter'] = ['reference' => $encounterRef];
}
$builder->buildComposition($composition);
$bundle = $builder->toBundle();
$validation = $this->validator->validate($bundle, 'prescription', [
'resolved' => $resolvedCount,
'unresolved' => $unresolvedCount,
'fallback_used' => $fallbackCount,
]);
return [
'hi_type' => 'PrescriptionRecord',
'care_context_reference' => $careContextReference,
'care_context_display' => $careContextDisplay,
'fhir_bundle' => $bundle,
'validation' => $validation->toArray(),
];
}
}

View File

@ -1122,6 +1122,140 @@ final class FhirGeneratorsTest extends CIUnitTestCase
$encExp = array_values(array_filter($outExp['fhir_bundle']['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'Encounter'))[0]['resource'];
$this->assertSame('exp', $encExp['hospitalization']['dischargeDisposition']['coding'][0]['code']);
}
public function testPrescriptionGeneratorHappyPath(): void
{
$src = [
'record_id' => 9001,
'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',
],
'medications' => [
[
'name' => 'Paracetamol 500mg',
'code' => '387593006',
'dosage' => '1 tablet twice daily for 3 days',
'formulation' => 'TAB',
],
[
'name' => 'Amoxicillin 500mg',
'code' => '372687004',
'dosage' => '1 capsule thrice daily after meals',
'formulation' => 'CAP',
],
],
'prescription_pdf_base64' => base64_encode('%PDF-1.4 test prescription'),
];
$out = $this->factory->prescription()->generate($src);
$this->assertSame('PrescriptionRecord', $out['hi_type']);
$this->assertSame('PRESCRIPTION-9001-S1-2026-06-27', $out['care_context_reference']);
$this->assertTrue((bool) ($out['validation']['valid'] ?? false));
$bundle = $out['fhir_bundle'];
$this->assertSame('Bundle', $bundle['resourceType']);
$this->assertSame('document', $bundle['type']);
$composition = $bundle['entry'][0]['resource'];
$this->assertSame('Composition', $composition['resourceType']);
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/PrescriptionRecord', $composition['meta']['profile']);
$this->assertSame('440545006', $composition['type']['coding'][0]['code']);
$this->assertCount(1, $composition['section']);
$this->assertSame('440545006', $composition['section'][0]['code']['coding'][0]['code']);
// Check medication requests and document reference
$medRequests = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'MedicationRequest'));
$this->assertCount(2, $medRequests);
$this->assertSame('active', $medRequests[0]['resource']['status']);
$this->assertSame('order', $medRequests[0]['resource']['intent']);
$docRefs = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'DocumentReference'));
$this->assertCount(1, $docRefs);
$this->assertSame('application/pdf', $docRefs[0]['resource']['content'][0]['attachment']['contentType']);
}
public function testImmunizationGeneratorHappyPath(): void
{
$src = [
'record_id' => 9002,
'visit_date' => '2026-06-27',
'completed_at' => '2026-06-27T10:30:00+05:30',
'patient' => [
'id' => 12,
'name' => 'Devender Singh',
'gender' => 'male',
'dob' => '1979-03-28',
'abha_id' => '1234-5678-9012',
],
'doctor' => [
'id' => 'D9',
'name' => 'Dr. R K Sundriyal',
],
'organization' => [
'id' => 'H1',
'name' => 'Chamunda Hospital',
],
'immunizations' => [
[
'vaccine_name' => 'COVID-19 vaccine',
'code' => '840534001',
'given_date' => '2026-06-27T10:15:00+05:30',
],
[
'vaccine_name' => 'Hepatitis B vaccine',
'code' => '836377005',
'given_date' => '2026-06-27T10:20:00+05:30',
],
],
'certificate_pdf_base64' => base64_encode('%PDF-1.4 vaccine certificate'),
];
$out = $this->factory->immunization()->generate($src);
$this->assertSame('ImmunizationRecord', $out['hi_type']);
$this->assertSame('IMMUNIZATION-9002-2026-06-27', $out['care_context_reference']);
$this->assertTrue((bool) ($out['validation']['valid'] ?? false));
$bundle = $out['fhir_bundle'];
$this->assertSame('Bundle', $bundle['resourceType']);
$this->assertSame('document', $bundle['type']);
$composition = $bundle['entry'][0]['resource'];
$this->assertSame('Composition', $composition['resourceType']);
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord', $composition['meta']['profile']);
$this->assertSame('41000179103', $composition['type']['coding'][0]['code']);
$this->assertCount(1, $composition['section']);
$this->assertSame('41000179103', $composition['section'][0]['code']['coding'][0]['code']);
// Check immunization resources and certificate attachment
$immResources = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'Immunization'));
$this->assertCount(2, $immResources);
$this->assertSame('completed', $immResources[0]['resource']['status']);
$this->assertTrue($immResources[0]['resource']['primarySource']);
$docRefs = array_values(array_filter($bundle['entry'], static fn (array $e): bool => ($e['resource']['resourceType'] ?? '') === 'DocumentReference'));
$this->assertCount(1, $docRefs);
$this->assertSame('application/pdf', $docRefs[0]['resource']['content'][0]['attachment']['contentType']);
}
}