From 6d851f0e9e7808d270b75a237824d813889b49f8 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Wed, 20 May 2026 03:40:40 +0530 Subject: [PATCH] ABDM compliance: fix OPConsult bundle to match NRCES IG v6.5.0 - Use UUID v4 for all resources + urn:uuid: references (not Patient/123) - Add fullUrl on every entry (required by FHIR Document bundle spec) - Fix Composition.type: LOINC 34133-9 -> SNOMED CT 371530004 (Clinical consultation report) - Add meta.profile to Bundle, Composition, Patient, Practitioner, Organization, Encounter, Condition, Observation, AllergyIntolerance, MedicationRequest, ServiceRequest, Appointment - Bundle identifier system: urn:ietf:rfc:3986 -> http://hip.in - Bundle meta.security: add V (very restricted) confidentiality - All timestamps: ISO 8601 with +05:30 offset (new isoTimestamp()) - Add generateUuid() private helper using random_bytes - Composition: add language en-IN, identifier (ndhm.in/phr), custodian, author fallback, encounter reference - Section SNOMED codes: Chief complaints 422843007, Allergies 722446000, Physical Examination 425044008, Diagnoses 439401001, Medications 721912009, Investigation Advice 721963009, Follow Up 736271009 - Separate Chief Complaints from Diagnoses sections - Encounter.diagnosis.use: SNOMED 39154008 (confirmed) / 148006 (preliminary) - Practitioner identifier: doctor.ndhm.gov.in system with MD type code - Organization identifier: facility.ndhm.gov.in system with PRN type code - Patient identifier: healthid.ndhm.gov.in (was abdm.gov.in) + MR type code - ServiceRequest/MedicationRequest: add requester from Practitioner --- app/Libraries/FhirR4Builder.php | 680 ++++++++++++++++++-------------- 1 file changed, 377 insertions(+), 303 deletions(-) diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index 19bf4c5..c519fae 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -16,201 +16,206 @@ class FhirR4Builder */ public function buildPrescriptionBundle(array $patient, array $encounter, array $medications, array $conditions = [], array $context = []): array { - $issuedAt = Time::now('Asia/Kolkata')->toDateTimeString(); - $bundleId = 'bundle-opd-' . (string) ($encounter['id'] ?? 'unknown') . '-' . date('YmdHis'); - $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown'); - $encounterRef = 'Encounter/' . (string) ($encounter['id'] ?? 'unknown'); - $practitioner = is_array($context['practitioner'] ?? null) ? (array) $context['practitioner'] : []; - $organization = is_array($context['organization'] ?? null) ? (array) $context['organization'] : []; - $observations = is_array($context['observations'] ?? null) ? (array) $context['observations'] : []; - $allergies = is_array($context['allergies'] ?? null) ? (array) $context['allergies'] : []; - $complaints = is_array($context['complaints'] ?? null) ? (array) $context['complaints'] : []; + $issuedAt = $this->isoTimestamp(); + $practitioner = is_array($context['practitioner'] ?? null) ? (array) $context['practitioner'] : []; + $organization = is_array($context['organization'] ?? null) ? (array) $context['organization'] : []; + $observations = is_array($context['observations'] ?? null) ? (array) $context['observations'] : []; + $allergies = is_array($context['allergies'] ?? null) ? (array) $context['allergies'] : []; + $complaints = is_array($context['complaints'] ?? null) ? (array) $context['complaints'] : []; $serviceRequests = is_array($context['service_requests'] ?? null) ? (array) $context['service_requests'] : []; - $appointments = is_array($context['appointments'] ?? null) ? (array) $context['appointments'] : []; + $appointments = is_array($context['appointments'] ?? null) ? (array) $context['appointments'] : []; - $practitionerId = trim((string) ($practitioner['id'] ?? '')); - $organizationId = trim((string) ($organization['id'] ?? '')); - $practitionerRef = $practitionerId !== '' ? ('Practitioner/' . $practitionerId) : ''; - $organizationRef = $organizationId !== '' ? ('Organization/' . $organizationId) : ''; + // UUID-based identity for every resource (ABDM IG v6.5.0 requirement) + $bundleUuid = $this->generateUuid(); + $compositionUuid = $this->generateUuid(); + $patientUuid = $this->generateUuid(); + $encounterUuid = $this->generateUuid(); + $hasPractitioner = trim((string) ($practitioner['id'] ?? '')) !== '' || trim((string) ($practitioner['name'] ?? '')) !== ''; + $hasOrganization = trim((string) ($organization['id'] ?? '')) !== '' || trim((string) ($organization['name'] ?? '')) !== ''; + $practitionerUuid = $hasPractitioner ? $this->generateUuid() : ''; + $organizationUuid = $hasOrganization ? $this->generateUuid() : ''; + + $patientRef = 'urn:uuid:' . $patientUuid; + $encounterRef = 'urn:uuid:' . $encounterUuid; + $practitionerRef = $hasPractitioner ? ('urn:uuid:' . $practitionerUuid) : ''; + $organizationRef = $hasOrganization ? ('urn:uuid:' . $organizationUuid) : ''; + + // ── Tracking arrays ────────────────────────────────────────────────── + $encounterDiagnosisRefs = []; + $complaintRefs = []; + $observationRefs = []; + $allergyRefs = []; + $medicationRefs = []; + $serviceRequestRefs = []; + $appointmentRefs = []; + + // Resource entries collected here; Composition prepended at the end. + $resourceEntries = []; + + // ── Practitioner ───────────────────────────────────────────────────── + if ($hasPractitioner) { + $practResource = [ + 'resourceType' => 'Practitioner', + 'id' => $practitionerUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']], + 'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]], + ]; + $regNumber = trim((string) ($practitioner['registration_number'] ?? '')); + if ($regNumber !== '') { + $practResource['identifier'] = [[ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'MD', + 'display' => 'Medical License number', + ]]], + 'system' => 'https://doctor.ndhm.gov.in', + 'value' => $regNumber, + ]]; + } + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $practitionerUuid, 'resource' => $practResource]; + } + + // ── Organization ───────────────────────────────────────────────────── + if ($hasOrganization) { + $orgResource = [ + 'resourceType' => 'Organization', + 'id' => $organizationUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']], + 'name' => trim((string) ($organization['name'] ?? '')), + ]; + $hfrId = trim((string) ($organization['hfr_id'] ?? '')); + if ($hfrId !== '') { + $orgResource['identifier'] = [[ + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'PRN', + 'display' => 'Provider number', + ]]], + 'system' => 'https://facility.ndhm.gov.in', + 'value' => $hfrId, + ]]; + } + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $organizationUuid, 'resource' => $orgResource]; + } + + // ── Patient ─────────────────────────────────────────────────────────── + $patientResource = $this->buildPatientResource($patient); + $patientResource['id'] = $patientUuid; // override DB id with UUID + $resourceEntries[] = ['fullUrl' => $patientRef, 'resource' => $patientResource]; + + // ── Encounter (built here, appended after conditions loop) ────────── $encounterResource = [ 'resourceType' => 'Encounter', - 'id' => (string) ($encounter['id'] ?? 'unknown'), - 'status' => (string) ($encounter['status'] ?? 'finished'), - 'class' => [ - 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode', - 'code' => 'AMB', + 'id' => $encounterUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter']], + 'identifier' => [['system' => 'https://ndhm.in', 'value' => (string) ($encounter['id'] ?? $encounterUuid)]], + 'status' => (string) ($encounter['status'] ?? 'finished'), + 'class' => [ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode', + 'code' => 'AMB', 'display' => 'ambulatory', ], - 'subject' => ['reference' => $patientRef], + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'period' => ['start' => trim((string) ($encounter['period_start'] ?? '')) !== '' ? (string) $encounter['period_start'] : $issuedAt], ]; if ($practitionerRef !== '') { - $encounterResource['participant'] = [[ - 'individual' => ['reference' => $practitionerRef], - ]]; + $encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]]; } if ($organizationRef !== '') { $encounterResource['serviceProvider'] = ['reference' => $organizationRef]; } - if (! empty($encounter['period_start']) || ! empty($encounter['period_end'])) { - $encounterResource['period'] = [ - 'start' => (string) ($encounter['period_start'] ?? $issuedAt), - 'end' => (string) ($encounter['period_end'] ?? $issuedAt), - ]; - } - - $conditionRefs = []; - $observationRefs = []; - $allergyRefs = []; - $medicationRefs = []; - $complaintRefs = []; - $serviceRequestRefs = []; - $appointmentRefs = []; - - $entries = [[ - 'resource' => $this->buildPatientResource($patient), - ], [ - 'resource' => $encounterResource, - ]]; - - if ($organizationRef !== '') { - $organizationResource = [ - 'resourceType' => 'Organization', - 'id' => $organizationId, - 'name' => trim((string) ($organization['name'] ?? '')), - ]; - $hfrId = trim((string) ($organization['hfr_id'] ?? '')); - if ($hfrId !== '') { - $organizationResource['identifier'] = [[ - 'system' => 'https://facility.abdm.gov.in/hfr', - 'value' => $hfrId, - ]]; - } - $entries[] = ['resource' => $organizationResource]; - } - - if ($practitionerRef !== '') { - $practitionerResource = [ - 'resourceType' => 'Practitioner', - 'id' => $practitionerId, - 'name' => [[ - 'text' => trim((string) ($practitioner['name'] ?? '')), - ]], - ]; - $regNumber = trim((string) ($practitioner['registration_number'] ?? '')); - if ($regNumber !== '') { - $practitionerResource['identifier'] = [[ - 'system' => 'https://hpr.abdm.gov.in/hpr-id', - 'value' => $regNumber, - ]]; - } - $entries[] = ['resource' => $practitionerResource]; - } + // ── Conditions (diagnoses) ──────────────────────────────────────────── foreach ($conditions as $index => $condition) { $text = trim((string) ($condition['text'] ?? '')); if ($text === '') { continue; } - $conditionId = 'cond-' . ($index + 1); - $conditionRefs[] = [ - 'condition' => [ - 'reference' => 'Condition/' . $conditionId, - ], - ]; - - $verification = trim((string) ($condition['verification_status'] ?? 'provisional')); + $condUuid = $this->generateUuid(); + $condRef = 'urn:uuid:' . $condUuid; + $verification = trim((string) ($condition['verification_status'] ?? 'provisional')); $verificationCode = strtolower($verification) === 'confirmed' ? 'confirmed' : 'provisional'; + $useSnomedCode = $verificationCode === 'confirmed' ? '39154008' : '148006'; + $useSnomedDisplay = $verificationCode === 'confirmed' ? 'Clinical diagnosis' : 'Preliminary diagnosis'; - $code = [ - 'text' => $text, + $encounterDiagnosisRefs[] = [ + 'condition' => ['reference' => $condRef, 'display' => 'Condition'], + 'use' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => $useSnomedCode, + 'display' => $useSnomedDisplay, + ]]], ]; + $code = ['text' => $text]; $snomedCode = trim((string) ($condition['snomed_code'] ?? '')); if ($snomedCode !== '') { $code['coding'] = [[ - 'system' => 'http://snomed.info/sct', - 'code' => $snomedCode, + 'system' => 'http://snomed.info/sct', + 'code' => $snomedCode, 'display' => trim((string) ($condition['snomed_display'] ?? $text)), ]]; } - $entries[] = [ - 'resource' => [ - 'resourceType' => 'Condition', - 'id' => $conditionId, - 'clinicalStatus' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', - 'code' => 'active', - ]], - ], - 'verificationStatus' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', - 'code' => $verificationCode, - ]], - ], - 'category' => [[ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-category', - 'code' => 'encounter-diagnosis', - ]], - ]], - 'code' => $code, - 'subject' => ['reference' => $patientRef], - 'encounter' => ['reference' => $encounterRef], - 'recordedDate' => $issuedAt, - ], - ]; + $resourceEntries[] = ['fullUrl' => $condRef, 'resource' => [ + 'resourceType' => 'Condition', + 'id' => $condUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']], + 'clinicalStatus' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', + 'code' => 'active', + 'display' => 'Active', + ]]], + 'verificationStatus' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', + 'code' => $verificationCode, + 'display' => ucfirst($verificationCode), + ]]], + 'code' => $code, + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + ]]; } + // Append Encounter (now has diagnosis refs) + if (! empty($encounterDiagnosisRefs)) { + $encounterResource['diagnosis'] = $encounterDiagnosisRefs; + } + $resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource]; + + // ── Complaints (Chief Complaints) ───────────────────────────────── foreach ($complaints as $index => $complaint) { $text = trim((string) ($complaint['text'] ?? '')); if ($text === '') { continue; } - $complaintId = 'complaint-' . ($index + 1); - $complaintRefs[] = ['reference' => 'Condition/' . $complaintId]; - $code = ['text' => $text]; + $complaintUuid = $this->generateUuid(); + $complaintRef = 'urn:uuid:' . $complaintUuid; + $complaintRefs[] = ['reference' => $complaintRef, 'display' => 'Condition']; + $code = ['text' => $text]; $snomedCode = trim((string) ($complaint['snomed_code'] ?? '')); if ($snomedCode !== '') { $code['coding'] = [[ - 'system' => 'http://snomed.info/sct', - 'code' => $snomedCode, + 'system' => 'http://snomed.info/sct', + 'code' => $snomedCode, 'display' => trim((string) ($complaint['snomed_display'] ?? $text)), ]]; } $complaintResource = [ - 'resourceType' => 'Condition', - 'id' => $complaintId, - 'clinicalStatus' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', - 'code' => 'active', - ]], - ], - 'verificationStatus' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', - 'code' => 'unconfirmed', - ]], - ], - 'category' => [[ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/condition-category', - 'code' => 'problem-list-item', - ]], - 'text' => 'Chief Complaint', - ]], - 'code' => $code, - 'subject' => ['reference' => $patientRef], - 'encounter' => ['reference' => $encounterRef], - 'recordedDate' => $issuedAt, + 'resourceType' => 'Condition', + 'id' => $complaintUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']], + 'clinicalStatus' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', + 'code' => 'active', + 'display' => 'Active', + ]]], + 'code' => $code, + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], ]; // Severity (High/Moderate/Low → SNOMED coded) @@ -237,54 +242,52 @@ class FhirR4Builder } // Duration → note - $durationText = trim((string) ($complaint['duration'] ?? '')); + $durationText = trim((string) ($complaint['duration'] ?? '')); $frequencyText = trim((string) ($complaint['frequency'] ?? '')); - $noteParts = array_filter([$durationText, $frequencyText]); + $noteParts = array_filter([$durationText, $frequencyText]); if (! empty($noteParts)) { $complaintResource['note'] = [['text' => implode(' | ', $noteParts)]]; } - $entries[] = ['resource' => $complaintResource]; + $resourceEntries[] = ['fullUrl' => $complaintRef, 'resource' => $complaintResource]; } + // ── Observations (Vitals / Physical Examination) ────────────────────── foreach ($observations as $index => $observation) { $value = $observation['value'] ?? null; if (! is_numeric($value)) { continue; } - $observationId = 'obs-' . ($index + 1); - $observationRefs[] = ['reference' => 'Observation/' . $observationId]; - $entries[] = [ - 'resource' => [ - 'resourceType' => 'Observation', - 'id' => $observationId, - 'status' => 'final', - 'category' => [[ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', - 'code' => 'vital-signs', - ]], + $obsUuid = $this->generateUuid(); + $observationRefs[] = ['reference' => 'urn:uuid:' . $obsUuid, 'display' => 'Observation']; + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $obsUuid, 'resource' => [ + 'resourceType' => 'Observation', + 'id' => $obsUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ObservationVitalSigns']], + 'status' => 'final', + 'category' => [['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/observation-category', + 'code' => 'vital-signs', + ]]]], + 'code' => [ + 'coding' => [[ + 'system' => 'http://loinc.org', + 'code' => (string) ($observation['loinc'] ?? ''), + 'display' => (string) ($observation['display'] ?? ''), ]], - 'code' => [ - 'coding' => [[ - 'system' => 'http://loinc.org', - 'code' => (string) ($observation['loinc'] ?? ''), - 'display' => (string) ($observation['display'] ?? ''), - ]], - 'text' => (string) ($observation['display'] ?? ''), - ], - 'subject' => ['reference' => $patientRef], - 'encounter' => ['reference' => $encounterRef], - 'effectiveDateTime' => $issuedAt, - 'valueQuantity' => [ - 'value' => (float) $value, - 'unit' => (string) ($observation['unit'] ?? ''), - 'system' => 'http://unitsofmeasure.org', - 'code' => (string) ($observation['ucum'] ?? ''), - ], + 'text' => (string) ($observation['display'] ?? ''), ], - ]; + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'encounter' => ['reference' => $encounterRef], + 'effectiveDateTime' => $issuedAt, + 'valueQuantity' => [ + 'value' => (float) $value, + 'unit' => (string) ($observation['unit'] ?? ''), + 'system' => 'http://unitsofmeasure.org', + 'code' => (string) ($observation['ucum'] ?? ''), + ], + ]]; } foreach ($allergies as $index => $allergy) { @@ -293,231 +296,280 @@ class FhirR4Builder continue; } - $allergyId = 'allergy-' . ($index + 1); - $allergyRefs[] = ['reference' => 'AllergyIntolerance/' . $allergyId]; + $allergyUuid = $this->generateUuid(); + $allergyRefs[] = ['reference' => 'urn:uuid:' . $allergyUuid, 'display' => 'AllergyIntolerance']; $allergyResource = [ - 'resourceType' => 'AllergyIntolerance', - 'id' => $allergyId, - 'clinicalStatus' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical', - 'code' => (string) ($allergy['clinical_status'] ?? 'active'), - ]], - ], - 'verificationStatus' => [ - 'coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification', - 'code' => (string) ($allergy['verification_status'] ?? 'confirmed'), - ]], - ], - 'patient' => ['reference' => $patientRef], - 'code' => [ - 'text' => $codeText, - ], + 'resourceType' => 'AllergyIntolerance', + 'id' => $allergyUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/AllergyIntolerance']], + 'clinicalStatus' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical', + 'code' => (string) ($allergy['clinical_status'] ?? 'active'), + ]]], + 'verificationStatus' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification', + 'code' => (string) ($allergy['verification_status'] ?? 'confirmed'), + ]]], + 'code' => ['text' => $codeText], + 'patient' => ['reference' => $patientRef, 'display' => 'Patient'], 'recordedDate' => $issuedAt, ]; $reaction = trim((string) ($allergy['reaction_text'] ?? '')); if ($reaction !== '') { - $allergyResource['reaction'] = [[ - 'description' => $reaction, - ]]; + $allergyResource['reaction'] = [['description' => $reaction]]; } - $entries[] = ['resource' => $allergyResource]; - } - - if (! empty($conditionRefs)) { - $entries[1]['resource']['diagnosis'] = $conditionRefs; + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $allergyUuid, 'resource' => $allergyResource]; } + // ── Medications ─────────────────────────────────────────────────────── foreach ($medications as $index => $medication) { - $entryId = 'medreq-' . ($index + 1); - $medicationRefs[] = ['reference' => 'MedicationRequest/' . $entryId]; + $medUuid = $this->generateUuid(); + $medicationRefs[] = ['reference' => 'urn:uuid:' . $medUuid, 'display' => 'MedicationRequest']; - $drugName = trim((string) ($medication['drug_name'] ?? '')); + $drugName = trim((string) ($medication['drug_name'] ?? '')); $genericName = trim((string) ($medication['generic_name'] ?? '')); - $medType = trim((string) ($medication['med_type'] ?? '')); + $medType = trim((string) ($medication['med_type'] ?? '')); $displayText = $drugName; if ($genericName !== '' && stripos($drugName, $genericName) === false) { $displayText = trim($drugName . ' (' . $genericName . ')'); } - $medicationCodeableConcept = [ - 'text' => $displayText !== '' ? $displayText : (string) ($medication['drug_name'] ?? ''), - ]; + $medicationCodeableConcept = ['text' => $displayText !== '' ? $displayText : $drugName]; $snomedCode = trim((string) ($medication['snomed_code'] ?? '')); - $atcCode = strtoupper(trim((string) ($medication['atc_code'] ?? ''))); + $atcCode = strtoupper(trim((string) ($medication['atc_code'] ?? ''))); if ($snomedCode !== '') { $medicationCodeableConcept['coding'] = [[ - 'system' => 'http://snomed.info/sct', - 'code' => $snomedCode, + 'system' => 'http://snomed.info/sct', + 'code' => $snomedCode, 'display' => $displayText !== '' ? $displayText : $drugName, ]]; } elseif ($atcCode !== '') { $medicationCodeableConcept['coding'] = [[ - 'system' => 'http://www.whocc.no/atc', - 'code' => $atcCode, + 'system' => 'http://www.whocc.no/atc', + 'code' => $atcCode, 'display' => $displayText !== '' ? $displayText : $drugName, ]]; } - $dosageInstruction = [ - 'text' => (string) ($medication['dosage'] ?? ''), - ]; - + $dosageInstruction = ['text' => (string) ($medication['dosage'] ?? '')]; $routeText = trim((string) ($medication['route_text'] ?? '')); if ($routeText !== '') { - $dosageInstruction['route'] = [ - 'text' => $routeText, - ]; + $dosageInstruction['route'] = ['text' => $routeText]; } if ($medType !== '') { - $dosageInstruction['method'] = [ - 'text' => $medType, - ]; + $dosageInstruction['method'] = ['text' => $medType]; } - $entries[] = [ - 'resource' => [ - 'resourceType' => 'MedicationRequest', - 'id' => $entryId, - 'status' => (string) ($medication['status'] ?? 'active'), - 'intent' => 'order', - 'subject' => ['reference' => $patientRef], - 'encounter' => ['reference' => $encounterRef], - 'authoredOn' => $issuedAt, - 'medicationCodeableConcept' => $medicationCodeableConcept, - 'dosageInstruction' => [$dosageInstruction], - ], + $medRes = [ + 'resourceType' => 'MedicationRequest', + 'id' => $medUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/MedicationRequest']], + 'status' => (string) ($medication['status'] ?? 'active'), + 'intent' => 'order', + 'medicationCodeableConcept' => $medicationCodeableConcept, + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'encounter' => ['reference' => $encounterRef], + 'authoredOn' => $issuedAt, + 'dosageInstruction' => [$dosageInstruction], ]; + if ($practitionerRef !== '') { + $medRes['requester'] = ['reference' => $practitionerRef]; + } + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $medUuid, 'resource' => $medRes]; } + // ── ServiceRequests (Investigation Advice) ──────────────────────────── foreach ($serviceRequests as $index => $serviceRequest) { $codeText = trim((string) ($serviceRequest['code_text'] ?? '')); if ($codeText === '') { continue; } - $serviceRequestId = 'svc-' . ($index + 1); - $serviceRequestRefs[] = ['reference' => 'ServiceRequest/' . $serviceRequestId]; - $entries[] = [ - 'resource' => [ - 'resourceType' => 'ServiceRequest', - 'id' => $serviceRequestId, - 'status' => (string) ($serviceRequest['status'] ?? 'active'), - 'intent' => (string) ($serviceRequest['intent'] ?? 'order'), - 'subject' => ['reference' => $patientRef], - 'encounter' => ['reference' => $encounterRef], - 'authoredOn' => $issuedAt, - 'code' => [ - 'text' => $codeText, - ], - ], + $svcUuid = $this->generateUuid(); + $serviceRequestRefs[] = ['reference' => 'urn:uuid:' . $svcUuid, 'display' => 'ServiceRequest']; + $svcRes = [ + 'resourceType' => 'ServiceRequest', + 'id' => $svcUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ServiceRequest']], + 'status' => (string) ($serviceRequest['status'] ?? 'active'), + 'intent' => (string) ($serviceRequest['intent'] ?? 'order'), + 'code' => ['text' => $codeText], + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'encounter' => ['reference' => $encounterRef], + 'authoredOn' => $issuedAt, ]; + if ($practitionerRef !== '') { + $svcRes['requester'] = ['reference' => $practitionerRef]; + } + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $svcUuid, 'resource' => $svcRes]; } + // ── Appointments (Follow Up) ────────────────────────────────────────── foreach ($appointments as $index => $appointment) { $description = trim((string) ($appointment['description'] ?? '')); if ($description === '') { continue; } - $appointmentId = 'appt-' . ($index + 1); - $appointmentRefs[] = ['reference' => 'Appointment/' . $appointmentId]; - $appointmentResource = [ + $apptUuid = $this->generateUuid(); + $appointmentRefs[] = ['reference' => 'urn:uuid:' . $apptUuid, 'display' => 'Appointment']; + $apptRes = [ 'resourceType' => 'Appointment', - 'id' => $appointmentId, - 'status' => (string) ($appointment['status'] ?? 'proposed'), - 'description' => $description, - 'participant' => [[ - 'actor' => ['reference' => $patientRef], + 'id' => $apptUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Appointment']], + 'status' => (string) ($appointment['status'] ?? 'proposed'), + 'description' => $description, + 'participant' => [[ + 'actor' => ['reference' => $patientRef, 'display' => 'Patient'], 'status' => 'accepted', ]], ]; if ($practitionerRef !== '') { - $appointmentResource['participant'][] = [ - 'actor' => ['reference' => $practitionerRef], + $apptRes['participant'][] = [ + 'actor' => ['reference' => $practitionerRef], 'status' => 'accepted', ]; } - $entries[] = ['resource' => $appointmentResource]; + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $apptUuid, 'resource' => $apptRes]; } + // ── Composition sections (ABDM SNOMED section codes) ───────────────── $compositionSections = []; - if (! empty($conditionRefs) || ! empty($complaintRefs)) { + if (! empty($complaintRefs)) { $compositionSections[] = [ - 'title' => 'Problems and Diagnoses', - 'entry' => array_values(array_merge($conditionRefs, $complaintRefs)), - ]; - } - if (! empty($observationRefs)) { - $compositionSections[] = [ - 'title' => 'Vitals', - 'entry' => $observationRefs, + 'title' => 'Chief complaints', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '422843007', + 'display' => 'Chief complaint section', + ]]], + 'entry' => $complaintRefs, ]; } if (! empty($allergyRefs)) { $compositionSections[] = [ 'title' => 'Allergies', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '722446000', + 'display' => 'Allergy record', + ]]], 'entry' => $allergyRefs, ]; } + if (! empty($observationRefs)) { + $compositionSections[] = [ + 'title' => 'Physical Examination', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '425044008', + 'display' => 'Physical exam section', + ]]], + 'entry' => $observationRefs, + ]; + } + if (! empty($encounterDiagnosisRefs)) { + // Diagnoses section — conditions referenced from Encounter.diagnosis + $diagSectionEntries = array_map(static fn ($d) => $d['condition'], $encounterDiagnosisRefs); + $compositionSections[] = [ + 'title' => 'Problems and Diagnoses', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '439401001', + 'display' => 'Diagnosis', + ]]], + 'entry' => $diagSectionEntries, + ]; + } if (! empty($medicationRefs)) { $compositionSections[] = [ 'title' => 'Medications', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '721912009', + 'display' => 'Medication summary document', + ]]], 'entry' => $medicationRefs, ]; } if (! empty($serviceRequestRefs)) { $compositionSections[] = [ - 'title' => 'Investigations', + 'title' => 'Investigation Advice', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '721963009', + 'display' => 'Order document', + ]]], 'entry' => $serviceRequestRefs, ]; } if (! empty($appointmentRefs)) { $compositionSections[] = [ 'title' => 'Follow Up', + 'code' => ['coding' => [[ + 'system' => 'http://snomed.info/sct', + 'code' => '736271009', + 'display' => 'Outpatient care plan', + ]]], 'entry' => $appointmentRefs, ]; } + // ── Composition (first entry per ABDM spec) ─────────────────────────── $composition = [ 'resourceType' => 'Composition', - 'id' => 'composition-' . (string) ($encounter['id'] ?? 'unknown'), - 'status' => 'final', - 'type' => [ + 'id' => $compositionUuid, + 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/OPConsultRecord']], + 'language' => 'en-IN', + 'identifier' => ['system' => 'https://ndhm.in/phr', 'value' => $compositionUuid], + 'status' => 'final', + 'type' => [ 'coding' => [[ - 'system' => 'http://loinc.org', - 'code' => '34133-9', - 'display' => 'Summarization of Episode Note', + 'system' => 'http://snomed.info/sct', + 'code' => '371530004', + 'display' => 'Clinical consultation report', ]], - 'text' => 'OP Consultation Record', + 'text' => 'Clinical Consultation report', ], - 'subject' => ['reference' => $patientRef], - 'encounter' => ['reference' => $encounterRef], - 'date' => $issuedAt, - 'title' => 'OP Consultation and Prescription', - 'section' => $compositionSections, + 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], + 'encounter' => ['reference' => $encounterRef, 'display' => 'Encounter'], + 'date' => $issuedAt, + 'author' => $practitionerRef !== '' + ? [['reference' => $practitionerRef, 'display' => 'Practitioner']] + : [['display' => 'Unknown']], + 'title' => 'Consultation Report', + 'section' => $compositionSections, ]; - if ($practitionerRef !== '') { - $composition['author'] = [[ - 'reference' => $practitionerRef, - ]]; + if ($organizationRef !== '') { + $composition['custodian'] = ['reference' => $organizationRef, 'display' => 'Organization']; } - $entries[] = ['resource' => $composition]; + // Composition first, then all resource entries + $allEntries = array_merge( + [['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]], + $resourceEntries + ); + + // ── Bundle ──────────────────────────────────────────────────────────── return [ 'resourceType' => 'Bundle', - 'identifier' => [ - 'system' => 'urn:ietf:rfc:3986', - 'value' => 'urn:uuid:' . $bundleId, + 'id' => $bundleUuid, + 'meta' => [ + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle'], + 'security' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-Confidentiality', + 'code' => 'V', + 'display' => 'very restricted', + ]], ], - 'type' => 'document', - 'timestamp' => $issuedAt, - 'entry' => $entries, + 'identifier' => ['system' => 'http://hip.in', 'value' => $bundleUuid], + 'type' => 'document', + 'timestamp' => $issuedAt, + 'entry' => $allEntries, ]; } @@ -717,6 +769,22 @@ class FhirR4Builder ]; } + /** Generate a cryptographically random UUID v4. */ + private function generateUuid(): string + { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + + /** ISO 8601 timestamp with India Standard Time offset (+05:30). */ + private function isoTimestamp(): string + { + return (new \DateTime('now', new \DateTimeZone('Asia/Kolkata')))->format('Y-m-d\TH:i:sP'); + } + /** * @param array $patient * @@ -742,9 +810,15 @@ class FhirR4Builder $abhaAddress = trim((string) ($patient['abhaAddress'] ?? '')); if ($abhaAddress !== '') { + $resource['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient']]; $resource['identifier'] = [[ - 'system' => 'https://healthid.abdm.gov.in/abha-address', - 'value' => $abhaAddress, + 'type' => ['coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', + 'code' => 'MR', + 'display' => 'Medical record number', + ]]], + 'system' => 'https://healthid.ndhm.gov.in', + 'value' => $abhaAddress, ]]; }