4030 lines
182 KiB
PHP
4030 lines
182 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries;
|
|
|
|
use CodeIgniter\I18n\Time;
|
|
|
|
class FhirR4Builder
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $patient
|
|
* @param array<string, mixed> $encounter
|
|
* @param array<int, array<string, mixed>> $medications
|
|
* @param array<int, array<string, mixed>> $conditions
|
|
* @param array<string, mixed> $context
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildPrescriptionBundle(array $patient, array $encounter, array $medications, array $conditions = [], array $context = []): array
|
|
{
|
|
$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'] : [];
|
|
$attachments = is_array($context['attachments'] ?? null) ? (array) $context['attachments'] : [];
|
|
|
|
// 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 = [];
|
|
$documentRefs = [];
|
|
|
|
// Resource entries collected here; Composition prepended at the end.
|
|
$resourceEntries = [];
|
|
|
|
// ── Practitioner ─────────────────────────────────────────────────────
|
|
if ($hasPractitioner) {
|
|
$practRawName = trim((string) ($practitioner['name'] ?? ''));
|
|
$practNameEntry = ['text' => $practRawName];
|
|
// Split "Dr. Ramesh Sharma" → prefix=["Dr."], given=["Ramesh"], family="Sharma"
|
|
$practParts = preg_split('/\s+/', $practRawName, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
|
if (count($practParts) >= 2) {
|
|
if (preg_match('/^(Dr\.?|Prof\.?|Mr\.?|Mrs\.?|Ms\.?)$/i', $practParts[0])) {
|
|
$practNameEntry['prefix'] = [array_shift($practParts)];
|
|
}
|
|
if (count($practParts) >= 1) {
|
|
$practNameEntry['family'] = (string) array_pop($practParts);
|
|
}
|
|
if (! empty($practParts)) {
|
|
$practNameEntry['given'] = $practParts;
|
|
}
|
|
}
|
|
$practResource = [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practitionerUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
|
|
'name' => [$practNameEntry],
|
|
];
|
|
$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) {
|
|
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $organizationUuid, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)];
|
|
}
|
|
|
|
// ── 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' => $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, 'display' => 'Patient'],
|
|
'period' => ['start' => $this->normalizeIsoDateTime((string) ($encounter['period_start'] ?? ''), $issuedAt)],
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$encounterResource['serviceProvider'] = ['reference' => $organizationRef];
|
|
}
|
|
|
|
// ── Conditions (diagnoses) ────────────────────────────────────────────
|
|
foreach ($conditions as $index => $condition) {
|
|
$text = trim((string) ($condition['text'] ?? ''));
|
|
if ($text === '') {
|
|
continue;
|
|
}
|
|
|
|
$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';
|
|
|
|
$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,
|
|
'display' => trim((string) ($condition['snomed_display'] ?? $text)),
|
|
]];
|
|
}
|
|
|
|
$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'],
|
|
'recordedDate' => $issuedAt,
|
|
]];
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
$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,
|
|
'display' => trim((string) ($complaint['snomed_display'] ?? $text)),
|
|
]];
|
|
}
|
|
|
|
$complaintResource = [
|
|
'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'],
|
|
'recordedDate' => $issuedAt,
|
|
];
|
|
|
|
// Severity (High/Moderate/Low → SNOMED coded)
|
|
$severityText = trim((string) ($complaint['severity'] ?? ''));
|
|
if ($severityText !== '') {
|
|
$severityMap = [
|
|
'high' => ['24484000', 'Severe'],
|
|
'severe' => ['24484000', 'Severe'],
|
|
'moderate' => ['6736007', 'Moderate'],
|
|
'mild' => ['255604002', 'Mild'],
|
|
'low' => ['255604002', 'Mild'],
|
|
];
|
|
$severityKey = strtolower($severityText);
|
|
[$sevCode, $sevDisplay] = $severityMap[$severityKey] ?? ['', $severityText];
|
|
$severityCoding = ['text' => ucfirst($severityText)];
|
|
if ($sevCode !== '') {
|
|
$severityCoding['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $sevCode,
|
|
'display' => $sevDisplay,
|
|
]];
|
|
}
|
|
$complaintResource['severity'] = $severityCoding;
|
|
}
|
|
|
|
// Duration → note
|
|
$durationText = trim((string) ($complaint['duration'] ?? ''));
|
|
$frequencyText = trim((string) ($complaint['frequency'] ?? ''));
|
|
$noteParts = array_filter([$durationText, $frequencyText]);
|
|
if (! empty($noteParts)) {
|
|
$complaintResource['note'] = [['text' => implode(' | ', $noteParts)]];
|
|
}
|
|
|
|
$resourceEntries[] = ['fullUrl' => $complaintRef, 'resource' => $complaintResource];
|
|
}
|
|
|
|
// ── Observations (Vitals / Physical Examination) ──────────────────────
|
|
foreach ($observations as $index => $observation) {
|
|
$value = $observation['value'] ?? null;
|
|
if (! is_numeric($value)) {
|
|
continue;
|
|
}
|
|
|
|
$num = (float) $value;
|
|
$loinc = (string) ($observation['loinc'] ?? '');
|
|
$display = (string) ($observation['display'] ?? '');
|
|
$unit = (string) ($observation['unit'] ?? '');
|
|
if ($loinc === '8310-5' || strcasecmp($display, 'Body temperature') === 0 || in_array($unit, ['Cel', 'degF', '[degF]'], true)) {
|
|
$num = round($num, 1);
|
|
} elseif ($loinc === '39156-5' || strcasecmp($display, 'Body mass index') === 0) {
|
|
$num = round($num, 1);
|
|
} elseif ($loinc === '29463-7' || strcasecmp($display, 'Body weight') === 0) {
|
|
$num = round($num, 2);
|
|
} elseif ($loinc === '8302-2' || strcasecmp($display, 'Body height') === 0) {
|
|
$num = round($num, 1);
|
|
} elseif (in_array($loinc, ['8867-4', '59408-5', '8480-6', '8462-4', '9279-1'], true)) {
|
|
$num = (float) round($num);
|
|
} else {
|
|
$num = round($num, 2);
|
|
}
|
|
|
|
$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' => $loinc,
|
|
'display' => $display,
|
|
]],
|
|
'text' => $display,
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'effectiveDateTime' => $issuedAt,
|
|
'valueQuantity' => [
|
|
'value' => $num,
|
|
'unit' => $unit,
|
|
'system' => 'http://unitsofmeasure.org',
|
|
'code' => (string) ($observation['ucum'] ?? ''),
|
|
],
|
|
]];
|
|
}
|
|
|
|
foreach ($allergies as $index => $allergy) {
|
|
$codeText = trim((string) ($allergy['code_text'] ?? ''));
|
|
if ($codeText === '') {
|
|
continue;
|
|
}
|
|
|
|
$allergyUuid = $this->generateUuid();
|
|
$allergyRefs[] = ['reference' => 'urn:uuid:' . $allergyUuid, 'display' => 'AllergyIntolerance'];
|
|
|
|
$allergyResource = [
|
|
'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]];
|
|
}
|
|
|
|
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $allergyUuid, 'resource' => $allergyResource];
|
|
}
|
|
|
|
// ── Medications ───────────────────────────────────────────────────────
|
|
foreach ($medications as $index => $medication) {
|
|
$medUuid = $this->generateUuid();
|
|
$medicationRefs[] = ['reference' => 'urn:uuid:' . $medUuid, 'display' => 'MedicationRequest'];
|
|
|
|
$drugName = trim((string) ($medication['drug_name'] ?? ''));
|
|
$genericName = trim((string) ($medication['generic_name'] ?? ''));
|
|
$medType = trim((string) ($medication['med_type'] ?? ''));
|
|
$displayText = $drugName;
|
|
if ($genericName !== '' && stripos($drugName, $genericName) === false) {
|
|
$displayText = trim($drugName . ' (' . $genericName . ')');
|
|
}
|
|
|
|
$medicationCodeableConcept = ['text' => $displayText !== '' ? $displayText : $drugName];
|
|
|
|
$snomedCode = trim((string) ($medication['snomed_code'] ?? ''));
|
|
$atcCode = strtoupper(trim((string) ($medication['atc_code'] ?? '')));
|
|
if ($this->isMeaningfulValue($snomedCode) && $snomedCode !== '371530004') {
|
|
$medicationCodeableConcept['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $snomedCode,
|
|
'display' => $displayText !== '' ? $displayText : $drugName,
|
|
]];
|
|
} elseif ($this->isMeaningfulValue($atcCode)) {
|
|
$medicationCodeableConcept['coding'] = [[
|
|
'system' => 'http://www.whocc.no/atc',
|
|
'code' => $atcCode,
|
|
'display' => $displayText !== '' ? $displayText : $drugName,
|
|
]];
|
|
} else {
|
|
$medicationCodeableConcept['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '105904009',
|
|
'display' => $displayText !== '' ? $displayText : $drugName,
|
|
]];
|
|
}
|
|
|
|
$dosageInstruction = ['text' => (string) ($medication['dosage'] ?? '')];
|
|
$routeText = trim((string) ($medication['route_text'] ?? ''));
|
|
$routeCode = trim((string) ($medication['route_code'] ?? ''));
|
|
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';
|
|
}
|
|
} elseif ($routeCode === '') {
|
|
$routeCode = $this->resolveRouteSnomedCode($routeText);
|
|
}
|
|
|
|
$routeEntry = ['text' => $routeText];
|
|
if ($this->isMeaningfulValue($routeCode) && $routeCode !== '371530004') {
|
|
$routeEntry['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $routeCode,
|
|
'display' => $routeText,
|
|
]];
|
|
} else {
|
|
$routeEntry['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '260548002',
|
|
'display' => $routeText,
|
|
]];
|
|
}
|
|
$dosageInstruction['route'] = $routeEntry;
|
|
|
|
$methodText = trim((string) ($medication['method_text'] ?? $medType));
|
|
$methodCode = trim((string) ($medication['method_code'] ?? ''));
|
|
if ($this->isMeaningfulValue($methodText)) {
|
|
$methodEntry = ['text' => $methodText];
|
|
if ($this->isMeaningfulValue($methodCode) && $methodCode !== '371530004') {
|
|
$methodEntry['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $methodCode,
|
|
'display' => $methodText,
|
|
]];
|
|
}
|
|
$dosageInstruction['method'] = $methodEntry;
|
|
}
|
|
|
|
$freqText = trim((string) ($medication['frequency_text'] ?? ''));
|
|
$dosageText = (string) ($medication['dosage'] ?? '');
|
|
$timingResult = $this->buildFhirTimingStructure($freqText, $dosageText);
|
|
if ($timingResult['timing'] !== null) {
|
|
$dosageInstruction['timing'] = $timingResult['timing'];
|
|
}
|
|
|
|
$medRes = [
|
|
'resourceType' => 'MedicationRequest',
|
|
'id' => $medUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/MedicationRequest']],
|
|
'status' => (string) ($medication['status'] ?? 'active'),
|
|
'intent' => 'order',
|
|
'category' => [[
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/medicationrequest-category',
|
|
'code' => 'outpatient',
|
|
'display' => 'Outpatient',
|
|
]],
|
|
]],
|
|
'medicationCodeableConcept' => $medicationCodeableConcept,
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'authoredOn' => $issuedAt,
|
|
'dosageInstruction' => [$dosageInstruction],
|
|
];
|
|
if ($timingResult['asNeeded']) {
|
|
$medRes['asNeededBoolean'] = true;
|
|
}
|
|
if ($practitionerRef !== '') {
|
|
$medRes['requester'] = ['reference' => $practitionerRef, 'display' => $practRawName !== '' ? $practRawName : 'Practitioner'];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $medUuid, 'resource' => $medRes];
|
|
}
|
|
|
|
// ── ServiceRequests (Investigation Advice) ────────────────────────────
|
|
foreach ($serviceRequests as $index => $serviceRequest) {
|
|
$codeText = trim((string) ($serviceRequest['code_text'] ?? ''));
|
|
if ($codeText === '') {
|
|
continue;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
$apptUuid = $this->generateUuid();
|
|
$appointmentRefs[] = ['reference' => 'urn:uuid:' . $apptUuid, 'display' => 'Appointment'];
|
|
|
|
$apptStartIso = $issuedAt;
|
|
if (preg_match('/(\d{2}-\d{2}-\d{4})/', $description, $m) === 1) {
|
|
$parsed = \DateTimeImmutable::createFromFormat('d-m-Y', $m[1]);
|
|
if ($parsed !== false) {
|
|
$apptStartIso = $parsed->setTime(10, 0, 0)->format(DATE_ATOM);
|
|
}
|
|
} elseif (preg_match('/(\d{4}-\d{2}-\d{2})/', $description, $m) === 1) {
|
|
$parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $m[1]);
|
|
if ($parsed !== false) {
|
|
$apptStartIso = $parsed->setTime(10, 0, 0)->format(DATE_ATOM);
|
|
}
|
|
}
|
|
|
|
$apptRes = [
|
|
'resourceType' => 'Appointment',
|
|
'id' => $apptUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Appointment']],
|
|
'status' => (string) ($appointment['status'] ?? 'proposed'),
|
|
'description' => $description,
|
|
'start' => (string) ($appointment['start'] ?? $apptStartIso),
|
|
'end' => (string) ($appointment['end'] ?? $apptStartIso),
|
|
'participant' => [[
|
|
'actor' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'status' => 'accepted',
|
|
]],
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$apptRes['participant'][] = [
|
|
'actor' => ['reference' => $practitionerRef, 'display' => $practRawName !== '' ? $practRawName : 'Practitioner'],
|
|
'status' => 'accepted',
|
|
];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $apptUuid, 'resource' => $apptRes];
|
|
}
|
|
|
|
// ── OPD scan/upload attachments (DocumentReference + Binary) ─────────
|
|
foreach ($attachments as $index => $attachment) {
|
|
if (! is_array($attachment)) {
|
|
continue;
|
|
}
|
|
|
|
$dataBase64 = trim((string) ($attachment['data_base64'] ?? ''));
|
|
if ($dataBase64 === '') {
|
|
continue;
|
|
}
|
|
|
|
$contentType = trim((string) ($attachment['content_type'] ?? 'application/octet-stream'));
|
|
$title = trim((string) ($attachment['title'] ?? 'OPD scanned document'));
|
|
$date = $this->normalizeIsoDateTime((string) ($attachment['date'] ?? ''), $issuedAt);
|
|
$attachmentId = preg_replace('/[^A-Za-z0-9\-\.]/', '-', trim((string) ($attachment['id'] ?? '')));
|
|
if ($attachmentId === '' || $attachmentId === null) {
|
|
$attachmentId = (string) ($index + 1);
|
|
}
|
|
|
|
if (str_starts_with($attachmentId, 'prescription-') || str_starts_with($attachmentId, 'doc-') || $contentType === 'application/pdf') {
|
|
$docUuid = str_starts_with($attachmentId, 'doc-') ? $attachmentId : 'doc-' . $attachmentId;
|
|
} else {
|
|
$docUuid = 'opd-scan-' . $attachmentId . '-' . $this->generateUuid();
|
|
}
|
|
$binaryUuid = 'binary-' . $docUuid;
|
|
$docRef = 'urn:uuid:' . $docUuid;
|
|
$binaryRef = 'urn:uuid:' . $binaryUuid;
|
|
|
|
$documentRefs[] = ['reference' => $docRef, 'display' => 'DocumentReference'];
|
|
$resourceEntries[] = ['fullUrl' => $binaryRef, 'resource' => [
|
|
'resourceType' => 'Binary',
|
|
'id' => $binaryUuid,
|
|
'contentType' => $contentType,
|
|
'data' => $dataBase64,
|
|
]];
|
|
$resourceEntries[] = ['fullUrl' => $docRef, 'resource' => [
|
|
'resourceType' => 'DocumentReference',
|
|
'id' => $docUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
|
|
'status' => 'current',
|
|
'type' => ['text' => $title],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'date' => $date,
|
|
'content' => [[
|
|
'attachment' => [
|
|
'contentType' => $contentType,
|
|
'url' => $binaryRef,
|
|
'data' => $dataBase64,
|
|
'title' => $title,
|
|
],
|
|
]],
|
|
]];
|
|
}
|
|
|
|
// ── Composition sections (ABDM SNOMED section codes) ─────────────────
|
|
$compositionSections = [];
|
|
if (! empty($complaintRefs)) {
|
|
$compositionSections[] = [
|
|
'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' => '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,
|
|
];
|
|
}
|
|
if (! empty($documentRefs)) {
|
|
$compositionSections[] = [
|
|
'title' => 'Document Reference',
|
|
'code' => ['coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '371530004',
|
|
'display' => 'Clinical consultation report',
|
|
]]],
|
|
'entry' => $documentRefs,
|
|
];
|
|
}
|
|
|
|
// ── Composition (first entry per ABDM spec) ───────────────────────────
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => $compositionUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/PrescriptionRecord']],
|
|
'language' => 'en-IN',
|
|
'identifier' => ['system' => 'https://ndhm.in/phr', 'value' => $compositionUuid],
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '371530004',
|
|
'display' => 'Clinical consultation report',
|
|
]],
|
|
'text' => 'Clinical Consultation report',
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'encounter' => ['reference' => $encounterRef, 'display' => 'Encounter'],
|
|
'date' => $issuedAt,
|
|
'author' => $practitionerRef !== ''
|
|
? [['reference' => $practitionerRef, 'display' => 'Practitioner']]
|
|
: [['display' => 'Unknown']],
|
|
'title' => 'Prescription Record',
|
|
'section' => $compositionSections,
|
|
];
|
|
if ($organizationRef !== '') {
|
|
$composition['custodian'] = ['reference' => $organizationRef, 'display' => 'Organization'];
|
|
}
|
|
|
|
// Composition first, then all resource entries
|
|
$allEntries = array_merge(
|
|
[['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]],
|
|
$resourceEntries
|
|
);
|
|
|
|
// ── Bundle ────────────────────────────────────────────────────────────
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
|
$bundleIdSystem = $hfrId !== ''
|
|
? 'https://' . strtolower(preg_replace('/[^A-Za-z0-9]/', '', $hfrId)) . '.hfr.abdm.gov.in'
|
|
: 'https://hfr.abdm.gov.in';
|
|
$bundleIdValue = $hfrId !== ''
|
|
? 'PRESCRIPTION-' . ($encounter['id'] ?? $bundleUuid) . '-' . date('Y-m-d')
|
|
: $bundleUuid;
|
|
|
|
return $this->sanitizeBundle([
|
|
'resourceType' => 'Bundle',
|
|
'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',
|
|
]],
|
|
],
|
|
'identifier' => ['system' => $bundleIdSystem, 'value' => $bundleIdValue],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => $allEntries,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Build a ABDM-compliant DiagnosticReportRecord FHIR DocumentBundle.
|
|
*
|
|
* Supports:
|
|
* - LOINC-coded observations (valueQuantity) for structured lab results
|
|
* - HTML report as presentedForm via Binary + DocumentReference
|
|
* - Optional Practitioner, Organization, Encounter
|
|
*
|
|
* @param array<string, mixed> $patient {id, name, given_name, family_name, gender, birthDate, abhaAddress, phone}
|
|
* @param array<string, mixed> $diagnosticReport {id, title, category_snomed_code, category_snomed_display,
|
|
* loinc_code, loinc_display, status, conclusion,
|
|
* reported_at, report_html, report_content_type}
|
|
* @param array<int, array<string, mixed>> $observations Each: {test_name, loinc_code, value, value_type (quantity|string),
|
|
* unit, ucum_code, ref_low, ref_high, interpretation, status}
|
|
* @param array<string, mixed>|null $practitioner {name, registration_number}
|
|
* @param array<string, mixed>|null $organization {name, hfr_id}
|
|
* @param array<string, mixed>|null $encounter {id, status, class_code (AMB|IMP|EMER), period_start, period_end}
|
|
* @param array<string, mixed>|null $attachment Optional PDF attachment {content_type, data_base64, title}
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildLabReportBundle(
|
|
array $patient,
|
|
array $diagnosticReport,
|
|
array $observations = [],
|
|
?array $practitioner = null,
|
|
?array $organization = null,
|
|
?array $encounter = null,
|
|
?array $attachment = null
|
|
): array {
|
|
$issuedAt = $this->isoTimestamp();
|
|
$bundleUuid = $this->generateUuid();
|
|
$compositionUuid = $this->generateUuid();
|
|
$patientUuid = $this->generateUuid();
|
|
$reportUuid = $this->generateUuid();
|
|
|
|
$patientRef = 'urn:uuid:' . $patientUuid;
|
|
$reportRef = 'urn:uuid:' . $reportUuid;
|
|
$reportedAt = $this->normalizeIsoDateTime((string) ($diagnosticReport['reported_at'] ?? ''), $issuedAt);
|
|
$reportTitle = trim((string) ($diagnosticReport['title'] ?? 'Diagnostic Report'));
|
|
$reportStatus = trim((string) ($diagnosticReport['status'] ?? 'final'));
|
|
$isImaging = (bool) ($diagnosticReport['is_imaging'] ?? false)
|
|
|| strtolower(trim((string) ($diagnosticReport['report_domain'] ?? ''))) === 'imaging';
|
|
|
|
$hasPractitioner = $practitioner !== null && trim((string) ($practitioner['name'] ?? '')) !== '';
|
|
$hasOrganization = $organization !== null && trim((string) ($organization['name'] ?? '')) !== '';
|
|
$hasEncounter = $encounter !== null;
|
|
$practitionerUuid = $hasPractitioner ? $this->generateUuid() : '';
|
|
$organizationUuid = $hasOrganization ? $this->generateUuid() : '';
|
|
$encounterUuid = $hasEncounter ? $this->generateUuid() : '';
|
|
|
|
$practitionerRef = $hasPractitioner ? ('urn:uuid:' . $practitionerUuid) : '';
|
|
$organizationRef = $hasOrganization ? ('urn:uuid:' . $organizationUuid) : '';
|
|
$encounterRef = $hasEncounter ? ('urn:uuid:' . $encounterUuid) : '';
|
|
|
|
$resourceEntries = [];
|
|
|
|
// ── Practitioner ──────────────────────────────────────────────────────
|
|
if ($hasPractitioner) {
|
|
$practRes = [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practitionerUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
|
|
'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]],
|
|
];
|
|
$regNo = trim((string) ($practitioner['registration_number'] ?? ''));
|
|
if ($regNo !== '') {
|
|
$practRes['identifier'] = [[
|
|
'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'MD', 'display' => 'Medical License number']]],
|
|
'system' => 'https://doctor.ndhm.gov.in',
|
|
'value' => $regNo,
|
|
]];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => $practitionerRef, 'resource' => $practRes];
|
|
}
|
|
|
|
// ── Organization ──────────────────────────────────────────────────────
|
|
if ($hasOrganization) {
|
|
$resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)];
|
|
}
|
|
|
|
// ── Patient ───────────────────────────────────────────────────────────
|
|
$patientResource = $this->buildPatientResource($patient);
|
|
$patientResource['id'] = $patientUuid;
|
|
$resourceEntries[] = ['fullUrl' => $patientRef, 'resource' => $patientResource];
|
|
|
|
// ── Encounter ─────────────────────────────────────────────────────────
|
|
if ($hasEncounter) {
|
|
$encounterRes = [
|
|
'resourceType' => 'Encounter',
|
|
'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' => (string) ($encounter['class_code'] ?? 'AMB'),
|
|
'display' => (string) ($encounter['class_code'] ?? 'AMB') === 'IMP' ? 'inpatient encounter' : 'Ambulatory',
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
];
|
|
$pStartRaw = trim((string) ($encounter['period_start'] ?? ''));
|
|
if ($pStartRaw !== '') {
|
|
$encounterRes['period'] = ['start' => $this->normalizeIsoDateTime($pStartRaw, $issuedAt)];
|
|
$pEndRaw = trim((string) ($encounter['period_end'] ?? ''));
|
|
if ($pEndRaw !== '') {
|
|
$pEndIso = $this->normalizeIsoDateTime($pEndRaw);
|
|
if ($pEndIso !== '') {
|
|
$encounterRes['period']['end'] = $pEndIso;
|
|
}
|
|
}
|
|
}
|
|
if ($practitionerRef !== '') {
|
|
$encounterRes['participant'] = [['individual' => ['reference' => $practitionerRef]]];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$encounterRes['serviceProvider'] = ['reference' => $organizationRef];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterRes];
|
|
}
|
|
|
|
// ── Observations (structured lab results) ─────────────────────────────
|
|
$observationEntries = [];
|
|
$observationRefs = [];
|
|
foreach ($observations as $obs) {
|
|
$obsUuid = $this->generateUuid();
|
|
$obsRef = 'urn:uuid:' . $obsUuid;
|
|
$observationRefs[] = ['reference' => $obsRef];
|
|
$testName = trim((string) ($obs['test_name'] ?? ''));
|
|
$loincCode = trim((string) ($obs['loinc_code'] ?? ''));
|
|
$codeEntry = ['text' => $testName];
|
|
if ($loincCode !== '') {
|
|
$codeEntry['coding'] = [['system' => 'http://loinc.org', 'code' => $loincCode, 'display' => $testName]];
|
|
}
|
|
$obsResource = [
|
|
'resourceType' => 'Observation',
|
|
'id' => $obsUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']],
|
|
'status' => (string) ($obs['status'] ?? 'final'),
|
|
'code' => $codeEntry,
|
|
'subject' => ['reference' => $patientRef],
|
|
'effectiveDateTime' => $reportedAt,
|
|
];
|
|
if ($encounterRef !== '') {
|
|
$obsResource['encounter'] = ['reference' => $encounterRef];
|
|
}
|
|
if ($practitionerRef !== '') {
|
|
$obsResource['performer'] = [['reference' => $practitionerRef]];
|
|
} elseif ($organizationRef !== '') {
|
|
$obsResource['performer'] = [['reference' => $organizationRef]];
|
|
}
|
|
// Value: quantity or string
|
|
$valueType = trim((string) ($obs['value_type'] ?? 'string'));
|
|
$rawValue = $obs['value'] ?? '';
|
|
if ($valueType === 'quantity' && is_numeric($rawValue)) {
|
|
$ucum = trim((string) ($obs['ucum_code'] ?? ''));
|
|
$unit = trim((string) ($obs['unit'] ?? ''));
|
|
$obsResource['valueQuantity'] = [
|
|
'value' => (float) $rawValue,
|
|
'unit' => $unit !== '' ? $unit : $ucum,
|
|
'system' => 'http://unitsofmeasure.org',
|
|
'code' => $ucum,
|
|
];
|
|
} else {
|
|
$obsResource['valueString'] = (string) $rawValue;
|
|
}
|
|
// Reference range
|
|
$refLow = $obs['ref_low'] ?? null;
|
|
$refHigh = $obs['ref_high'] ?? null;
|
|
if ($refLow !== null || $refHigh !== null) {
|
|
$rr = [];
|
|
$ucum = trim((string) ($obs['ucum_code'] ?? ''));
|
|
$unit = trim((string) ($obs['unit'] ?? ''));
|
|
if ($refLow !== null && is_numeric($refLow)) {
|
|
$rr['low'] = ['value' => (float) $refLow, 'unit' => $unit ?: $ucum, 'system' => 'http://unitsofmeasure.org', 'code' => $ucum];
|
|
}
|
|
if ($refHigh !== null && is_numeric($refHigh)) {
|
|
$rr['high'] = ['value' => (float) $refHigh, 'unit' => $unit ?: $ucum, 'system' => 'http://unitsofmeasure.org', 'code' => $ucum];
|
|
}
|
|
if (! empty($rr)) {
|
|
$obsResource['referenceRange'] = [$rr];
|
|
}
|
|
}
|
|
// Interpretation (H/L/N)
|
|
$interp = trim((string) ($obs['interpretation'] ?? ''));
|
|
if ($interp !== '') {
|
|
$interpCode = strtoupper($interp);
|
|
$interpCodeMap = ['H' => 'H', 'HIGH' => 'H', 'L' => 'L', 'LOW' => 'L', 'N' => 'N', 'NORMAL' => 'N'];
|
|
$obsResource['interpretation'] = [[
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation',
|
|
'code' => $interpCodeMap[$interpCode] ?? $interpCode,
|
|
'display' => $interp,
|
|
]],
|
|
'text' => $interp,
|
|
]];
|
|
}
|
|
$observationEntries[] = ['fullUrl' => $obsRef, 'resource' => $obsResource];
|
|
}
|
|
|
|
// ── Binary + DocumentReference for HTML report ─────────────────────────
|
|
$docRefEntry = null;
|
|
$docRefRef = null;
|
|
$pdfDocRefRef = null;
|
|
$mediaRef = null;
|
|
$reportHtml = trim((string) ($diagnosticReport['report_html'] ?? ''));
|
|
if ($reportHtml !== '') {
|
|
$binaryUuid = $this->generateUuid();
|
|
$docRefUuid = $this->generateUuid();
|
|
$docRefRef = 'urn:uuid:' . $docRefUuid;
|
|
$contentType = trim((string) ($diagnosticReport['report_content_type'] ?? 'text/html; charset=utf-8'));
|
|
|
|
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $binaryUuid, 'resource' => [
|
|
'resourceType' => 'Binary',
|
|
'id' => $binaryUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Binary']],
|
|
'contentType' => $contentType,
|
|
'data' => base64_encode($reportHtml),
|
|
]];
|
|
|
|
$docRefEntry = [
|
|
'resourceType' => 'DocumentReference',
|
|
'id' => $docRefUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
|
|
'status' => 'current',
|
|
'type' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnostic studies report']]],
|
|
'subject' => ['reference' => $patientRef],
|
|
'content' => [['attachment' => ['contentType' => $contentType, 'url' => 'urn:uuid:' . $binaryUuid, 'data' => base64_encode($reportHtml), 'title' => $reportTitle]]],
|
|
];
|
|
$resourceEntries[] = ['fullUrl' => $docRefRef, 'resource' => $docRefEntry];
|
|
}
|
|
|
|
// ── Optional PDF attachment as Binary + DocumentReference ───────────
|
|
if ($attachment !== null && ! empty($attachment['data_base64'])) {
|
|
$pdfData = trim((string) ($attachment['data_base64'] ?? ''));
|
|
if ($pdfData !== '') {
|
|
$pdfBinaryUuid = $this->generateUuid();
|
|
$pdfDocRefUuid = $this->generateUuid();
|
|
$pdfDocRefRef = 'urn:uuid:' . $pdfDocRefUuid;
|
|
$pdfContentType = trim((string) ($attachment['content_type'] ?? 'application/pdf')) ?: 'application/pdf';
|
|
$pdfTitle = trim((string) ($attachment['title'] ?? 'Lab Report PDF')) ?: 'Lab Report PDF';
|
|
$pdfBytes = base64_decode($pdfData, true);
|
|
$pdfSize = (int) ($attachment['size'] ?? (is_string($pdfBytes) ? strlen($pdfBytes) : 0));
|
|
$pdfHash = trim((string) ($attachment['hash'] ?? (is_string($pdfBytes) ? base64_encode(sha1($pdfBytes, true)) : '')));
|
|
|
|
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $pdfBinaryUuid, 'resource' => [
|
|
'resourceType' => 'Binary',
|
|
'id' => $pdfBinaryUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Binary']],
|
|
'contentType' => $pdfContentType,
|
|
'data' => $pdfData,
|
|
]];
|
|
|
|
$resourceEntries[] = ['fullUrl' => $pdfDocRefRef, 'resource' => [
|
|
'resourceType' => 'DocumentReference',
|
|
'id' => $pdfDocRefUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
|
|
'status' => 'current',
|
|
'type' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnostic studies report']]],
|
|
'subject' => ['reference' => $patientRef],
|
|
'content' => [[
|
|
'attachment' => array_filter([
|
|
'contentType' => $pdfContentType,
|
|
'url' => 'urn:uuid:' . $pdfBinaryUuid,
|
|
'data' => $pdfData,
|
|
'title' => $pdfTitle,
|
|
'size' => $pdfSize > 0 ? $pdfSize : null,
|
|
'hash' => $pdfHash !== '' ? $pdfHash : null,
|
|
], static fn ($value): bool => $value !== null),
|
|
]],
|
|
]];
|
|
|
|
if ($isImaging) {
|
|
$mediaUuid = $this->generateUuid();
|
|
$mediaRef = 'urn:uuid:' . $mediaUuid;
|
|
$resourceEntries[] = ['fullUrl' => $mediaRef, 'resource' => [
|
|
'resourceType' => 'Media',
|
|
'id' => $mediaUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Media']],
|
|
'status' => 'completed',
|
|
'type' => 'photo',
|
|
'subject' => ['reference' => $patientRef],
|
|
'createdDateTime' => $reportedAt,
|
|
'content' => [
|
|
'contentType' => $pdfContentType,
|
|
'url' => 'urn:uuid:' . $pdfBinaryUuid,
|
|
'data' => $pdfData,
|
|
'title' => $pdfTitle,
|
|
],
|
|
]];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pick a single DocumentReference for Composition.section slicing (0..1).
|
|
$effectiveDocRefRef = $pdfDocRefRef !== null ? $pdfDocRefRef : $docRefRef;
|
|
|
|
// ── DiagnosticReport ──────────────────────────────────────────────────
|
|
$reportRes = [
|
|
'resourceType' => 'DiagnosticReport',
|
|
'id' => $reportUuid,
|
|
'meta' => ['profile' => [$isImaging
|
|
? 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DiagnosticReportImaging'
|
|
: 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DiagnosticReportLab'
|
|
]],
|
|
'status' => $reportStatus,
|
|
'code' => array_filter([
|
|
'coding' => trim((string) ($diagnosticReport['loinc_code'] ?? '')) !== ''
|
|
? [['system' => 'http://loinc.org', 'code' => (string) $diagnosticReport['loinc_code'], 'display' => $reportTitle]]
|
|
: null,
|
|
'text' => $reportTitle,
|
|
]),
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'issued' => $reportedAt,
|
|
];
|
|
if (!empty($observationRefs)) {
|
|
$reportRes['result'] = $observationRefs;
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$reportRes['performer'] = [['reference' => $organizationRef]];
|
|
} elseif ($practitionerRef !== '') {
|
|
$reportRes['performer'] = [['reference' => $practitionerRef]];
|
|
}
|
|
if ($practitionerRef !== '') {
|
|
$reportRes['resultsInterpreter'] = [['reference' => $practitionerRef]];
|
|
} elseif ($organizationRef !== '') {
|
|
$reportRes['resultsInterpreter'] = [['reference' => $organizationRef]];
|
|
}
|
|
if ($encounterRef !== '') {
|
|
$reportRes['encounter'] = ['reference' => $encounterRef];
|
|
}
|
|
$catSnomedCode = trim((string) ($diagnosticReport['category_snomed_code'] ?? ''));
|
|
if ($catSnomedCode !== '') {
|
|
$reportRes['category'] = [['coding' => [['system' => 'http://snomed.info/sct', 'code' => $catSnomedCode, 'display' => trim((string) ($diagnosticReport['category_snomed_display'] ?? $catSnomedCode))]]]];
|
|
}
|
|
$conclusion = trim((string) ($diagnosticReport['conclusion'] ?? ''));
|
|
$reportRes['conclusion'] = $conclusion !== '' ? $conclusion : 'Laboratory report generated';
|
|
if ($isImaging && $mediaRef !== null) {
|
|
$reportRes['media'] = [[
|
|
'link' => ['reference' => $mediaRef],
|
|
]];
|
|
}
|
|
if ($attachment !== null && ! empty($attachment['data_base64'])) {
|
|
$pdfPresentedFormUrl = $pdfDocRefRef !== null ? $pdfDocRefRef : null;
|
|
$reportRes['presentedForm'] = [[
|
|
'contentType' => trim((string) ($attachment['content_type'] ?? 'application/pdf')) ?: 'application/pdf',
|
|
'title' => trim((string) ($attachment['title'] ?? 'Lab Report PDF')) ?: 'Lab Report PDF',
|
|
'url' => $pdfPresentedFormUrl,
|
|
]];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => $reportRef, 'resource' => $reportRes];
|
|
|
|
// ── Composition section entries ────────────────────────────────────────
|
|
$sectionEntries = [[
|
|
'reference' => $reportRef,
|
|
'type' => 'DiagnosticReport',
|
|
]];
|
|
if ($effectiveDocRefRef !== null) {
|
|
$sectionEntries[] = [
|
|
'reference' => $effectiveDocRefRef,
|
|
'type' => 'DocumentReference',
|
|
];
|
|
}
|
|
|
|
$sectionTitle = trim((string) ($diagnosticReport['section_title'] ?? ($isImaging ? 'Computed tomography imaging report' : 'Hematology report')));
|
|
$sectionCode = trim((string) ($diagnosticReport['section_snomed_code'] ?? ($isImaging ? '371531008' : '4321000179101')));
|
|
$sectionDisplay = trim((string) ($diagnosticReport['section_snomed_display'] ?? $sectionTitle));
|
|
|
|
// ── Composition ───────────────────────────────────────────────────────
|
|
$authorEntry = $practitionerRef !== ''
|
|
? ['reference' => $practitionerRef]
|
|
: ($organizationRef !== '' ? ['reference' => $organizationRef] : ['reference' => $patientRef]);
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => $compositionUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DiagnosticReportRecord']],
|
|
'language' => 'en-IN',
|
|
'identifier' => ['system' => 'https://ndhm.in/phr', 'value' => $compositionUuid],
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $isImaging ? '721979004' : '721981007',
|
|
'display' => $isImaging ? 'Diagnostic Report- Imaging' : 'Diagnostic Report- Lab',
|
|
]],
|
|
'text' => $isImaging ? 'Diagnostic Report- Imaging' : 'Diagnostic Report- Lab',
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'date' => $issuedAt,
|
|
'author' => [$authorEntry],
|
|
'title' => $reportTitle,
|
|
'section' => [[
|
|
'title' => $sectionTitle,
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => $sectionCode, 'display' => $sectionDisplay]]],
|
|
'entry' => $sectionEntries,
|
|
]],
|
|
];
|
|
if ($encounterRef !== '') {
|
|
$composition['encounter'] = ['reference' => $encounterRef];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$composition['custodian'] = ['reference' => $organizationRef];
|
|
}
|
|
|
|
// ── Bundle identifier system: HFR ID based or fallback ────────────────
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
|
$bundleIdSystem = $hfrId !== ''
|
|
? 'https://' . strtolower(preg_replace('/[^A-Za-z0-9]/', '', $hfrId)) . '.hfr.abdm.gov.in'
|
|
: 'https://hfr.abdm.gov.in';
|
|
$bundleIdValue = $hfrId !== ''
|
|
? 'LAB-' . (string) ($diagnosticReport['id'] ?? $bundleUuid)
|
|
: $bundleUuid;
|
|
|
|
return $this->sanitizeBundle([
|
|
'resourceType' => 'Bundle',
|
|
'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']],
|
|
],
|
|
'identifier' => ['system' => $bundleIdSystem, 'value' => $bundleIdValue],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => array_merge(
|
|
[['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]],
|
|
$resourceEntries,
|
|
$observationEntries
|
|
),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Build an ABDM-compliant DischargeSummaryRecord FHIR DocumentBundle.
|
|
*
|
|
* @param array<string, mixed> $patient {id, name, given_name, family_name, gender, birthDate, abhaAddress, phone}
|
|
* @param array<string, mixed> $encounter {id, admission_date, discharge_date, ipd_code, discharge_disposition}
|
|
* @param array<string, mixed> $summary {title, clinical_summary_html, chief_complaints, diagnosis_text,
|
|
* investigations_text, procedures_text, medications_text, follow_up}
|
|
* @param array<string, mixed>|null $practitioner {name, registration_number}
|
|
* @param array<string, mixed>|null $organization {name, hfr_id}
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildDischargeSummaryBundle(
|
|
array $patient,
|
|
array $encounter,
|
|
array $summary,
|
|
?array $practitioner = null,
|
|
?array $organization = null
|
|
): array {
|
|
$issuedAt = $this->isoTimestamp();
|
|
$bundleUuid = $this->generateUuid();
|
|
$compositionUuid = $this->generateUuid();
|
|
$patientUuid = $this->generateUuid();
|
|
$encounterUuid = $this->generateUuid();
|
|
|
|
$patientRef = 'urn:uuid:' . $patientUuid;
|
|
$encounterRef = 'urn:uuid:' . $encounterUuid;
|
|
|
|
$hasPractitioner = $practitioner !== null && trim((string) ($practitioner['name'] ?? '')) !== '';
|
|
$hasOrganization = $organization !== null && trim((string) ($organization['name'] ?? '')) !== '';
|
|
$practitionerUuid = $hasPractitioner ? $this->generateUuid() : '';
|
|
$organizationUuid = $hasOrganization ? $this->generateUuid() : '';
|
|
$practitionerRef = $hasPractitioner ? ('urn:uuid:' . $practitionerUuid) : '';
|
|
$organizationRef = $hasOrganization ? ('urn:uuid:' . $organizationUuid) : '';
|
|
|
|
$resourceEntries = [];
|
|
|
|
// ── Practitioner ──────────────────────────────────────────────────────
|
|
if ($hasPractitioner) {
|
|
$practRes = [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practitionerUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
|
|
'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]],
|
|
];
|
|
$regNo = trim((string) ($practitioner['registration_number'] ?? ''));
|
|
if ($regNo !== '') {
|
|
$practRes['identifier'] = [[
|
|
'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'MD', 'display' => 'Medical License number']]],
|
|
'system' => 'https://doctor.ndhm.gov.in',
|
|
'value' => $regNo,
|
|
]];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => $practitionerRef, 'resource' => $practRes];
|
|
}
|
|
|
|
// ── Organization ──────────────────────────────────────────────────────
|
|
if ($hasOrganization) {
|
|
$resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)];
|
|
}
|
|
|
|
// ── Patient ───────────────────────────────────────────────────────────
|
|
$patientResource = $this->buildPatientResource($patient);
|
|
$patientResource['id'] = $patientUuid;
|
|
$resourceEntries[] = ['fullUrl' => $patientRef, 'resource' => $patientResource];
|
|
|
|
// ── Encounter (inpatient) ─────────────────────────────────────────────
|
|
$admissionDate = trim((string) ($encounter['admission_date'] ?? '')) ?: $issuedAt;
|
|
$dischargeDate = trim((string) ($encounter['discharge_date'] ?? '')) ?: $issuedAt;
|
|
$encounterResource = [
|
|
'resourceType' => 'Encounter',
|
|
'id' => $encounterUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter']],
|
|
'identifier' => [['system' => 'https://ndhm.in', 'value' => trim((string) ($encounter['ipd_code'] ?? $encounterUuid))]],
|
|
'status' => 'finished',
|
|
'class' => [
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
|
|
'code' => 'IMP',
|
|
'display' => 'inpatient encounter',
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'period' => ['start' => $admissionDate, 'end' => $dischargeDate],
|
|
];
|
|
$dispText = trim((string) ($encounter['discharge_disposition'] ?? ''));
|
|
if ($dispText !== '') {
|
|
$encounterResource['hospitalization'] = [
|
|
'dischargeDisposition' => [
|
|
'coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/discharge-disposition', 'code' => 'home', 'display' => 'Home']],
|
|
'text' => $dispText,
|
|
],
|
|
];
|
|
}
|
|
if ($practitionerRef !== '') {
|
|
$encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$encounterResource['serviceProvider'] = ['reference' => $organizationRef];
|
|
}
|
|
|
|
// ABDM rejects a DischargeSummaryRecord without at least one Condition or
|
|
// Procedure resource, so the diagnosis must be a resource and not just narrative.
|
|
$diagnosisResourceText = trim((string) (
|
|
$summary['diagnosis_text']
|
|
?? $summary['final_diagnosis']
|
|
?? $summary['chief_complaints']
|
|
?? ''
|
|
));
|
|
$conditionRef = '';
|
|
if ($diagnosisResourceText !== '') {
|
|
$conditionUuid = $this->generateUuid();
|
|
$conditionRef = 'urn:uuid:' . $conditionUuid;
|
|
|
|
$encounterResource['diagnosis'] = [[
|
|
'condition' => ['reference' => $conditionRef, 'display' => 'Condition'],
|
|
]];
|
|
|
|
$resourceEntries[] = ['fullUrl' => $conditionRef, 'resource' => [
|
|
'resourceType' => 'Condition',
|
|
'id' => $conditionUuid,
|
|
'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' => 'confirmed', 'display' => 'Confirmed']]],
|
|
'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'encounter-diagnosis', 'display' => 'Encounter Diagnosis']]]],
|
|
'code' => ['text' => $diagnosisResourceText],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
]];
|
|
}
|
|
|
|
$resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource];
|
|
|
|
// ── Composition sections per DischargeSummaryRecord spec ──────────────
|
|
$sections = [];
|
|
$summaryHtml = trim((string) ($summary['clinical_summary_html'] ?? ''));
|
|
|
|
// Chief complaints section (SNOMED 422843007)
|
|
$chiefComplaints = trim((string) ($summary['chief_complaints'] ?? ''));
|
|
if ($chiefComplaints !== '' || $summaryHtml !== '') {
|
|
$ccSection = [
|
|
'title' => 'Chief complaints',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '422843007', 'display' => 'Chief complaint section']]],
|
|
];
|
|
if ($chiefComplaints !== '') {
|
|
$ccSection['text'] = ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($chiefComplaints) . '</div>'];
|
|
}
|
|
$sections[] = $ccSection;
|
|
}
|
|
|
|
// Medical History section (SNOMED 1003642006)
|
|
$diagnosisText = trim((string) ($summary['diagnosis_text'] ?? ''));
|
|
if ($diagnosisText !== '') {
|
|
$sections[] = [
|
|
'title' => 'Medical History',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '1003642006', 'display' => 'Past medical history section']]],
|
|
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($diagnosisText) . '</div>'],
|
|
];
|
|
}
|
|
|
|
if ($conditionRef !== '') {
|
|
$sections[] = [
|
|
'title' => 'Diagnosis',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnosis']]],
|
|
'entry' => [['reference' => $conditionRef, 'display' => 'Condition']],
|
|
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($diagnosisResourceText) . '</div>'],
|
|
];
|
|
}
|
|
|
|
// Investigations section (SNOMED 721981007)
|
|
$investigationsText = trim((string) ($summary['investigations_text'] ?? ''));
|
|
if ($investigationsText !== '') {
|
|
$sections[] = [
|
|
'title' => 'Investigations',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnostic studies report']]],
|
|
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($investigationsText) . '</div>'],
|
|
];
|
|
}
|
|
|
|
// Procedures section (SNOMED 1003640003)
|
|
$proceduresText = trim((string) ($summary['procedures_text'] ?? ''));
|
|
if ($proceduresText !== '') {
|
|
$sections[] = [
|
|
'title' => 'Procedures',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '1003640003', 'display' => 'Procedure report']]],
|
|
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($proceduresText) . '</div>'],
|
|
];
|
|
}
|
|
|
|
// Medications section (SNOMED 721912009)
|
|
$medicationsText = trim((string) ($summary['medications_text'] ?? ''));
|
|
if ($medicationsText !== '') {
|
|
$sections[] = [
|
|
'title' => 'Medications',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721912009', 'display' => 'Medication summary document']]],
|
|
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($medicationsText) . '</div>'],
|
|
];
|
|
}
|
|
|
|
// Follow-up section (SNOMED 736271009)
|
|
$followUp = trim((string) ($summary['follow_up'] ?? ''));
|
|
if ($followUp !== '') {
|
|
$sections[] = [
|
|
'title' => 'Follow Up',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '736271009', 'display' => 'Outpatient care plan']]],
|
|
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($followUp) . '</div>'],
|
|
];
|
|
}
|
|
|
|
// Fallback: one summary section if nothing else provided
|
|
if (empty($sections) && $summaryHtml !== '') {
|
|
$sections[] = [
|
|
'title' => 'Discharge Summary',
|
|
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '373942005', 'display' => 'Discharge summary']]],
|
|
'text' => ['status' => 'generated', 'div' => $summaryHtml],
|
|
];
|
|
}
|
|
|
|
// ── Composition ───────────────────────────────────────────────────────
|
|
$authorEntry = $practitionerRef !== '' ? ['reference' => $practitionerRef] : ['display' => 'Unknown'];
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => $compositionUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord']],
|
|
'language' => 'en-IN',
|
|
'identifier' => ['system' => 'https://ndhm.in/phr', 'value' => $compositionUuid],
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [['system' => 'http://snomed.info/sct', 'code' => '373942005', 'display' => 'Discharge summary']],
|
|
'text' => 'Discharge Summary',
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'encounter' => ['reference' => $encounterRef, 'display' => 'Encounter'],
|
|
'date' => $issuedAt,
|
|
'author' => [[$authorEntry]],
|
|
'title' => (string) ($summary['title'] ?? 'Discharge Summary'),
|
|
'section' => $sections,
|
|
];
|
|
if ($organizationRef !== '') {
|
|
$composition['custodian'] = ['reference' => $organizationRef];
|
|
}
|
|
|
|
// ── Bundle identifier system: HFR ID based or fallback ────────────────
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
|
$bundleIdSystem = $hfrId !== ''
|
|
? 'https://' . strtolower(preg_replace('/[^A-Za-z0-9]/', '', $hfrId)) . '.hfr.abdm.gov.in'
|
|
: 'https://hfr.abdm.gov.in';
|
|
$ipdCode = trim((string) ($encounter['ipd_code'] ?? ''));
|
|
$bundleIdValue = $hfrId !== ''
|
|
? 'IPD-' . ($ipdCode !== '' ? $ipdCode : $bundleUuid)
|
|
: $bundleUuid;
|
|
|
|
return $this->sanitizeBundle([
|
|
'resourceType' => 'Bundle',
|
|
'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']],
|
|
],
|
|
'identifier' => ['system' => $bundleIdSystem, 'value' => $bundleIdValue],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => array_merge(
|
|
[['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]],
|
|
$resourceEntries
|
|
),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $patient
|
|
* @param array<string, mixed> $encounter
|
|
* @param array<string, mixed> $claim
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildClaimBundle(array $patient, array $encounter, array $claim): array
|
|
{
|
|
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
|
$encounterRef = 'Encounter/' . (string) ($encounter['id'] ?? 'unknown');
|
|
|
|
$claimId = (string) ($claim['id'] ?? ('claim-' . time()));
|
|
$items = is_array($claim['items'] ?? null) ? ($claim['items'] ?? []) : [];
|
|
|
|
$claimItems = [];
|
|
foreach ($items as $index => $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$claimItems[] = [
|
|
'sequence' => $index + 1,
|
|
'productOrService' => [
|
|
'text' => (string) ($item['name'] ?? $item['description'] ?? ''),
|
|
],
|
|
'quantity' => [
|
|
'value' => (float) ($item['qty'] ?? 1),
|
|
],
|
|
'unitPrice' => [
|
|
'value' => (float) ($item['unit_price'] ?? 0),
|
|
'currency' => (string) ($item['currency'] ?? 'INR'),
|
|
],
|
|
'net' => [
|
|
'value' => (float) ($item['amount'] ?? 0),
|
|
'currency' => (string) ($item['currency'] ?? 'INR'),
|
|
],
|
|
];
|
|
}
|
|
|
|
$claimResource = [
|
|
'resourceType' => 'Claim',
|
|
'id' => $claimId,
|
|
'status' => (string) ($claim['status'] ?? 'active'),
|
|
'use' => (string) ($claim['use'] ?? 'claim'),
|
|
'type' => [
|
|
'text' => (string) ($claim['type'] ?? 'institutional'),
|
|
],
|
|
'patient' => ['reference' => $patientRef],
|
|
'created' => $issuedAt,
|
|
'provider' => [
|
|
'display' => (string) ($claim['provider'] ?? ''),
|
|
],
|
|
'insurer' => [
|
|
'display' => (string) ($claim['insurer'] ?? ''),
|
|
],
|
|
'priority' => [
|
|
'text' => (string) ($claim['priority'] ?? 'normal'),
|
|
],
|
|
'item' => $claimItems,
|
|
'total' => [
|
|
'value' => (float) ($claim['total'] ?? 0),
|
|
'currency' => (string) ($claim['currency'] ?? 'INR'),
|
|
],
|
|
'encounter' => [[
|
|
'reference' => $encounterRef,
|
|
]],
|
|
];
|
|
|
|
// ABDM validates InvoiceRecord on a FHIR Invoice resource; Claim alone is rejected.
|
|
$invoiceLineItems = [];
|
|
foreach ($claimItems as $index => $claimItem) {
|
|
$invoiceLineItems[] = [
|
|
'sequence' => $index + 1,
|
|
'chargeItemCodeableConcept' => $claimItem['productOrService'],
|
|
'priceComponent' => [[
|
|
'type' => 'base',
|
|
'amount' => $claimItem['net'],
|
|
]],
|
|
];
|
|
}
|
|
|
|
$invoiceResource = [
|
|
'resourceType' => 'Invoice',
|
|
'id' => 'invoice-' . $claimId,
|
|
'status' => (string) ($claim['invoice_status'] ?? 'issued'),
|
|
'subject' => ['reference' => $patientRef],
|
|
'date' => $issuedAt,
|
|
'totalGross' => [
|
|
'value' => (float) ($claim['total'] ?? 0),
|
|
'currency' => (string) ($claim['currency'] ?? 'INR'),
|
|
],
|
|
'totalNet' => [
|
|
'value' => (float) ($claim['total'] ?? 0),
|
|
'currency' => (string) ($claim['currency'] ?? 'INR'),
|
|
],
|
|
];
|
|
|
|
$invoiceNo = trim((string) ($claim['invoice_no'] ?? ''));
|
|
if ($invoiceNo !== '') {
|
|
$invoiceResource['identifier'] = [['system' => 'https://ndhm.in/invoice', 'value' => $invoiceNo]];
|
|
}
|
|
if ($invoiceLineItems !== []) {
|
|
$invoiceResource['lineItem'] = $invoiceLineItems;
|
|
}
|
|
|
|
// ABDM InvoiceRecord must be a document bundle led by a Composition.
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => 'comp-invoice-' . $claimId,
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '736762002',
|
|
'display' => 'Invoice record',
|
|
]],
|
|
'text' => 'Invoice',
|
|
],
|
|
'subject' => ['reference' => $patientRef],
|
|
'date' => $issuedAt,
|
|
'title' => 'Invoice',
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'section' => [[
|
|
'title' => 'Invoice',
|
|
'entry' => [
|
|
['reference' => 'Invoice/invoice-' . $claimId],
|
|
['reference' => 'Claim/' . $claimId],
|
|
],
|
|
]],
|
|
];
|
|
|
|
$provider = trim((string) ($claim['provider'] ?? ''));
|
|
if ($provider !== '') {
|
|
$composition['author'] = [['display' => $provider]];
|
|
}
|
|
|
|
return $this->sanitizeBundle([
|
|
'resourceType' => 'Bundle',
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => [[
|
|
'resource' => $composition,
|
|
], [
|
|
'resource' => $this->buildPatientResource($patient),
|
|
], [
|
|
'resource' => [
|
|
'resourceType' => 'Encounter',
|
|
'id' => (string) ($encounter['id'] ?? 'unknown'),
|
|
'status' => (string) ($encounter['status'] ?? 'finished'),
|
|
'subject' => ['reference' => $patientRef],
|
|
],
|
|
], [
|
|
'resource' => $invoiceResource,
|
|
], [
|
|
'resource' => $claimResource,
|
|
]],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Build an ABDM ImmunizationRecord FHIR DocumentBundle.
|
|
*
|
|
* @param array<string,mixed> $patient
|
|
* @param array<int,array<string,mixed>> $immunizations
|
|
* @param array<string,mixed> $context practitioner, organization, encounter, care_context_reference
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function buildImmunizationRecordBundle(array $patient, array $immunizations, array $context = []): array
|
|
{
|
|
$issuedAt = $this->isoTimestamp();
|
|
$bundleUuid = $this->generateUuid();
|
|
$compositionUuid = $this->generateUuid();
|
|
$patientUuid = $this->generateUuid();
|
|
$patientRef = 'urn:uuid:' . $patientUuid;
|
|
|
|
$practitioner = is_array($context['practitioner'] ?? null) ? (array) $context['practitioner'] : [];
|
|
$organization = is_array($context['organization'] ?? null) ? (array) $context['organization'] : [];
|
|
$recommendations = is_array($context['recommendations'] ?? null) ? (array) $context['recommendations'] : [];
|
|
if ($recommendations === [] && isset($context['recommendation']) && is_array($context['recommendation'])) {
|
|
$recommendations = [$context['recommendation']];
|
|
}
|
|
|
|
$hasPractitioner = trim((string) ($practitioner['id'] ?? '')) !== '' || trim((string) ($practitioner['name'] ?? '')) !== '';
|
|
$hasOrganization = trim((string) ($organization['id'] ?? $organization['hfr_id'] ?? '')) !== '' || trim((string) ($organization['name'] ?? '')) !== '';
|
|
|
|
$practitionerUuid = $hasPractitioner ? $this->generateUuid() : '';
|
|
$organizationUuid = $hasOrganization ? $this->generateUuid() : '';
|
|
$practitionerRef = $hasPractitioner ? ('urn:uuid:' . $practitionerUuid) : '';
|
|
$organizationRef = $hasOrganization ? ('urn:uuid:' . $organizationUuid) : '';
|
|
|
|
$patientDisplayName = trim((string) ($patient['name'] ?? ''));
|
|
if ($patientDisplayName === '') {
|
|
$patientDisplayName = 'Patient';
|
|
}
|
|
|
|
$patientResource = $this->buildPatientResource($patient);
|
|
$patientResource['id'] = $patientUuid;
|
|
$patientResource['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient']];
|
|
$patientEntry = ['fullUrl' => $patientRef, 'resource' => $patientResource];
|
|
|
|
$careContextReference = trim((string) ($context['care_context_reference'] ?? ''));
|
|
if ($careContextReference === '') {
|
|
$sourceIds = [];
|
|
foreach ($immunizations as $immunization) {
|
|
$sourceId = trim((string) ($immunization['id'] ?? ''));
|
|
if ($sourceId !== '') {
|
|
$sourceIds[] = $sourceId;
|
|
}
|
|
}
|
|
$sourceIds = array_values(array_unique($sourceIds));
|
|
sort($sourceIds, SORT_STRING);
|
|
if (count($sourceIds) === 1) {
|
|
$careContextReference = 'IMM-' . $sourceIds[0];
|
|
} elseif ($sourceIds !== []) {
|
|
$careContextReference = 'IMM-SET-' . substr(hash('sha256', implode(',', $sourceIds)), 0, 20);
|
|
} else {
|
|
$careContextReference = 'IMM-PAT-' . trim((string) ($patient['id'] ?? 'unknown'));
|
|
}
|
|
}
|
|
|
|
$encounterUuid = $this->generateUuid();
|
|
$encounterRef = 'urn:uuid:' . $encounterUuid;
|
|
$encounterContext = is_array($context['encounter'] ?? null) ? (array) $context['encounter'] : [];
|
|
$encounterId = trim((string) ($encounterContext['id'] ?? ''));
|
|
if ($encounterId === '') {
|
|
$encounterId = $careContextReference !== '' ? $careContextReference : ('IMM-' . $encounterUuid);
|
|
}
|
|
$encounterStatus = trim((string) ($encounterContext['status'] ?? 'finished'));
|
|
$classCode = strtoupper(trim((string) ($encounterContext['class_code'] ?? 'AMB')));
|
|
$classDisplay = trim((string) ($encounterContext['class_display'] ?? ($classCode === 'IMP' ? 'inpatient encounter' : 'ambulatory')));
|
|
|
|
$encounterStartRaw = trim((string) ($encounterContext['period_start'] ?? ''));
|
|
if ($encounterStartRaw === '') {
|
|
foreach ($immunizations as $imm) {
|
|
$d = trim((string) ($imm['given_date'] ?? $imm['occurrenceDateTime'] ?? ''));
|
|
if ($d !== '') {
|
|
$encounterStartRaw = $d;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$encounterStart = $this->normalizeIsoDateTime($encounterStartRaw, $issuedAt);
|
|
$encounterEndRaw = trim((string) ($encounterContext['period_end'] ?? ''));
|
|
$encounterEnd = $encounterEndRaw !== '' ? $this->normalizeIsoDateTime($encounterEndRaw, $issuedAt) : '';
|
|
|
|
$encounterPeriod = ['start' => $encounterStart];
|
|
if ($encounterEnd !== '') {
|
|
$encounterPeriod['end'] = $encounterEnd;
|
|
}
|
|
|
|
$encounterResource = [
|
|
'resourceType' => 'Encounter',
|
|
'id' => $encounterUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter']],
|
|
'identifier' => [[
|
|
'system' => 'https://ndhm.in',
|
|
'value' => $encounterId,
|
|
]],
|
|
'status' => $encounterStatus,
|
|
'class' => [
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
|
|
'code' => $classCode,
|
|
'display' => $classDisplay,
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => $patientDisplayName],
|
|
'period' => $encounterPeriod,
|
|
];
|
|
|
|
$practitionerName = '';
|
|
$practitionerEntry = null;
|
|
if ($hasPractitioner) {
|
|
$practitionerName = trim((string) ($practitioner['name'] ?? ''));
|
|
if ($practitionerName !== '' && stripos($practitionerName, 'Dr') === false) {
|
|
$practitionerName = 'Dr. ' . $practitionerName;
|
|
}
|
|
$practitionerResource = [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practitionerUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
|
|
'name' => [['text' => $practitionerName]],
|
|
];
|
|
$registrationNumber = trim((string) ($practitioner['registration_number'] ?? ''));
|
|
$hprId = trim((string) ($practitioner['hpr_id'] ?? ''));
|
|
$practitionerResource['identifier'] = [];
|
|
if ($registrationNumber !== '') {
|
|
$practitionerResource['identifier'][] = [
|
|
'type' => ['coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
|
|
'code' => 'MD',
|
|
'display' => 'Medical License number',
|
|
]]],
|
|
'system' => 'https://doctor.ndhm.gov.in',
|
|
'value' => $registrationNumber,
|
|
];
|
|
}
|
|
if ($hprId !== '') {
|
|
$practitionerResource['identifier'][] = [
|
|
'type' => ['coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
|
|
'code' => 'HPR',
|
|
'display' => 'Healthcare Professional Registry ID',
|
|
]]],
|
|
'system' => 'https://hpr.ndhm.gov.in',
|
|
'value' => $hprId,
|
|
];
|
|
}
|
|
if ($practitionerResource['identifier'] === []) {
|
|
$practitionerResource['identifier'][] = [
|
|
'type' => ['coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
|
|
'code' => 'MD',
|
|
'display' => 'Medical License number',
|
|
]]],
|
|
'system' => 'https://doctor.ndhm.gov.in',
|
|
'value' => (string) ($practitioner['id'] ?? 'DOC-1'),
|
|
];
|
|
}
|
|
$practitionerEntry = ['fullUrl' => $practitionerRef, 'resource' => $practitionerResource];
|
|
}
|
|
|
|
if ($practitionerRef !== '') {
|
|
$encounterResource['participant'] = [[
|
|
'individual' => [
|
|
'reference' => $practitionerRef,
|
|
'display' => $practitionerName ?: 'Doctor',
|
|
],
|
|
]];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$encounterResource['serviceProvider'] = [
|
|
'reference' => $organizationRef,
|
|
'display' => trim((string) ($organization['name'] ?? '')),
|
|
];
|
|
}
|
|
$encounterEntry = ['fullUrl' => $encounterRef, 'resource' => $encounterResource];
|
|
|
|
$organizationEntry = null;
|
|
if ($hasOrganization) {
|
|
$organizationEntry = ['fullUrl' => $organizationRef, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)];
|
|
}
|
|
|
|
$immunizationRefs = [];
|
|
$immunizationEntries = [];
|
|
$firstImmunizationRef = '';
|
|
foreach ($immunizations as $immunization) {
|
|
$vaccineName = trim((string) ($immunization['vaccine_name'] ?? $immunization['vaccine_display'] ?? ''));
|
|
if ($vaccineName === '') {
|
|
continue;
|
|
}
|
|
|
|
$immunizationUuid = $this->generateUuid();
|
|
$immunizationRef = 'urn:uuid:' . $immunizationUuid;
|
|
if ($firstImmunizationRef === '') {
|
|
$firstImmunizationRef = $immunizationRef;
|
|
}
|
|
$status = $this->normalizeImmunizationStatus((string) ($immunization['status'] ?? 'completed'));
|
|
$occurrence = $this->normalizeIsoDateTime(
|
|
(string) ($immunization['given_date'] ?? $immunization['occurrenceDateTime'] ?? $immunization['due_date'] ?? ''),
|
|
$issuedAt
|
|
);
|
|
$vaccineCoding = $this->resolveVaccineStandardCoding(
|
|
(string) ($immunization['vaccine_code_system'] ?? ''),
|
|
(string) ($immunization['vaccine_code'] ?? ''),
|
|
$vaccineName
|
|
);
|
|
|
|
$resource = [
|
|
'resourceType' => 'Immunization',
|
|
'id' => $immunizationUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Immunization']],
|
|
'identifier' => [[
|
|
'system' => 'https://ndhm.in/immunization',
|
|
'value' => (string) ($immunization['id'] ?? $immunizationUuid),
|
|
]],
|
|
'status' => $status,
|
|
'vaccineCode' => $vaccineCoding,
|
|
'patient' => ['reference' => $patientRef, 'display' => $patientDisplayName],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'occurrenceDateTime' => $occurrence,
|
|
'recorded' => $issuedAt,
|
|
'primarySource' => true,
|
|
];
|
|
|
|
$manufacturer = trim((string) ($immunization['manufacturer'] ?? ''));
|
|
if ($manufacturer !== '') {
|
|
$resource['manufacturer'] = ['display' => $manufacturer];
|
|
}
|
|
$lotNumber = trim((string) ($immunization['lot_number'] ?? ''));
|
|
if ($lotNumber !== '') {
|
|
$resource['lotNumber'] = $lotNumber;
|
|
}
|
|
$expiryDate = trim((string) ($immunization['expiry_date'] ?? $immunization['expirationDate'] ?? ''));
|
|
if ($expiryDate !== '' && strtotime($expiryDate) !== false) {
|
|
$resource['expirationDate'] = date('Y-m-d', strtotime($expiryDate));
|
|
}
|
|
|
|
$site = $this->resolveSiteCoding((string) ($immunization['site_code'] ?? ''), (string) ($immunization['site_name'] ?? ''));
|
|
if (! empty($site)) {
|
|
$resource['site'] = $site;
|
|
}
|
|
|
|
$route = $this->resolveRouteCoding((string) ($immunization['route_code'] ?? ''), (string) ($immunization['route_name'] ?? ''));
|
|
if (! empty($route)) {
|
|
$resource['route'] = $route;
|
|
}
|
|
|
|
if ($practitionerRef !== '') {
|
|
$resource['performer'] = [[
|
|
'function' => [
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0443',
|
|
'code' => 'AP',
|
|
'display' => 'Administering Provider',
|
|
]],
|
|
'text' => 'Administering provider',
|
|
],
|
|
'actor' => ['reference' => $practitionerRef, 'display' => $practitionerName ?? trim((string) ($practitioner['name'] ?? 'Doctor'))],
|
|
]];
|
|
} elseif ($organizationRef !== '') {
|
|
$resource['performer'] = [[
|
|
'function' => [
|
|
'text' => 'Administering organization',
|
|
],
|
|
'actor' => ['reference' => $organizationRef, 'display' => trim((string) ($organization['name'] ?? ''))],
|
|
]];
|
|
}
|
|
|
|
$protocol = [];
|
|
$series = trim((string) ($immunization['series_name'] ?? ''));
|
|
if ($series === '') {
|
|
$series = $vaccineName;
|
|
}
|
|
$protocol['series'] = $series;
|
|
|
|
$dose = $this->buildDoseNumberElement((string) ($immunization['dose_number'] ?? '1'));
|
|
if (! empty($dose)) {
|
|
$protocol += $dose;
|
|
}
|
|
$seriesDoses = $this->buildDoseNumberElement((string) ($immunization['series_doses'] ?? ''), 'seriesDoses');
|
|
if (! empty($seriesDoses)) {
|
|
$protocol += $seriesDoses;
|
|
}
|
|
|
|
$targetDisease = $this->resolveTargetDiseaseCoding(
|
|
(string) ($immunization['target_disease_code'] ?? ''),
|
|
(string) ($immunization['target_disease_name'] ?? ''),
|
|
$vaccineName
|
|
);
|
|
if (! empty($targetDisease)) {
|
|
$protocol['targetDisease'] = [$targetDisease];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$protocol['authority'] = ['reference' => $organizationRef];
|
|
}
|
|
$resource['protocolApplied'] = [$protocol];
|
|
|
|
$note = trim((string) ($immunization['notes'] ?? ''));
|
|
if ($note !== '') {
|
|
$resource['note'] = [['text' => $note]];
|
|
}
|
|
|
|
$immunizationRefs[] = ['reference' => $immunizationRef, 'type' => 'Immunization'];
|
|
$immunizationEntries[] = ['fullUrl' => $immunizationRef, 'resource' => $resource];
|
|
}
|
|
|
|
// --- Build ImmunizationRecommendation resource (ABDM Section 3, slice max: 1) ---
|
|
$recommendationRefs = [];
|
|
$recommendationEntries = [];
|
|
$recItems = [];
|
|
foreach ($recommendations as $rec) {
|
|
$recVaccineName = trim((string) ($rec['vaccine_name'] ?? $rec['vaccine_display'] ?? ''));
|
|
if ($recVaccineName === '') {
|
|
continue;
|
|
}
|
|
|
|
$recCoding = $this->resolveVaccineStandardCoding(
|
|
(string) ($rec['vaccine_code_system'] ?? ''),
|
|
(string) ($rec['vaccine_code'] ?? ''),
|
|
$recVaccineName
|
|
);
|
|
|
|
$dueDate = trim((string) ($rec['due_date'] ?? ''));
|
|
if ($dueDate !== '') {
|
|
$dueDate = $this->normalizeIsoDateTime($dueDate, '');
|
|
}
|
|
|
|
$recDoseNum = (int) ($rec['dose_number'] ?? 0);
|
|
$recSeriesDoses = (int) ($rec['series_doses'] ?? 0);
|
|
$recSeries = trim((string) ($rec['series_name'] ?? $recVaccineName));
|
|
|
|
$recItem = [
|
|
'vaccineCode' => [$recCoding],
|
|
'forecastStatus' => [
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/immunization-recommendation-status',
|
|
'code' => 'due',
|
|
'display' => 'Due',
|
|
]],
|
|
],
|
|
];
|
|
if ($dueDate !== '') {
|
|
$recItem['dateCriterion'] = [[
|
|
'code' => [
|
|
'coding' => [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => '30980-7',
|
|
'display' => 'Date vaccine due',
|
|
]],
|
|
],
|
|
'value' => $dueDate,
|
|
]];
|
|
}
|
|
if ($recDoseNum > 0) {
|
|
$recItem['doseNumberPositiveInt'] = $recDoseNum;
|
|
$recItem['description'] = 'Dose ' . $recDoseNum . ' in ' . $recSeries;
|
|
}
|
|
if ($recSeriesDoses > 0) {
|
|
$recItem['seriesDosesPositiveInt'] = $recSeriesDoses;
|
|
}
|
|
if ($recSeries !== '') {
|
|
$recItem['series'] = $recSeries;
|
|
}
|
|
if ($firstImmunizationRef !== '') {
|
|
$recItem['supportingImmunization'] = [[
|
|
'reference' => $firstImmunizationRef,
|
|
'display' => 'Immunization',
|
|
]];
|
|
}
|
|
|
|
$recItems[] = $recItem;
|
|
}
|
|
|
|
if ($recItems !== []) {
|
|
$recUuid = $this->generateUuid();
|
|
$recRef = 'urn:uuid:' . $recUuid;
|
|
$recResource = [
|
|
'resourceType' => 'ImmunizationRecommendation',
|
|
'id' => $recUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecommendation']],
|
|
'patient' => ['reference' => $patientRef, 'display' => 'Patient'],
|
|
'date' => $issuedAt,
|
|
'recommendation' => $recItems,
|
|
];
|
|
if ($organizationRef !== '') {
|
|
$recResource['authority'] = ['reference' => $organizationRef];
|
|
}
|
|
|
|
$recommendationRefs[] = [
|
|
'reference' => $recRef,
|
|
'type' => 'ImmunizationRecommendation',
|
|
];
|
|
$recommendationEntries[] = ['fullUrl' => $recRef, 'resource' => $recResource];
|
|
}
|
|
|
|
$allSectionEntries = array_merge($immunizationRefs, $recommendationRefs);
|
|
|
|
$patientDisplayName = trim((string) ($patient['name'] ?? ''));
|
|
if ($patientDisplayName === '') {
|
|
$patientDisplayName = 'Patient';
|
|
}
|
|
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => $compositionUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord']],
|
|
'language' => 'en-IN',
|
|
'text' => [
|
|
'status' => 'generated',
|
|
'div' => '<div xmlns="http://www.w3.org/1999/xhtml">Immunization record</div>',
|
|
],
|
|
'identifier' => [
|
|
'system' => 'https://ndhm.in/phr',
|
|
'value' => $compositionUuid,
|
|
],
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '41000179103',
|
|
'display' => 'Immunization record',
|
|
]],
|
|
'text' => 'Immunization record',
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => $patientDisplayName],
|
|
'encounter' => ['reference' => $encounterRef, 'display' => 'Encounter'],
|
|
'date' => $issuedAt,
|
|
'title' => 'Immunization record',
|
|
'section' => [[
|
|
'title' => 'Immunization record',
|
|
'code' => ['coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '41000179103',
|
|
'display' => 'Immunization record',
|
|
]]],
|
|
'entry' => $allSectionEntries,
|
|
]],
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$composition['author'] = [[
|
|
'reference' => $practitionerRef,
|
|
'display' => $practitionerName ?? trim((string) ($practitioner['name'] ?? 'Doctor')),
|
|
]];
|
|
} elseif ($organizationRef !== '') {
|
|
$composition['author'] = [[
|
|
'reference' => $organizationRef,
|
|
'display' => trim((string) ($organization['name'] ?? '')),
|
|
]];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$composition['custodian'] = [
|
|
'reference' => $organizationRef,
|
|
'display' => trim((string) ($organization['name'] ?? '')),
|
|
];
|
|
}
|
|
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? ''));
|
|
|
|
// Order entries in canonical NRCES order:
|
|
// 1. Composition
|
|
// 2. Practitioner (if present)
|
|
// 3. Organization (if present)
|
|
// 4. Patient
|
|
// 5. Encounter
|
|
// 6. Immunization(s)
|
|
// 7. ImmunizationRecommendation(s)
|
|
$bundleEntries = [
|
|
['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition],
|
|
];
|
|
if ($practitionerEntry !== null) {
|
|
$bundleEntries[] = $practitionerEntry;
|
|
}
|
|
if ($organizationEntry !== null) {
|
|
$bundleEntries[] = $organizationEntry;
|
|
}
|
|
$bundleEntries[] = $patientEntry;
|
|
$bundleEntries[] = $encounterEntry;
|
|
foreach ($immunizationEntries as $entry) {
|
|
$bundleEntries[] = $entry;
|
|
}
|
|
foreach ($recommendationEntries as $entry) {
|
|
$bundleEntries[] = $entry;
|
|
}
|
|
|
|
return $this->sanitizeBundle([
|
|
'resourceType' => 'Bundle',
|
|
'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',
|
|
]],
|
|
],
|
|
'identifier' => [
|
|
'system' => $hfrId !== '' ? ('https://' . $hfrId . '.hfr.abdm.gov.in') : 'urn:ietf:rfc:3986',
|
|
'value' => $careContextReference,
|
|
],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => $bundleEntries,
|
|
]);
|
|
}
|
|
|
|
private function normalizeImmunizationStatus(string $status): string
|
|
{
|
|
$status = strtolower(trim($status));
|
|
return match ($status) {
|
|
'completed', 'done', 'given' => 'completed',
|
|
'entered-in-error', 'entered_in_error', 'error' => 'entered-in-error',
|
|
default => 'not-done',
|
|
};
|
|
}
|
|
|
|
/** @return array{0:string,1:string} */
|
|
private function normalizeVaccineCoding(string $system, string $code): array
|
|
{
|
|
$system = trim($system);
|
|
$code = trim($code);
|
|
if ($system !== 'https://hms.local/immunization/uip') {
|
|
return [$system, $code];
|
|
}
|
|
|
|
$cvxCodes = [
|
|
'UIP-BCG' => '19',
|
|
'UIP-OPV' => '02',
|
|
'UIP-HEPB' => '45',
|
|
'UIP-ROTA' => '122',
|
|
'UIP-FIPV' => '10',
|
|
'UIP-PENTA' => '198',
|
|
'UIP-PENTAVALENT' => '198',
|
|
'UIP-PCV' => '152',
|
|
'UIP-MR' => '04',
|
|
'UIP-JE' => '39',
|
|
'UIP-DPT' => '01',
|
|
'UIP-TD' => '09',
|
|
'UIP-TT' => '09',
|
|
];
|
|
if (! isset($cvxCodes[$code])) {
|
|
return [$system, $code];
|
|
}
|
|
|
|
return ['http://hl7.org/fhir/sid/cvx', $cvxCodes[$code]];
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function resolveVaccineStandardCoding(string $system, string $code, string $name): array
|
|
{
|
|
$concept = [];
|
|
$name = trim($name);
|
|
if ($name !== '') {
|
|
$concept['text'] = $name;
|
|
}
|
|
|
|
[$normSystem, $normCode] = $this->normalizeVaccineCoding($system, $code);
|
|
|
|
if ($normSystem !== '' && $normCode !== '' && $normSystem !== 'https://hms.local/immunization/uip') {
|
|
$display = $name;
|
|
if ($normSystem === 'http://hl7.org/fhir/sid/cvx') {
|
|
$display = match ($normCode) {
|
|
'19' => 'BCG',
|
|
'02' => 'OPV',
|
|
'45' => 'Hep B',
|
|
'122' => 'rotavirus, unspecified',
|
|
'10' => 'IPV',
|
|
'198' => 'Pentavalent',
|
|
'152' => 'Pneumococcal Conjugate',
|
|
'04' => 'M/R',
|
|
'39' => 'Japanese Encephalitis',
|
|
'01' => 'DTP',
|
|
'09' => 'Td',
|
|
default => $name,
|
|
};
|
|
}
|
|
$concept['coding'] = [[
|
|
'system' => $normSystem,
|
|
'code' => $normCode,
|
|
'display' => $display,
|
|
]];
|
|
return $concept;
|
|
}
|
|
|
|
$lookupKey = strtoupper(trim($code !== '' ? $code : $name));
|
|
$knownCodes = [
|
|
'BCG' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '19', 'display' => 'BCG'],
|
|
'OPV' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '02', 'display' => 'OPV'],
|
|
'HEPATITIS B' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '45', 'display' => 'Hep B'],
|
|
'HEP B' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '45', 'display' => 'Hep B'],
|
|
'HEPB' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '45', 'display' => 'Hep B'],
|
|
'ROTAVIRUS' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '122', 'display' => 'rotavirus, unspecified'],
|
|
'ROTA' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '122', 'display' => 'rotavirus, unspecified'],
|
|
'FRACTIONAL IPV' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '10', 'display' => 'IPV'],
|
|
'FIPV' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '10', 'display' => 'IPV'],
|
|
'IPV' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '10', 'display' => 'IPV'],
|
|
'PENTAVALENT' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '198', 'display' => 'Pentavalent'],
|
|
'PENTA' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '198', 'display' => 'Pentavalent'],
|
|
'PCV' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '152', 'display' => 'Pneumococcal Conjugate'],
|
|
'MR' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '04', 'display' => 'M/R'],
|
|
'MEASLES' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '05', 'display' => 'measles'],
|
|
'JE' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '39', 'display' => 'Japanese Encephalitis'],
|
|
'DPT' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '01', 'display' => 'DTP'],
|
|
'TD' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '09', 'display' => 'Td'],
|
|
'TT' => ['system' => 'http://hl7.org/fhir/sid/cvx', 'code' => '09', 'display' => 'tetanus toxoid'],
|
|
'COVID' => ['system' => 'http://snomed.info/sct', 'code' => '1119305005', 'display' => 'COVID-19 vaccine'],
|
|
];
|
|
|
|
foreach ($knownCodes as $prefix => $target) {
|
|
if (str_contains($lookupKey, $prefix)) {
|
|
$concept['coding'] = [$target];
|
|
return $concept;
|
|
}
|
|
}
|
|
|
|
if ($normSystem !== '' && $normCode !== '') {
|
|
$concept['coding'] = [[
|
|
'system' => $normSystem,
|
|
'code' => $normCode,
|
|
'display' => $name,
|
|
]];
|
|
return $concept;
|
|
}
|
|
|
|
if (empty($concept['coding']) && $name !== '') {
|
|
$concept['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '774203006',
|
|
'display' => $name,
|
|
]];
|
|
}
|
|
|
|
return $concept;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function resolveSiteCoding(string $code, string $name): array
|
|
{
|
|
$code = trim($code);
|
|
$name = trim($name);
|
|
if ($code === '' && $name === '') {
|
|
return [];
|
|
}
|
|
|
|
$search = strtolower($code . ' ' . $name);
|
|
$snomedCode = '';
|
|
$display = '';
|
|
|
|
if (str_contains($search, 'right upper arm') || str_contains($search, 'right arm') || str_contains($search, 'right deltoid')) {
|
|
$snomedCode = '368209003';
|
|
$display = 'Right upper arm structure';
|
|
} elseif (str_contains($search, 'left upper arm') || str_contains($search, 'left arm') || str_contains($search, 'left deltoid')) {
|
|
$snomedCode = '368208006';
|
|
$display = 'Left upper arm structure';
|
|
} elseif (str_contains($search, 'left thigh') || str_contains($search, 'anterolateral aspect of left thigh')) {
|
|
$snomedCode = '61396006';
|
|
$display = 'Left thigh structure';
|
|
} elseif (str_contains($search, 'right thigh') || str_contains($search, 'anterolateral aspect of right thigh')) {
|
|
$snomedCode = '11207009';
|
|
$display = 'Right thigh structure';
|
|
} elseif (str_contains($search, 'mouth') || str_contains($search, 'oral')) {
|
|
$snomedCode = '123851003';
|
|
$display = 'Mouth region structure';
|
|
} elseif ($code !== '' && preg_match('/^\d+$/', $code) === 1) {
|
|
$snomedCode = $code;
|
|
$display = $name !== '' ? $name : 'Anatomical site';
|
|
}
|
|
|
|
$res = [];
|
|
if ($name !== '') {
|
|
$res['text'] = $name;
|
|
}
|
|
if ($snomedCode !== '') {
|
|
$res['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $snomedCode,
|
|
'display' => $display,
|
|
]];
|
|
if (empty($res['text'])) {
|
|
$res['text'] = $display;
|
|
}
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function resolveRouteCoding(string $code, string $name): array
|
|
{
|
|
$code = trim($code);
|
|
$name = trim($name);
|
|
if ($code === '' && $name === '') {
|
|
return [];
|
|
}
|
|
|
|
$search = strtolower($code . ' ' . $name);
|
|
$snomedCode = '';
|
|
$display = '';
|
|
|
|
if (str_contains($search, 'intradermal') || preg_match('/\b(id)\b/i', $search) === 1) {
|
|
$snomedCode = '372464004';
|
|
$display = 'Intradermal use';
|
|
} elseif (str_contains($search, 'intramuscular') || preg_match('/\b(im)\b/i', $search) === 1) {
|
|
$snomedCode = '78421000';
|
|
$display = 'Intramuscular route';
|
|
} elseif (str_contains($search, 'subcutaneous') || preg_match('/\b(sc|subq)\b/i', $search) === 1) {
|
|
$snomedCode = '34206005';
|
|
$display = 'Subcutaneous route';
|
|
} elseif (str_contains($search, 'oral') || preg_match('/\b(po)\b/i', $search) === 1) {
|
|
$snomedCode = '260548002';
|
|
$display = 'Oral route';
|
|
} elseif (str_contains($search, 'intranasal') || str_contains($search, 'nasal')) {
|
|
$snomedCode = '46713006';
|
|
$display = 'Nasal route';
|
|
} elseif ($code !== '' && preg_match('/^\d+$/', $code) === 1) {
|
|
$snomedCode = $code;
|
|
$display = $name !== '' ? $name : 'Route';
|
|
}
|
|
|
|
$res = [];
|
|
if ($name !== '') {
|
|
$res['text'] = $name;
|
|
}
|
|
if ($snomedCode !== '') {
|
|
$res['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $snomedCode,
|
|
'display' => $display,
|
|
]];
|
|
if (empty($res['text'])) {
|
|
$res['text'] = $display;
|
|
}
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function resolveTargetDiseaseCoding(string $code, string $name, string $vaccineName): array
|
|
{
|
|
$code = trim($code);
|
|
$name = trim($name);
|
|
$vaccineName = trim($vaccineName);
|
|
|
|
$search = strtolower($code . ' ' . $name . ' ' . $vaccineName);
|
|
$snomedCode = '';
|
|
$display = '';
|
|
|
|
if ($code !== '' && preg_match('/^\d+$/', $code) === 1) {
|
|
$snomedCode = $code;
|
|
$display = $name !== '' ? $name : 'Target Disease';
|
|
} elseif (str_contains($search, 'polio') || str_contains($search, 'fipv') || str_contains($search, 'ipv') || str_contains($search, 'opv')) {
|
|
$snomedCode = '398102009';
|
|
$display = 'Poliomyelitis';
|
|
} elseif (str_contains($search, 'tuberculosis') || str_contains($search, 'bcg')) {
|
|
$snomedCode = '56717001';
|
|
$display = 'Tuberculosis';
|
|
} elseif (str_contains($search, 'hepb') || str_contains($search, 'hepatitis b')) {
|
|
$snomedCode = '66071002';
|
|
$display = 'Type B viral hepatitis';
|
|
} elseif (str_contains($search, 'rotavirus') || str_contains($search, 'rota')) {
|
|
$snomedCode = '186358004';
|
|
$display = 'Rotavirus infection';
|
|
} elseif (str_contains($search, 'diphtheria')) {
|
|
$snomedCode = '397430003';
|
|
$display = 'Diphtheria';
|
|
} elseif (str_contains($search, 'tetanus') || str_contains($search, 'tt') || str_contains($search, 'td')) {
|
|
$snomedCode = '76902006';
|
|
$display = 'Tetanus';
|
|
} elseif (str_contains($search, 'pertussis') || str_contains($search, 'whooping')) {
|
|
$snomedCode = '27836007';
|
|
$display = 'Pertussis';
|
|
} elseif (str_contains($search, 'measles')) {
|
|
$snomedCode = '14189004';
|
|
$display = 'Measles';
|
|
} elseif (str_contains($search, 'rubella')) {
|
|
$snomedCode = '36653000';
|
|
$display = 'Rubella';
|
|
} elseif (str_contains($search, 'japanese encephalitis') || str_contains($search, 'je')) {
|
|
$snomedCode = '52947006';
|
|
$display = 'Japanese encephalitis';
|
|
} elseif (str_contains($search, 'pneumococcal') || str_contains($search, 'pcv')) {
|
|
$snomedCode = '16814004';
|
|
$display = 'Pneumococcal infectious disease';
|
|
}
|
|
|
|
$res = [];
|
|
$text = $name !== '' ? $name : $display;
|
|
if ($text !== '') {
|
|
$res['text'] = $text;
|
|
}
|
|
if ($snomedCode !== '') {
|
|
$res['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $snomedCode,
|
|
'display' => $display !== '' ? $display : $text,
|
|
]];
|
|
}
|
|
|
|
return $res;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function buildSimpleCodeableConcept(string $system, string $code, string $display, string $text): array
|
|
{
|
|
$concept = [];
|
|
$text = trim($text);
|
|
if ($text !== '') {
|
|
$concept['text'] = $text;
|
|
}
|
|
|
|
$system = trim($system);
|
|
$code = trim($code);
|
|
$display = trim($display);
|
|
if ($system !== '' && $code !== '' && $display !== '') {
|
|
$concept['coding'] = [[
|
|
'system' => $system,
|
|
'code' => $code,
|
|
'display' => $display,
|
|
]];
|
|
}
|
|
|
|
return $concept;
|
|
}
|
|
|
|
/**
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function buildDoseNumberElement(string $value, string $fieldPrefix = 'doseNumber'): array
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return [];
|
|
}
|
|
|
|
if (preg_match('/^[1-9]\d*$/', $value) === 1) {
|
|
return [$fieldPrefix . 'PositiveInt' => (int) $value];
|
|
}
|
|
|
|
return [$fieldPrefix . 'String' => $value];
|
|
}
|
|
|
|
/** 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));
|
|
}
|
|
|
|
/** @param array<string,mixed> $bundle
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function sanitizeBundle(array $bundle): array
|
|
{
|
|
return $this->sanitizeFhirValue($bundle);
|
|
}
|
|
|
|
/** @param mixed $value
|
|
* @return mixed
|
|
*/
|
|
private function sanitizeFhirValue($value)
|
|
{
|
|
if (is_string($value)) {
|
|
$trimmed = trim($value);
|
|
if (in_array(strtoupper($trimmed), ['NA', 'N/A', 'NULL', 'NOT AVAILABLE'], true)) {
|
|
return null;
|
|
}
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $trimmed) === 1) {
|
|
return $this->normalizeIsoDateTime($trimmed);
|
|
}
|
|
return $value;
|
|
}
|
|
if (! is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$clean = [];
|
|
foreach ($value as $key => $nested) {
|
|
$sanitized = $this->sanitizeFhirValue($nested);
|
|
if ($sanitized === null || $sanitized === '' || $sanitized === []) {
|
|
continue;
|
|
}
|
|
$clean[$key] = $sanitized;
|
|
}
|
|
|
|
return $clean;
|
|
}
|
|
|
|
/** 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');
|
|
}
|
|
|
|
/** Excludes blank, 0, or NA placeholder values. */
|
|
public function isMeaningfulValue(string $value): bool
|
|
{
|
|
$v = strtoupper(trim(strip_tags($value)));
|
|
if ($v === '') {
|
|
return false;
|
|
}
|
|
|
|
return ! in_array($v, ['0', 'NA', 'N/A', 'N / A', 'N/ A', 'NONE', 'NIL', 'NULL', 'UNDEFINED', 'UNSPECIFIED'], true);
|
|
}
|
|
|
|
/**
|
|
* Normalize any datetime-like string into ISO 8601 with IST offset.
|
|
*/
|
|
private function normalizeIsoDateTime(string $value, string $fallback = ''): string
|
|
{
|
|
$raw = trim($value);
|
|
if ($raw === '') {
|
|
return trim($fallback);
|
|
}
|
|
|
|
try {
|
|
$hasTimezone = preg_match('/([+-]\d{2}:\d{2}|Z)$/', $raw) === 1;
|
|
$dt = $hasTimezone && strpos($raw, 'T') !== false
|
|
? new \DateTime($raw)
|
|
: new \DateTime($raw, new \DateTimeZone('Asia/Kolkata'));
|
|
|
|
return $dt->setTimezone(new \DateTimeZone('Asia/Kolkata'))->format('Y-m-d\TH:i:sP');
|
|
} catch (\Throwable $e) {
|
|
return trim($fallback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build NRCES-compliant Organization resource with MustSupport telecom and address.
|
|
*
|
|
* @param array<string, mixed> $organization
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildOrganizationResource(array $organization, string $organizationUuid): array
|
|
{
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? ''));
|
|
$name = trim((string) ($organization['name'] ?? ''));
|
|
|
|
$resource = [
|
|
'resourceType' => 'Organization',
|
|
'id' => $organizationUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']],
|
|
'name' => $name,
|
|
];
|
|
|
|
if ($hfrId !== '') {
|
|
$resource['identifier'] = [[
|
|
'type' => ['coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
|
|
'code' => 'PRN',
|
|
'display' => 'Provider number',
|
|
]]],
|
|
'system' => 'https://facility.ndhm.gov.in',
|
|
'value' => $hfrId,
|
|
]];
|
|
}
|
|
|
|
$telecom = [];
|
|
if (isset($organization['telecom']) && is_array($organization['telecom'])) {
|
|
$telecom = $organization['telecom'];
|
|
} else {
|
|
$phone = trim((string) ($organization['phone'] ?? $organization['phone_no'] ?? ''));
|
|
if ($phone !== '') {
|
|
$telecom[] = [
|
|
'system' => 'phone',
|
|
'value' => $phone,
|
|
'use' => 'work',
|
|
];
|
|
}
|
|
$email = trim((string) ($organization['email'] ?? ''));
|
|
if ($email !== '') {
|
|
$telecom[] = [
|
|
'system' => 'email',
|
|
'value' => $email,
|
|
'use' => 'work',
|
|
];
|
|
}
|
|
}
|
|
if (! empty($telecom)) {
|
|
$resource['telecom'] = $telecom;
|
|
}
|
|
|
|
if (isset($organization['address']) && is_array($organization['address'])) {
|
|
$resource['address'] = $organization['address'];
|
|
} else {
|
|
$addr = $this->buildOrganizationAddress($organization);
|
|
if (! empty($addr)) {
|
|
$resource['address'] = [$addr];
|
|
}
|
|
}
|
|
|
|
return $resource;
|
|
}
|
|
|
|
/**
|
|
* Construct structured Organization address from database hospital_setting.
|
|
*
|
|
* @param array<string, mixed> $organization
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildOrganizationAddress(array $organization): array
|
|
{
|
|
$addressText = trim((string) ($organization['address'] ?? ''));
|
|
$line1 = trim((string) ($organization['address_1'] ?? $organization['line1'] ?? ''));
|
|
$line2 = trim((string) ($organization['address_2'] ?? $organization['line2'] ?? ''));
|
|
$city = trim((string) ($organization['city'] ?? ''));
|
|
$state = trim((string) ($organization['state'] ?? ''));
|
|
$pincode = trim((string) ($organization['pincode'] ?? $organization['postalCode'] ?? $organization['pin'] ?? ''));
|
|
|
|
if ($addressText !== '' && ($city === '' || $state === '' || $pincode === '')) {
|
|
if ($pincode === '' && preg_match('/\b(\d{6})\b/', $addressText, $pm)) {
|
|
$pincode = $pm[1];
|
|
}
|
|
if ($state === '' && preg_match('/\b(Uttarakhand|Uttar Pradesh|Delhi|Haryana|Punjab|Maharashtra|Karnataka|Tamil Nadu|Gujarat|Rajasthan|Bihar|West Bengal|Madhya Pradesh|Kerala|Odisha|Telangana|Andhra Pradesh|Assam|Jharkhand)\b/i', $addressText, $sm)) {
|
|
$state = $sm[1];
|
|
}
|
|
if ($city === '' && preg_match('/(?:,\s*|\b)([A-Z][a-zA-Z\s]+?)(?:\s*-\s*\d{6}|\s*,\s*(?:Uttarakhand|Uttar Pradesh|Delhi|Haryana|Punjab|India))/i', $addressText, $cm)) {
|
|
$city = trim($cm[1]);
|
|
}
|
|
}
|
|
|
|
$lines = [];
|
|
if ($line1 !== '') {
|
|
$lines[] = $line1;
|
|
}
|
|
if ($line2 !== '' && ($line1 === '' || ($city === '' && $state === ''))) {
|
|
$lines[] = $line2;
|
|
}
|
|
|
|
if (empty($lines) && $addressText === '' && $city === '' && $state === '' && $pincode === '') {
|
|
return [];
|
|
}
|
|
|
|
$address = [
|
|
'line' => ! empty($lines) ? $lines : [$addressText !== '' ? $addressText : 'Hospital Facility'],
|
|
];
|
|
if ($city !== '') {
|
|
$address['city'] = $city;
|
|
}
|
|
if ($state !== '') {
|
|
$address['state'] = $state;
|
|
}
|
|
if ($pincode !== '') {
|
|
$address['postalCode'] = $pincode;
|
|
}
|
|
$address['country'] = 'IND';
|
|
$address['text'] = $addressText !== '' ? $addressText : implode(', ', array_filter([implode(', ', $lines), $city, $state, $pincode, 'IND']));
|
|
|
|
return $address;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $patient
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildPatientResource(array $patient): array
|
|
{
|
|
// Build structured name: prefer explicit given_name/family_name fields;
|
|
// fall back to splitting the full 'name' string.
|
|
$fullText = trim((string) ($patient['name'] ?? ''));
|
|
$givenName = trim((string) ($patient['given_name'] ?? ''));
|
|
$familyName = trim((string) ($patient['family_name'] ?? ''));
|
|
|
|
if ($givenName === '' && $familyName === '' && $fullText !== '') {
|
|
// Try splitting: last word → family, rest → given
|
|
$parts = preg_split('/\s+/', $fullText, -1, PREG_SPLIT_NO_EMPTY) ?: [$fullText];
|
|
$familyName = count($parts) > 1 ? (string) array_pop($parts) : '';
|
|
$givenName = implode(' ', $parts);
|
|
}
|
|
|
|
$nameText = $fullText !== '' ? $fullText : trim($givenName . ' ' . $familyName);
|
|
$nameEntry = ['text' => $nameText];
|
|
if ($givenName !== '') {
|
|
$nameEntry['given'] = [$givenName];
|
|
}
|
|
if ($familyName !== '') {
|
|
$nameEntry['family'] = $familyName;
|
|
}
|
|
|
|
$resource = [
|
|
'resourceType' => 'Patient',
|
|
'id' => (string) ($patient['id'] ?? 'unknown'),
|
|
'name' => [$nameEntry],
|
|
];
|
|
|
|
$gender = $this->normalizeAdministrativeGender($patient['gender'] ?? null);
|
|
if ($gender !== '') {
|
|
$resource['gender'] = $gender;
|
|
}
|
|
|
|
if (! empty($patient['birthDate'])) {
|
|
$resource['birthDate'] = (string) $patient['birthDate'];
|
|
}
|
|
|
|
$abhaAddress = trim((string) ($patient['abhaAddress'] ?? ''));
|
|
if ($abhaAddress !== '') {
|
|
$resource['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient']];
|
|
// NI = National unique individual identifier (ABHA number / address)
|
|
$resource['identifier'] = [[
|
|
'type' => ['coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
|
|
'code' => 'NI',
|
|
'display' => 'National unique individual identifier',
|
|
]]],
|
|
'system' => 'https://healthid.ndhm.gov.in',
|
|
'value' => $abhaAddress,
|
|
]];
|
|
}
|
|
|
|
$uhid = trim((string) ($patient['uhid'] ?? $patient['patient_code'] ?? $patient['p_code'] ?? $patient['hospital_patient_id'] ?? ''));
|
|
if ($this->isMeaningfulValue($uhid)) {
|
|
if (! isset($resource['identifier']) || ! is_array($resource['identifier'])) {
|
|
$resource['identifier'] = [];
|
|
}
|
|
$resource['identifier'][] = [
|
|
'type' => ['coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203',
|
|
'code' => 'MR',
|
|
'display' => 'Medical record number',
|
|
]], 'text' => 'UHID'],
|
|
'system' => 'https://hms.local/uhid',
|
|
'value' => $uhid,
|
|
];
|
|
}
|
|
|
|
// Phone / mobile telecom
|
|
$phone = trim((string) ($patient['phone'] ?? ''));
|
|
if ($phone !== '') {
|
|
$resource['telecom'] = [[
|
|
'system' => 'phone',
|
|
'value' => $phone,
|
|
'use' => 'mobile',
|
|
]];
|
|
}
|
|
|
|
return $resource;
|
|
}
|
|
|
|
/**
|
|
* Convert legacy/local gender values to FHIR AdministrativeGender.
|
|
*/
|
|
private function normalizeAdministrativeGender($value): string
|
|
{
|
|
$raw = strtolower(trim((string) $value));
|
|
if ($raw === '') {
|
|
return '';
|
|
}
|
|
|
|
return match ($raw) {
|
|
'm', 'male', '1' => 'male',
|
|
'f', 'female', '2' => 'female',
|
|
'o', 'other', '3', 'transgender', 'trans' => 'other',
|
|
'u', 'unknown', '0', 'na', 'n/a', 'not known' => 'unknown',
|
|
default => in_array($raw, ['male', 'female', 'other', 'unknown'], true) ? $raw : 'unknown',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map common route-of-administration text to SNOMED CT codes.
|
|
* Returns empty string when no mapping is found.
|
|
*/
|
|
private function resolveRouteSnomedCode(string $routeText): string
|
|
{
|
|
static $map = [
|
|
// Oral
|
|
'oral' => '26643006',
|
|
'by mouth' => '26643006',
|
|
'po' => '26643006',
|
|
'per oral' => '26643006',
|
|
// Intravenous
|
|
'intravenous' => '47625008',
|
|
'iv' => '47625008',
|
|
'i.v.' => '47625008',
|
|
'intravenously' => '47625008',
|
|
// Intramuscular
|
|
'intramuscular' => '78421000',
|
|
'im' => '78421000',
|
|
'i.m.' => '78421000',
|
|
// Subcutaneous
|
|
'subcutaneous' => '34206005',
|
|
'sc' => '34206005',
|
|
's.c.' => '34206005',
|
|
'subcut' => '34206005',
|
|
// Topical
|
|
'topical' => '6064005',
|
|
'locally' => '6064005',
|
|
// Inhaled / nebulisation
|
|
'inhaled' => '18679011000001101',
|
|
'inhalation' => '18679011000001101',
|
|
'nebulisation' => '18679011000001101',
|
|
'nebulizer' => '18679011000001101',
|
|
// Sublingual
|
|
'sublingual' => '37839007',
|
|
'sl' => '37839007',
|
|
// Rectal
|
|
'rectal' => '37161004',
|
|
'per rectal' => '37161004',
|
|
'pr' => '37161004',
|
|
// Nasal
|
|
'nasal' => '46713006',
|
|
'intranasal' => '46713006',
|
|
// Ophthalmic / eye
|
|
'ophthalmic' => '54485002',
|
|
'eye' => '54485002',
|
|
'otic' => '10547007',
|
|
'ear' => '10547007',
|
|
];
|
|
$key = strtolower(trim($routeText));
|
|
return $map[$key] ?? '';
|
|
}
|
|
|
|
// =========================================================================
|
|
// Enhanced FHIR Bundle Builders (ABDM M3 Compliant)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Build a full DiagnosticReport FHIR Bundle (ABDM M3 — DiagnosticReportRecord).
|
|
*
|
|
* Supports LOINC-coded observations, specimen metadata, Practitioner performer,
|
|
* and an optional scanned-document attachment via DocumentReference + Binary.
|
|
*
|
|
* @param array<string, mixed> $patient Patient demographics
|
|
* @param array<string, mixed> $diagnosticReport {id, title, category_loinc,
|
|
* category_display, status, conclusion, reported_at}
|
|
* @param array<int, array<string, mixed>> $observations Each: {test_name, loinc_code, value,
|
|
* value_type (quantity|string), unit, ucum_code,
|
|
* reference_range_low, reference_range_high,
|
|
* interpretation, status}
|
|
* @param array<string, mixed>|null $specimen {id, type_text, collection_date}
|
|
* @param array<string, mixed>|null $attachment {content_type, data_base64, title}
|
|
* @param array<string, mixed>|null $practitioner {id, name, registration_number}
|
|
* @param array<string, mixed>|null $organization {id, name, hfr_id}
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildEnhancedLabReportBundle(
|
|
array $patient,
|
|
array $diagnosticReport,
|
|
array $observations = [],
|
|
?array $specimen = null,
|
|
?array $attachment = null,
|
|
?array $practitioner = null,
|
|
?array $organization = null
|
|
): array {
|
|
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
|
$reportId = 'lab-rpt-' . (string) ($diagnosticReport['id'] ?? date('YmdHis'));
|
|
|
|
$entries = [['resource' => $this->buildPatientResource($patient)]];
|
|
|
|
$practitionerRef = '';
|
|
$organizationRef = '';
|
|
|
|
if ($practitioner !== null) {
|
|
$practId = trim((string) ($practitioner['id'] ?? ''));
|
|
$practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : '';
|
|
if ($practitionerRef !== '') {
|
|
$practRes = [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practId,
|
|
'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]],
|
|
];
|
|
$regNo = trim((string) ($practitioner['registration_number'] ?? ''));
|
|
if ($regNo !== '') {
|
|
$practRes['identifier'] = [[
|
|
'system' => 'https://hpr.abdm.gov.in/hpr-id',
|
|
'value' => $regNo,
|
|
]];
|
|
}
|
|
$entries[] = ['resource' => $practRes];
|
|
}
|
|
}
|
|
|
|
if ($organization !== null) {
|
|
$orgId = trim((string) ($organization['id'] ?? ''));
|
|
$organizationRef = $orgId !== '' ? ('Organization/' . $orgId) : '';
|
|
if ($organizationRef !== '') {
|
|
$orgRes = [
|
|
'resourceType' => 'Organization',
|
|
'id' => $orgId,
|
|
'name' => trim((string) ($organization['name'] ?? '')),
|
|
];
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
|
if ($hfrId !== '') {
|
|
$orgRes['identifier'] = [[
|
|
'system' => 'https://facility.abdm.gov.in/hfr',
|
|
'value' => $hfrId,
|
|
]];
|
|
}
|
|
$entries[] = ['resource' => $orgRes];
|
|
}
|
|
}
|
|
|
|
// Specimen (optional)
|
|
$specimenRef = '';
|
|
if ($specimen !== null) {
|
|
$specimenId = 'specimen-' . (string) ($specimen['id'] ?? '1');
|
|
$specimenRef = 'Specimen/' . $specimenId;
|
|
$specimenRes = [
|
|
'resourceType' => 'Specimen',
|
|
'id' => $specimenId,
|
|
'subject' => ['reference' => $patientRef],
|
|
'type' => ['text' => (string) ($specimen['type_text'] ?? 'Blood')],
|
|
];
|
|
$collDate = trim((string) ($specimen['collection_date'] ?? ''));
|
|
if ($collDate !== '') {
|
|
$specimenRes['collection'] = ['collectedDateTime' => $collDate];
|
|
}
|
|
$entries[] = ['resource' => $specimenRes];
|
|
}
|
|
|
|
// Observations
|
|
$observationRefs = [];
|
|
foreach ($observations as $index => $obs) {
|
|
$obsId = 'obs-' . ($index + 1);
|
|
$observationRefs[] = ['reference' => 'Observation/' . $obsId];
|
|
|
|
$loincCode = trim((string) ($obs['loinc_code'] ?? ''));
|
|
$testName = (string) ($obs['test_name'] ?? '');
|
|
$valueType = trim((string) ($obs['value_type'] ?? 'string'));
|
|
$rawValue = $obs['value'] ?? '';
|
|
$unit = (string) ($obs['unit'] ?? '');
|
|
$ucumCode = (string) ($obs['ucum_code'] ?? $unit);
|
|
$interpText = (string) ($obs['interpretation'] ?? '');
|
|
|
|
$code = ['text' => $testName];
|
|
if ($loincCode !== '') {
|
|
$code['coding'] = [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => $loincCode,
|
|
'display' => $testName,
|
|
]];
|
|
}
|
|
|
|
$obsResource = [
|
|
'resourceType' => 'Observation',
|
|
'id' => $obsId,
|
|
'status' => (string) ($obs['status'] ?? 'final'),
|
|
'category' => [[
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
|
|
'code' => 'laboratory',
|
|
'display' => 'Laboratory',
|
|
]],
|
|
]],
|
|
'code' => $code,
|
|
'subject' => ['reference' => $patientRef],
|
|
'issued' => $issuedAt,
|
|
];
|
|
|
|
if ($specimenRef !== '') {
|
|
$obsResource['specimen'] = ['reference' => $specimenRef];
|
|
}
|
|
if ($practitionerRef !== '') {
|
|
$obsResource['performer'] = [['reference' => $practitionerRef]];
|
|
}
|
|
|
|
if ($valueType === 'quantity' && is_numeric($rawValue)) {
|
|
$obsResource['valueQuantity'] = [
|
|
'value' => (float) $rawValue,
|
|
'unit' => $unit,
|
|
'system' => 'http://unitsofmeasure.org',
|
|
'code' => $ucumCode !== '' ? $ucumCode : $unit,
|
|
];
|
|
} else {
|
|
$obsResource['valueString'] = (string) $rawValue;
|
|
}
|
|
|
|
$refLow = $obs['reference_range_low'] ?? null;
|
|
$refHigh = $obs['reference_range_high'] ?? null;
|
|
if ($refLow !== null || $refHigh !== null) {
|
|
$refRange = [];
|
|
if (is_numeric($refLow)) { $refRange['low'] = ['value' => (float) $refLow, 'unit' => $unit]; }
|
|
if (is_numeric($refHigh)) { $refRange['high'] = ['value' => (float) $refHigh, 'unit' => $unit]; }
|
|
if (! empty($refRange)) {
|
|
$obsResource['referenceRange'] = [$refRange];
|
|
}
|
|
}
|
|
|
|
if ($interpText !== '') {
|
|
$obsResource['interpretation'] = [['text' => $interpText]];
|
|
}
|
|
|
|
$entries[] = ['resource' => $obsResource];
|
|
}
|
|
|
|
// Optional scanned attachment
|
|
$docRefRef = '';
|
|
if ($attachment !== null && ! empty($attachment['data_base64'])) {
|
|
$docEntries = $this->buildDocumentReferenceEntry(
|
|
'doc-lab-' . $reportId,
|
|
(string) ($attachment['title'] ?? 'Lab Report Document'),
|
|
(string) ($attachment['content_type'] ?? 'application/pdf'),
|
|
(string) $attachment['data_base64'],
|
|
$patientRef,
|
|
$issuedAt
|
|
);
|
|
$docRefRef = 'DocumentReference/doc-lab-' . $reportId;
|
|
foreach ($docEntries as $entry) {
|
|
$entries[] = $entry;
|
|
}
|
|
}
|
|
|
|
$performers = [];
|
|
if ($practitionerRef !== '') { $performers[] = ['reference' => $practitionerRef]; }
|
|
if ($organizationRef !== '') { $performers[] = ['reference' => $organizationRef]; }
|
|
|
|
$categoryLoinc = trim((string) ($diagnosticReport['category_loinc'] ?? '26436-6'));
|
|
$categoryDisplay = trim((string) ($diagnosticReport['category_display'] ?? 'Laboratory studies (procedure)'));
|
|
|
|
$drResource = [
|
|
'resourceType' => 'DiagnosticReport',
|
|
'id' => $reportId,
|
|
'status' => (string) ($diagnosticReport['status'] ?? 'final'),
|
|
'category' => [[
|
|
'coding' => [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => $categoryLoinc,
|
|
'display' => $categoryDisplay,
|
|
]],
|
|
]],
|
|
'code' => ['text' => (string) ($diagnosticReport['title'] ?? 'Laboratory Report')],
|
|
'subject' => ['reference' => $patientRef],
|
|
'issued' => (string) ($diagnosticReport['reported_at'] ?? $issuedAt),
|
|
'result' => $observationRefs,
|
|
'conclusion' => (string) ($diagnosticReport['conclusion'] ?? ''),
|
|
];
|
|
|
|
if ($specimenRef !== '') {
|
|
$drResource['specimen'] = [['reference' => $specimenRef]];
|
|
}
|
|
if (! empty($performers)) {
|
|
$drResource['performer'] = $performers;
|
|
}
|
|
if ($docRefRef !== '') {
|
|
$drResource['presentedForm'] = [[
|
|
'contentType' => (string) ($attachment['content_type'] ?? 'application/pdf'),
|
|
'title' => (string) ($attachment['title'] ?? 'Lab Report'),
|
|
]];
|
|
}
|
|
$entries[] = ['resource' => $drResource];
|
|
|
|
// Composition (LOINC 11502-2 = Laboratory report)
|
|
$compositionSections = [
|
|
['title' => 'Lab Results', 'entry' => $observationRefs],
|
|
];
|
|
if ($docRefRef !== '') {
|
|
$compositionSections[] = [
|
|
'title' => 'Report Document',
|
|
'entry' => [['reference' => $docRefRef]],
|
|
];
|
|
}
|
|
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => 'comp-' . $reportId,
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => '11502-2',
|
|
'display' => 'Laboratory report',
|
|
]],
|
|
'text' => 'Diagnostic Report - Lab',
|
|
],
|
|
'subject' => ['reference' => $patientRef],
|
|
'date' => $issuedAt,
|
|
'title' => (string) ($diagnosticReport['title'] ?? 'Laboratory Report'),
|
|
'section' => $compositionSections,
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$composition['author'] = [['reference' => $practitionerRef]];
|
|
}
|
|
$entries[] = ['resource' => $composition];
|
|
|
|
return [
|
|
'resourceType' => 'Bundle',
|
|
'identifier' => [
|
|
'system' => 'urn:ietf:rfc:3986',
|
|
'value' => 'urn:uuid:bundle-lab-' . $reportId . '-' . date('YmdHis'),
|
|
],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => $entries,
|
|
];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a full Discharge Summary FHIR Bundle (ABDM M3 — DischargeSummaryRecord).
|
|
*
|
|
* Covers all mandatory composition sections: chief complaints, diagnosis,
|
|
* allergies, procedures, medications, investigations, care plan, and a
|
|
* free-text clinical notes narrative.
|
|
*
|
|
* @param array<string, mixed> $patient Patient demographics
|
|
* @param array<string, mixed> $practitioner {id, name, registration_number}
|
|
* @param array<string, mixed> $organization {id, name, hfr_id}
|
|
* @param array<string, mixed> $encounter {id, admission_date, discharge_date}
|
|
* @param array<string, mixed> $summary {
|
|
* title, chief_complaints[], conditions[], allergies[],
|
|
* procedures[], medications[], investigations[], care_plan,
|
|
* follow_up_date, clinical_notes_html
|
|
* }
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildEnhancedDischargeSummaryBundle(
|
|
array $patient,
|
|
array $practitioner,
|
|
array $organization,
|
|
array $encounter,
|
|
array $summary
|
|
): array {
|
|
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
$encounterId = (string) ($encounter['id'] ?? 'enc-' . date('YmdHis'));
|
|
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
|
$encounterRef = 'Encounter/' . $encounterId;
|
|
|
|
$practId = trim((string) ($practitioner['id'] ?? ''));
|
|
$orgId = trim((string) ($organization['id'] ?? ''));
|
|
$practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : '';
|
|
$organizationRef = $orgId !== '' ? ('Organization/' . $orgId) : '';
|
|
|
|
$entries = [['resource' => $this->buildPatientResource($patient)]];
|
|
|
|
// Practitioner
|
|
if ($practitionerRef !== '') {
|
|
$practRes = [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practId,
|
|
'name' => [['text' => (string) ($practitioner['name'] ?? '')]],
|
|
];
|
|
$regNo = trim((string) ($practitioner['registration_number'] ?? ''));
|
|
if ($regNo !== '') {
|
|
$practRes['identifier'] = [[
|
|
'system' => 'https://hpr.abdm.gov.in/hpr-id',
|
|
'value' => $regNo,
|
|
]];
|
|
}
|
|
$entries[] = ['resource' => $practRes];
|
|
}
|
|
|
|
// Organization
|
|
if ($organizationRef !== '') {
|
|
$orgRes = [
|
|
'resourceType' => 'Organization',
|
|
'id' => $orgId,
|
|
'name' => (string) ($organization['name'] ?? ''),
|
|
];
|
|
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
|
if ($hfrId !== '') {
|
|
$orgRes['identifier'] = [[
|
|
'system' => 'https://facility.abdm.gov.in/hfr',
|
|
'value' => $hfrId,
|
|
]];
|
|
}
|
|
$entries[] = ['resource' => $orgRes];
|
|
}
|
|
|
|
// Encounter (inpatient)
|
|
$encounterResource = [
|
|
'resourceType' => 'Encounter',
|
|
'id' => $encounterId,
|
|
'status' => 'finished',
|
|
'class' => [
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
|
|
'code' => 'IMP',
|
|
'display' => 'inpatient encounter',
|
|
],
|
|
'subject' => ['reference' => $patientRef],
|
|
'period' => [
|
|
'start' => (string) ($encounter['admission_date'] ?? $encounter['period_start'] ?? $issuedAt),
|
|
'end' => (string) ($encounter['discharge_date'] ?? $encounter['period_end'] ?? $issuedAt),
|
|
],
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]];
|
|
}
|
|
if ($organizationRef !== '') {
|
|
$encounterResource['serviceProvider'] = ['reference' => $organizationRef];
|
|
}
|
|
$entries[] = ['resource' => $encounterResource];
|
|
|
|
$sections = [];
|
|
|
|
// --- Chief Complaints ---
|
|
$complaintRefs = [];
|
|
foreach ((array) ($summary['chief_complaints'] ?? []) as $idx => $complaint) {
|
|
$text = is_array($complaint)
|
|
? trim((string) ($complaint['text'] ?? ''))
|
|
: trim((string) $complaint);
|
|
if ($text === '') { continue; }
|
|
|
|
$condId = 'cc-' . ($idx + 1);
|
|
$code = ['text' => $text];
|
|
$snomedCode = is_array($complaint) ? trim((string) ($complaint['snomed_code'] ?? '')) : '';
|
|
if ($snomedCode !== '') {
|
|
$code['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $text]];
|
|
}
|
|
|
|
$complaintRefs[] = ['reference' => 'Condition/' . $condId];
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'Condition',
|
|
'id' => $condId,
|
|
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active']]],
|
|
'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],
|
|
]];
|
|
}
|
|
if (! empty($complaintRefs)) {
|
|
$sections[] = ['title' => 'Chief Complaints', 'entry' => $complaintRefs];
|
|
}
|
|
|
|
// --- Diagnosis / Conditions ---
|
|
$conditionRefs = [];
|
|
$encounterDiagnosis = [];
|
|
foreach ((array) ($summary['conditions'] ?? []) as $idx => $cond) {
|
|
$text = is_array($cond) ? trim((string) ($cond['text'] ?? '')) : trim((string) $cond);
|
|
if ($text === '') { continue; }
|
|
|
|
$condId = 'diag-' . ($idx + 1);
|
|
$code = ['text' => $text];
|
|
$snomedCode = is_array($cond) ? trim((string) ($cond['snomed_code'] ?? '')) : '';
|
|
if ($snomedCode !== '') {
|
|
$code['coding'] = [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $snomedCode,
|
|
'display' => is_array($cond) ? (string) ($cond['snomed_display'] ?? $text) : $text,
|
|
]];
|
|
}
|
|
|
|
$verStatus = is_array($cond) && strtolower((string) ($cond['verification_status'] ?? '')) === 'confirmed'
|
|
? 'confirmed'
|
|
: 'provisional';
|
|
|
|
$conditionRefs[] = ['reference' => 'Condition/' . $condId];
|
|
$encounterDiagnosis[] = ['condition' => ['reference' => 'Condition/' . $condId]];
|
|
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'Condition',
|
|
'id' => $condId,
|
|
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active']]],
|
|
'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', 'code' => $verStatus]]],
|
|
'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'encounter-diagnosis']]]],
|
|
'code' => $code,
|
|
'subject' => ['reference' => $patientRef],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
]];
|
|
}
|
|
if (! empty($conditionRefs)) {
|
|
$sections[] = ['title' => 'Diagnosis', 'entry' => $conditionRefs];
|
|
// Back-patch encounter diagnosis references
|
|
$encounterResource['diagnosis'] = $encounterDiagnosis;
|
|
}
|
|
|
|
// --- Allergies ---
|
|
$allergyRefs = [];
|
|
foreach ((array) ($summary['allergies'] ?? []) as $idx => $allergy) {
|
|
$codeText = is_array($allergy)
|
|
? trim((string) ($allergy['code_text'] ?? ''))
|
|
: trim((string) $allergy);
|
|
if ($codeText === '') { continue; }
|
|
|
|
$allergyId = 'allergy-ds-' . ($idx + 1);
|
|
$allergyRefs[] = ['reference' => 'AllergyIntolerance/' . $allergyId];
|
|
$allergyRes = [
|
|
'resourceType' => 'AllergyIntolerance',
|
|
'id' => $allergyId,
|
|
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical', 'code' => 'active']]],
|
|
'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification', 'code' => 'confirmed']]],
|
|
'patient' => ['reference' => $patientRef],
|
|
'code' => ['text' => $codeText],
|
|
];
|
|
$reaction = is_array($allergy) ? trim((string) ($allergy['reaction_text'] ?? '')) : '';
|
|
if ($reaction !== '') {
|
|
$allergyRes['reaction'] = [['description' => $reaction]];
|
|
}
|
|
$entries[] = ['resource' => $allergyRes];
|
|
}
|
|
if (! empty($allergyRefs)) {
|
|
$sections[] = ['title' => 'Allergies', 'entry' => $allergyRefs];
|
|
}
|
|
|
|
// --- Procedures ---
|
|
$procedureRefs = [];
|
|
foreach ((array) ($summary['procedures'] ?? []) as $idx => $procedure) {
|
|
$text = is_array($procedure)
|
|
? trim((string) ($procedure['text'] ?? ''))
|
|
: trim((string) $procedure);
|
|
if ($text === '') { continue; }
|
|
|
|
$procId = 'proc-' . ($idx + 1);
|
|
$code = ['text' => $text];
|
|
$snomedCode = is_array($procedure) ? trim((string) ($procedure['snomed_code'] ?? '')) : '';
|
|
if ($snomedCode !== '') {
|
|
$code['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $text]];
|
|
}
|
|
|
|
$procedureRefs[] = ['reference' => 'Procedure/' . $procId];
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'Procedure',
|
|
'id' => $procId,
|
|
'status' => is_array($procedure) ? (string) ($procedure['status'] ?? 'completed') : 'completed',
|
|
'code' => $code,
|
|
'subject' => ['reference' => $patientRef],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
]];
|
|
}
|
|
if (! empty($procedureRefs)) {
|
|
$sections[] = ['title' => 'Procedures', 'entry' => $procedureRefs];
|
|
}
|
|
|
|
// --- Medications ---
|
|
$medicationRefs = [];
|
|
foreach ((array) ($summary['medications'] ?? []) as $idx => $med) {
|
|
$drugName = is_array($med) ? trim((string) ($med['drug_name'] ?? '')) : trim((string) $med);
|
|
if ($drugName === '') { continue; }
|
|
|
|
$medId = 'medreq-ds-' . ($idx + 1);
|
|
$mc = ['text' => $drugName];
|
|
$snomedCode = is_array($med) ? trim((string) ($med['snomed_code'] ?? '')) : '';
|
|
$atcCode = is_array($med) ? strtoupper(trim((string) ($med['atc_code'] ?? ''))) : '';
|
|
if ($snomedCode !== '') {
|
|
$mc['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $drugName]];
|
|
} elseif ($atcCode !== '') {
|
|
$mc['coding'] = [['system' => 'http://www.whocc.no/atc', 'code' => $atcCode, 'display' => $drugName]];
|
|
}
|
|
|
|
$medicationRefs[] = ['reference' => 'MedicationRequest/' . $medId];
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'MedicationRequest',
|
|
'id' => $medId,
|
|
'status' => 'active',
|
|
'intent' => 'order',
|
|
'subject' => ['reference' => $patientRef],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'medicationCodeableConcept' => $mc,
|
|
'dosageInstruction' => [['text' => is_array($med) ? (string) ($med['dosage'] ?? '') : '']],
|
|
]];
|
|
}
|
|
if (! empty($medicationRefs)) {
|
|
$sections[] = ['title' => 'Medications', 'entry' => $medicationRefs];
|
|
}
|
|
|
|
// --- Investigations ---
|
|
$investigationRefs = [];
|
|
foreach ((array) ($summary['investigations'] ?? []) as $idx => $inv) {
|
|
$text = is_array($inv) ? trim((string) ($inv['text'] ?? '')) : trim((string) $inv);
|
|
if ($text === '') { continue; }
|
|
|
|
$svcId = 'svc-ds-' . ($idx + 1);
|
|
$investigationRefs[] = ['reference' => 'ServiceRequest/' . $svcId];
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'ServiceRequest',
|
|
'id' => $svcId,
|
|
'status' => 'completed',
|
|
'intent' => 'order',
|
|
'code' => ['text' => $text],
|
|
'subject' => ['reference' => $patientRef],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
]];
|
|
}
|
|
if (! empty($investigationRefs)) {
|
|
$sections[] = ['title' => 'Investigations', 'entry' => $investigationRefs];
|
|
}
|
|
|
|
// --- Care Plan ---
|
|
$carePlanRefs = [];
|
|
$carePlanText = trim((string) ($summary['care_plan'] ?? ''));
|
|
if ($carePlanText !== '') {
|
|
$cpId = 'careplan-1';
|
|
$carePlanRefs[] = ['reference' => 'CarePlan/' . $cpId];
|
|
$cpResource = [
|
|
'resourceType' => 'CarePlan',
|
|
'id' => $cpId,
|
|
'status' => 'active',
|
|
'intent' => 'plan',
|
|
'subject' => ['reference' => $patientRef],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'description' => $carePlanText,
|
|
];
|
|
$followUpDate = trim((string) ($summary['follow_up_date'] ?? ''));
|
|
if ($followUpDate !== '') {
|
|
$cpResource['period'] = ['start' => $issuedAt, 'end' => $followUpDate];
|
|
}
|
|
$entries[] = ['resource' => $cpResource];
|
|
$sections[] = ['title' => 'Care Plan', 'entry' => $carePlanRefs];
|
|
}
|
|
|
|
// Clinical notes narrative (unstructured)
|
|
$clinicalNotesHtml = (string) ($summary['clinical_notes_html'] ?? '');
|
|
|
|
// Composition (LOINC 18842-5 = Discharge summary)
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => 'comp-discharge-' . $encounterId,
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => '18842-5',
|
|
'display' => 'Discharge summary',
|
|
]],
|
|
'text' => 'Discharge Summary',
|
|
],
|
|
'subject' => ['reference' => $patientRef],
|
|
'encounter' => ['reference' => $encounterRef],
|
|
'date' => $issuedAt,
|
|
'title' => (string) ($summary['title'] ?? 'Discharge Summary'),
|
|
'section' => $sections,
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$composition['author'] = [['reference' => $practitionerRef]];
|
|
}
|
|
if ($clinicalNotesHtml !== '') {
|
|
$composition['section'][] = [
|
|
'title' => 'Clinical Summary Notes',
|
|
'text' => ['status' => 'generated', 'div' => $clinicalNotesHtml],
|
|
];
|
|
}
|
|
$entries[] = ['resource' => $composition];
|
|
|
|
return [
|
|
'resourceType' => 'Bundle',
|
|
'identifier' => [
|
|
'system' => 'urn:ietf:rfc:3986',
|
|
'value' => 'urn:uuid:bundle-discharge-' . $encounterId . '-' . date('YmdHis'),
|
|
],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => $entries,
|
|
];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a WellnessRecord FHIR Bundle (ABDM M3 — WellnessRecord).
|
|
*
|
|
* Contains vital-sign / body-measurement Observations and
|
|
* social-history lifestyle Observations.
|
|
*
|
|
* @param array<string, mixed> $patient Patient demographics
|
|
* @param array<int, array<string, mixed>> $vitals Each: {loinc_code, display, value,
|
|
* value_type (quantity|string), unit, ucum_code}
|
|
* @param array<int, array<string, mixed>> $lifestyle Each: {code, display, value, system}
|
|
* @param array<string, mixed>|null $practitioner {id, name}
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildWellnessBundle(
|
|
array $patient,
|
|
array $vitals = [],
|
|
array $lifestyle = [],
|
|
?array $practitioner = null
|
|
): array {
|
|
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
|
$entries = [['resource' => $this->buildPatientResource($patient)]];
|
|
|
|
$practitionerRef = '';
|
|
if ($practitioner !== null) {
|
|
$practId = trim((string) ($practitioner['id'] ?? ''));
|
|
$practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : '';
|
|
if ($practitionerRef !== '') {
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practId,
|
|
'name' => [['text' => (string) ($practitioner['name'] ?? '')]],
|
|
]];
|
|
}
|
|
}
|
|
|
|
$observationRefs = [];
|
|
|
|
// Vital signs / body measurements
|
|
foreach ($vitals as $idx => $vital) {
|
|
$loincCode = trim((string) ($vital['loinc_code'] ?? ''));
|
|
$display = (string) ($vital['display'] ?? '');
|
|
$value = $vital['value'] ?? '';
|
|
$unit = (string) ($vital['unit'] ?? '');
|
|
$ucumCode = (string) ($vital['ucum_code'] ?? $unit);
|
|
$valueType = (string) ($vital['value_type'] ?? 'quantity');
|
|
|
|
if ($valueType === 'quantity' && ! is_numeric($value)) { continue; }
|
|
if (trim((string) $value) === '') { continue; }
|
|
|
|
$obsId = 'wellness-obs-' . ($idx + 1);
|
|
$observationRefs[] = ['reference' => 'Observation/' . $obsId];
|
|
|
|
$code = ['text' => $display];
|
|
if ($loincCode !== '') {
|
|
$code['coding'] = [['system' => 'http://loinc.org', 'code' => $loincCode, 'display' => $display]];
|
|
}
|
|
|
|
$obsResource = [
|
|
'resourceType' => 'Observation',
|
|
'id' => $obsId,
|
|
'status' => 'final',
|
|
'category' => [[
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
|
|
'code' => 'vital-signs',
|
|
'display' => 'Vital Signs',
|
|
]],
|
|
]],
|
|
'code' => $code,
|
|
'subject' => ['reference' => $patientRef],
|
|
'effectiveDateTime' => $issuedAt,
|
|
];
|
|
|
|
if ($practitionerRef !== '') {
|
|
$obsResource['performer'] = [['reference' => $practitionerRef]];
|
|
}
|
|
|
|
if ($valueType === 'quantity' && is_numeric($value)) {
|
|
$num = (float) $value;
|
|
if ($loincCode === '8310-5' || strcasecmp($display, 'Body temperature') === 0 || in_array($unit, ['Cel', 'degF', '[degF]'], true)) {
|
|
$num = round($num, 1);
|
|
} elseif ($loincCode === '39156-5' || strcasecmp($display, 'Body mass index') === 0) {
|
|
$num = round($num, 1);
|
|
} elseif ($loincCode === '29463-7' || strcasecmp($display, 'Body weight') === 0) {
|
|
$num = round($num, 2);
|
|
} elseif ($loincCode === '8302-2' || strcasecmp($display, 'Body height') === 0) {
|
|
$num = round($num, 1);
|
|
} elseif (in_array($loincCode, ['8867-4', '59408-5', '8480-6', '8462-4', '9279-1'], true)) {
|
|
$num = (float) round($num);
|
|
} else {
|
|
$num = round($num, 2);
|
|
}
|
|
$obsResource['valueQuantity'] = [
|
|
'value' => $num,
|
|
'unit' => $unit,
|
|
'system' => 'http://unitsofmeasure.org',
|
|
'code' => $ucumCode,
|
|
];
|
|
} else {
|
|
$obsResource['valueString'] = (string) $value;
|
|
}
|
|
|
|
$entries[] = ['resource' => $obsResource];
|
|
}
|
|
|
|
// Lifestyle / social-history observations
|
|
foreach ($lifestyle as $idx => $ls) {
|
|
$code = trim((string) ($ls['code'] ?? ''));
|
|
$display = (string) ($ls['display'] ?? '');
|
|
$value = (string) ($ls['value'] ?? '');
|
|
$system = (string) ($ls['system'] ?? 'http://snomed.info/sct');
|
|
|
|
if ($value === '' || $display === '') { continue; }
|
|
|
|
$obsId = 'wellness-ls-' . ($idx + 1);
|
|
$observationRefs[] = ['reference' => 'Observation/' . $obsId];
|
|
|
|
$obsCode = ['text' => $display];
|
|
if ($code !== '') {
|
|
$obsCode['coding'] = [['system' => $system, 'code' => $code, 'display' => $display]];
|
|
}
|
|
|
|
$entries[] = ['resource' => [
|
|
'resourceType' => 'Observation',
|
|
'id' => $obsId,
|
|
'status' => 'final',
|
|
'category' => [[
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
|
|
'code' => 'social-history',
|
|
'display' => 'Social History',
|
|
]],
|
|
]],
|
|
'code' => $obsCode,
|
|
'subject' => ['reference' => $patientRef],
|
|
'effectiveDateTime' => $issuedAt,
|
|
'valueString' => $value,
|
|
]];
|
|
}
|
|
|
|
$composition = [
|
|
'resourceType' => 'Composition',
|
|
'id' => 'comp-wellness-' . date('YmdHis'),
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => '371529009',
|
|
'display' => 'Health maintenance report (record artifact)',
|
|
]],
|
|
'text' => 'Wellness Record',
|
|
],
|
|
'subject' => ['reference' => $patientRef],
|
|
'date' => $issuedAt,
|
|
'title' => 'Wellness Record',
|
|
'section' => [
|
|
['title' => 'Vital Signs and Body Measurements', 'entry' => $observationRefs],
|
|
],
|
|
];
|
|
if ($practitionerRef !== '') {
|
|
$composition['author'] = [['reference' => $practitionerRef]];
|
|
}
|
|
// ABDM requires the Composition to be the first bundle entry.
|
|
array_unshift($entries, ['resource' => $composition]);
|
|
|
|
return [
|
|
'resourceType' => 'Bundle',
|
|
'identifier' => [
|
|
'system' => 'urn:ietf:rfc:3986',
|
|
'value' => 'urn:uuid:bundle-wellness-' . date('YmdHis'),
|
|
],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => $entries,
|
|
];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build DocumentReference + Binary resource entries for a scanned attachment.
|
|
*
|
|
* Usage: append the returned entries directly to an existing Bundle's entry array.
|
|
*
|
|
* @param string $id Resource ID stem (without "DocumentReference/")
|
|
* @param string $title Document title (human-readable)
|
|
* @param string $contentType MIME type — application/pdf | image/jpeg | image/png
|
|
* @param string $dataBase64 Base64-encoded raw file content
|
|
* @param string $subjectRef Patient reference string (e.g. "Patient/123")
|
|
* @param string $date ISO 8601 date/datetime string
|
|
*
|
|
* @return array<int, array<string, mixed>> [ [resource => Binary], [resource => DocumentReference] ]
|
|
*/
|
|
public function buildDocumentReferenceEntry(
|
|
string $id,
|
|
string $title,
|
|
string $contentType,
|
|
string $dataBase64,
|
|
string $subjectRef,
|
|
string $date
|
|
): array {
|
|
$binaryId = 'binary-' . $id;
|
|
$binaryRef = 'Binary/' . $binaryId;
|
|
|
|
$binaryResource = [
|
|
'resourceType' => 'Binary',
|
|
'id' => $binaryId,
|
|
'contentType' => $contentType,
|
|
'data' => $dataBase64,
|
|
];
|
|
|
|
$docRefResource = [
|
|
'resourceType' => 'DocumentReference',
|
|
'id' => $id,
|
|
'status' => 'current',
|
|
'type' => ['text' => $title],
|
|
'subject' => ['reference' => $subjectRef],
|
|
'date' => $date,
|
|
'content' => [[
|
|
'attachment' => [
|
|
'contentType' => $contentType,
|
|
'url' => $binaryRef,
|
|
'title' => $title,
|
|
],
|
|
]],
|
|
];
|
|
|
|
return [
|
|
['resource' => $binaryResource],
|
|
['resource' => $docRefResource],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Read a file from disk and return its base64-encoded content.
|
|
* Returns an empty string when the file is absent or unreadable.
|
|
*/
|
|
public function encodeFileAsBase64(string $filePath): string
|
|
{
|
|
if ($filePath === '' || ! is_file($filePath) || ! is_readable($filePath)) {
|
|
return '';
|
|
}
|
|
|
|
$content = file_get_contents($filePath);
|
|
|
|
return $content !== false ? base64_encode($content) : '';
|
|
}
|
|
|
|
/**
|
|
* Build FHIR Timing.repeat & timing code per ABDM specification
|
|
*
|
|
* @return array{timing: array<string, mixed>|null, asNeeded: bool}
|
|
*/
|
|
private function buildFhirTimingStructure(string $freqText, string $dosageText): array
|
|
{
|
|
$text = strtoupper(trim($freqText !== '' ? $freqText : $dosageText));
|
|
if ($text === '') {
|
|
return ['timing' => null, 'asNeeded' => false];
|
|
}
|
|
|
|
if (str_contains($text, 'SOS') || str_contains($text, 'AS NEEDED') || str_contains($text, 'PRN')) {
|
|
return [
|
|
'timing' => [
|
|
'code' => [
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v3-GTSAbbreviation',
|
|
'code' => 'PRN',
|
|
'display' => 'As needed',
|
|
]],
|
|
'text' => 'SOS',
|
|
],
|
|
],
|
|
'asNeeded' => true,
|
|
];
|
|
}
|
|
|
|
$repeat = null;
|
|
$gtsCode = null;
|
|
$gtsDisplay = null;
|
|
|
|
if (preg_match('/\b(OD|ONCE\s*DAILY|DAILY)\b/', $text) === 1) {
|
|
$repeat = ['frequency' => 1, 'period' => 1, 'periodUnit' => 'd'];
|
|
$gtsCode = 'QD';
|
|
$gtsDisplay = 'Once daily';
|
|
} elseif (preg_match('/\b(BD|BID|TWICE)\b/', $text) === 1) {
|
|
$repeat = ['frequency' => 2, 'period' => 1, 'periodUnit' => 'd'];
|
|
$gtsCode = 'BID';
|
|
$gtsDisplay = 'Twice daily';
|
|
} elseif (preg_match('/\b(TDS|TID|THRICE|3\s*TIMES)\b/', $text) === 1) {
|
|
$repeat = ['frequency' => 3, 'period' => 1, 'periodUnit' => 'd'];
|
|
$gtsCode = 'TID';
|
|
$gtsDisplay = 'Thrice daily';
|
|
} elseif (preg_match('/\b(QID|4\s*TIMES)\b/', $text) === 1) {
|
|
$repeat = ['frequency' => 4, 'period' => 1, 'periodUnit' => 'd'];
|
|
$gtsCode = 'QID';
|
|
$gtsDisplay = 'Four times daily';
|
|
} elseif (preg_match('/\b(HS|BEDTIME|NIGHT)\b/', $text) === 1) {
|
|
$repeat = ['frequency' => 1, 'period' => 1, 'periodUnit' => 'd'];
|
|
$gtsCode = 'HS';
|
|
$gtsDisplay = 'At bedtime';
|
|
} elseif (preg_match('/Q(\d+)H/', $text, $m) === 1 || preg_match('/EVERY\s*(\d+)\s*HOUR/', $text, $m) === 1) {
|
|
$hours = (int) ($m[1] ?? 6);
|
|
$repeat = ['frequency' => 1, 'period' => $hours, 'periodUnit' => 'h'];
|
|
} elseif (str_contains($text, 'ALTERNATE') || str_contains($text, 'QOD')) {
|
|
$repeat = ['frequency' => 1, 'period' => 2, 'periodUnit' => 'd'];
|
|
$gtsCode = 'QOD';
|
|
$gtsDisplay = 'Every other day';
|
|
} elseif (str_contains($text, 'WEEKLY')) {
|
|
$repeat = ['frequency' => 1, 'period' => 1, 'periodUnit' => 'wk'];
|
|
} elseif (str_contains($text, 'MONTHLY')) {
|
|
$repeat = ['frequency' => 1, 'period' => 1, 'periodUnit' => 'mo'];
|
|
} elseif (str_contains($text, 'STAT')) {
|
|
$repeat = ['frequency' => 1, 'period' => 1, 'periodUnit' => 'd'];
|
|
}
|
|
|
|
$res = [];
|
|
if ($repeat !== null) {
|
|
$res['repeat'] = $repeat;
|
|
}
|
|
if ($gtsCode !== null) {
|
|
$res['code'] = [
|
|
'coding' => [[
|
|
'system' => 'http://terminology.hl7.org/CodeSystem/v3-GTSAbbreviation',
|
|
'code' => $gtsCode,
|
|
'display' => $gtsDisplay,
|
|
]],
|
|
'text' => trim($freqText !== '' ? $freqText : $text),
|
|
];
|
|
} elseif ($freqText !== '') {
|
|
$res['code'] = ['text' => trim($freqText)];
|
|
}
|
|
|
|
return [
|
|
'timing' => ! empty($res) ? $res : null,
|
|
'asNeeded' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build ABDM FHIR R4 MedicationDispense Document Bundle
|
|
*
|
|
* @param array<string, mixed> $patient
|
|
* @param array<string, mixed> $store
|
|
* @param array<string, mixed> $sale
|
|
* @param array<int, array<string, mixed>> $items
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildPharmacyDispenseBundle(array $patient, array $store, array $sale, array $items): array
|
|
{
|
|
$issuedAt = $this->isoTimestamp();
|
|
$bundleUuid = $this->generateUuid();
|
|
$compositionUuid = $this->generateUuid();
|
|
$patientUuid = $this->generateUuid();
|
|
$practitionerUuid = $this->generateUuid();
|
|
$organizationUuid = $this->generateUuid();
|
|
|
|
$patientRef = 'urn:uuid:' . $patientUuid;
|
|
$practitionerRef = 'urn:uuid:' . $practitionerUuid;
|
|
$organizationRef = 'urn:uuid:' . $organizationUuid;
|
|
|
|
$resourceEntries = [];
|
|
|
|
// 1. Practitioner (Pharmacist or Prescribing Doctor)
|
|
$practName = trim((string)(($sale['doctor_name'] ?? '') ?: (($store['registered_pharmacist_name'] ?? '') ?: 'Pharmacist')));
|
|
$practRegNo = trim((string)(($sale['doctor_reg_no'] ?? '') ?: (($store['pharmacist_reg_no'] ?? '') ?: '')));
|
|
$practHprId = trim((string)($store['pharmacist_hpr_id'] ?? ''));
|
|
|
|
$practIdentifiers = [];
|
|
if ($practHprId !== '') {
|
|
$practIdentifiers[] = [
|
|
'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'PRN', 'display' => 'Provider number']]],
|
|
'system' => 'https://hpr.abdm.gov.in',
|
|
'value' => $practHprId
|
|
];
|
|
}
|
|
if ($practRegNo !== '') {
|
|
$practIdentifiers[] = [
|
|
'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'MD', 'display' => 'Medical License number']]],
|
|
'system' => 'https://doctor.ndhm.gov.in',
|
|
'value' => $practRegNo
|
|
];
|
|
}
|
|
|
|
$resourceEntries[] = [
|
|
'fullUrl' => $practitionerRef,
|
|
'resource' => [
|
|
'resourceType' => 'Practitioner',
|
|
'id' => $practitionerUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
|
|
'identifier' => $practIdentifiers,
|
|
'name' => [['text' => $practName]]
|
|
]
|
|
];
|
|
|
|
// 2. Organization (Hospital Pharmacy / Store)
|
|
$hfrId = trim((string)(($store['abdm_hfr_id'] ?? '') ?: (($store['store_code'] ?? '') ?: 'IN0710001234')));
|
|
$resourceEntries[] = [
|
|
'fullUrl' => $organizationRef,
|
|
'resource' => [
|
|
'resourceType' => 'Organization',
|
|
'id' => $organizationUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']],
|
|
'identifier' => [[
|
|
'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'PRN', 'display' => 'Provider number']]],
|
|
'system' => 'https://facility.ndhm.gov.in',
|
|
'value' => $hfrId
|
|
]],
|
|
'name' => (string)(($store['store_name'] ?? '') ?: 'Hospital Medical Store'),
|
|
'telecom' => [
|
|
['system' => 'phone', 'value' => (string)(($store['contact_phone'] ?? '') ?: '+91 9999999999')],
|
|
['system' => 'email', 'value' => (string)(($store['contact_email'] ?? '') ?: 'pharmacy@hospital.in')]
|
|
],
|
|
'address' => [[
|
|
'line' => [(string)(($store['address'] ?? '') ?: 'Hospital Building')],
|
|
'city' => (string)(($store['state_name'] ?? '') ?: 'Delhi'),
|
|
'state' => (string)(($store['state_name'] ?? '') ?: 'Delhi'),
|
|
'postalCode' => '110001',
|
|
'country' => 'IN'
|
|
]]
|
|
]
|
|
];
|
|
|
|
// 3. Patient
|
|
$patientResource = $this->buildPatientResource($patient);
|
|
$patientResource['id'] = $patientUuid;
|
|
// Inject ABHA identifiers if provided in sale or patient
|
|
$abhaNumber = trim((string)(($sale['abha_id'] ?? '') ?: ($patient['abha_id'] ?? '')));
|
|
$abhaAddress = trim((string)(($sale['abha_address'] ?? '') ?: ($patient['abha_address'] ?? '')));
|
|
|
|
if (!isset($patientResource['identifier'])) {
|
|
$patientResource['identifier'] = [];
|
|
}
|
|
if ($abhaNumber !== '') {
|
|
$patientResource['identifier'][] = [
|
|
'type' => ['coding' => [['system' => 'https://nrces.in/ndhm/fhir/r4/CodeSystem/ndhm-identifier-type-code', 'code' => 'ABHA', 'display' => 'Ayushman Bharat Health Account']]],
|
|
'system' => 'https://healthid.ndhm.gov.in',
|
|
'value' => $abhaNumber
|
|
];
|
|
}
|
|
if ($abhaAddress !== '') {
|
|
$patientResource['identifier'][] = [
|
|
'type' => ['coding' => [['system' => 'https://nrces.in/ndhm/fhir/r4/CodeSystem/ndhm-identifier-type-code', 'code' => 'ABHA-ADDRESS', 'display' => 'ABHA Address']]],
|
|
'system' => 'https://abdm.gov.in',
|
|
'value' => $abhaAddress
|
|
];
|
|
}
|
|
$resourceEntries[] = ['fullUrl' => $patientRef, 'resource' => $patientResource];
|
|
|
|
// 4. MedicationDispense Resources
|
|
$medicationRefs = [];
|
|
foreach ($items as $idx => $it) {
|
|
$medUuid = $this->generateUuid();
|
|
$medRef = 'urn:uuid:' . $medUuid;
|
|
$medicationRefs[] = ['reference' => $medRef];
|
|
|
|
$snomedCode = trim((string)($it['snomed_ct_code'] ?? ''));
|
|
$snomedDisplay = trim((string)($it['snomed_display'] ?? ($it['item_name'] ?? 'Medicine')));
|
|
|
|
$coding = [];
|
|
if ($snomedCode !== '') {
|
|
$coding[] = [
|
|
'system' => 'http://snomed.info/sct',
|
|
'code' => $snomedCode,
|
|
'display' => $snomedDisplay
|
|
];
|
|
}
|
|
|
|
$medResource = [
|
|
'resourceType' => 'MedicationDispense',
|
|
'id' => $medUuid,
|
|
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/MedicationDispense']],
|
|
'status' => 'completed',
|
|
'medicationCodeableConcept' => [
|
|
'coding' => $coding,
|
|
'text' => (string)($it['item_name'] ?? 'Medicine')
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => (string)(($sale['patient_name'] ?? '') ?: 'Patient')],
|
|
'performer' => [[
|
|
'actor' => ['reference' => $practitionerRef, 'display' => $practName]
|
|
]],
|
|
'location' => ['reference' => $organizationRef, 'display' => (string)(($store['store_name'] ?? '') ?: 'Pharmacy')],
|
|
'quantity' => [
|
|
'value' => (int)($it['qty'] ?? 1),
|
|
'unit' => (string)($it['unit_pack'] ?? 'Units'),
|
|
'system' => 'http://unitsofmeasure.org',
|
|
'code' => '1'
|
|
],
|
|
'daysSupply' => [
|
|
'value' => 5,
|
|
'unit' => 'days',
|
|
'system' => 'http://unitsofmeasure.org',
|
|
'code' => 'd'
|
|
],
|
|
'whenPrepared' => $issuedAt,
|
|
'whenHandedOver' => $issuedAt,
|
|
'dosageInstruction' => [[
|
|
'text' => 'As directed by physician'
|
|
]]
|
|
];
|
|
|
|
$resourceEntries[] = ['fullUrl' => $medRef, 'resource' => $medResource];
|
|
}
|
|
|
|
// 5. Composition (Record Head)
|
|
$compositionResource = [
|
|
'resourceType' => 'Composition',
|
|
'id' => $compositionUuid,
|
|
'meta' => [
|
|
'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/MedicationDispenseDocument'],
|
|
'versionId' => '1',
|
|
'lastUpdated' => $issuedAt
|
|
],
|
|
'language' => 'en-IN',
|
|
'identifier' => [
|
|
'system' => 'https://ndhm.in/phr',
|
|
'value' => (string)(($sale['abdm_care_context_ref'] ?? '') ?: ('PHARM-' . (($sale['invoice_no'] ?? '') ?: $compositionUuid)))
|
|
],
|
|
'status' => 'final',
|
|
'type' => [
|
|
'coding' => [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => '60590-7',
|
|
'display' => 'Medication dispensed.extended Document'
|
|
]],
|
|
'text' => 'Pharmacy Medication Dispense Record'
|
|
],
|
|
'subject' => ['reference' => $patientRef, 'display' => (string)(($sale['patient_name'] ?? '') ?: 'Patient')],
|
|
'date' => $issuedAt,
|
|
'author' => [['reference' => $practitionerRef, 'display' => $practName]],
|
|
'title' => 'Pharmacy Medication Dispensation Receipt',
|
|
'custodian' => ['reference' => $organizationRef, 'display' => (string)(($store['store_name'] ?? '') ?: 'Hospital Pharmacy')],
|
|
'section' => [[
|
|
'title' => 'Medications Dispensed',
|
|
'code' => [
|
|
'coding' => [[
|
|
'system' => 'http://loinc.org',
|
|
'code' => '10160-0',
|
|
'display' => 'History of Medication use'
|
|
]]
|
|
],
|
|
'entry' => $medicationRefs
|
|
]]
|
|
];
|
|
|
|
array_unshift($resourceEntries, [
|
|
'fullUrl' => 'urn:uuid:' . $compositionUuid,
|
|
'resource' => $compositionResource
|
|
]);
|
|
|
|
return [
|
|
'resourceType' => 'Bundle',
|
|
'id' => $bundleUuid,
|
|
'meta' => [
|
|
'versionId' => '1',
|
|
'lastUpdated' => $issuedAt,
|
|
'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle']
|
|
],
|
|
'identifier' => [
|
|
'system' => 'https://ndhm.in/phr',
|
|
'value' => $bundleUuid
|
|
],
|
|
'type' => 'document',
|
|
'timestamp' => $issuedAt,
|
|
'entry' => $resourceEntries
|
|
];
|
|
}
|
|
}
|