fix(abdm): enforce clinical decimal precision on vitals and temperature in FHIR bundles and task board

This commit is contained in:
DevSoftTech Server 2026-09-07 16:11:24 +05:30
parent bb6589605a
commit ee7cf59c49
7 changed files with 1294 additions and 330 deletions

View File

@ -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<string,mixed>|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');

View File

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

View File

@ -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'] ?? ''),

View File

@ -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),

File diff suppressed because it is too large Load Diff

View File

@ -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 += '<div class="border rounded px-2 py-1 text-center" style="min-width:80px;">';
html += '<div class="small text-muted" style="font-size:.7rem;">' + hesc(label) + '</div>';
html += '<div class="fw-bold">' + hesc(val) + '</div>';
@ -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 = '<span class="spinner-border spinner-border-sm me-1"></span>Submitting...';
post('<?= base_url('AbdmGateway/share_immunization_bundle') ?>', {
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;

View File

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