From 82fd31d105821c8e486a08ba43e748562c090835 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sun, 19 Jul 2026 00:41:12 +0530 Subject: [PATCH] Add patient immunization module --- app/Config/Routes.php | 10 + app/Controllers/AbdmGateway.php | 286 ++++++++++ app/Controllers/Immunization.php | 340 ++++++++++++ ...-000001_CreateImmunizationModuleTables.php | 497 ++++++++++++++++++ app/Libraries/FhirR4Builder.php | 334 ++++++++++++ app/Libraries/ImmunizationScheduleService.php | 153 ++++++ app/Models/ImmunizationRecordModel.php | 45 ++ app/Models/ImmunizationScheduleModel.php | 30 ++ app/Models/ImmunizationVaccineModel.php | 28 + app/Views/doctor_document/patient_search.php | 4 + app/Views/doctor_document/workspace.php | 29 + app/Views/immunization/index.php | 465 ++++++++++++++++ app/Views/immunization/schedule_master.php | 241 +++++++++ app/Views/partials/sidebar.php | 6 + 14 files changed, 2468 insertions(+) create mode 100644 app/Controllers/Immunization.php create mode 100644 app/Database/Migrations/2026-07-18-000001_CreateImmunizationModuleTables.php create mode 100644 app/Libraries/ImmunizationScheduleService.php create mode 100644 app/Models/ImmunizationRecordModel.php create mode 100644 app/Models/ImmunizationScheduleModel.php create mode 100644 app/Models/ImmunizationVaccineModel.php create mode 100644 app/Views/immunization/index.php create mode 100644 app/Views/immunization/schedule_master.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index d6309e8..ebc7aa9 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -683,6 +683,15 @@ $routes->post('AbdmGateway/share_diagnosis_report_bundle', 'AbdmGateway::shareDi $routes->post('AbdmGateway/shareDiagnosisReportBundle', 'AbdmGateway::shareDiagnosisReportBundle', ['filter' => $abdmPermFilter]); $routes->get('AbdmGateway/diagnosis_report_fhir_preview', 'AbdmGateway::diagnosisReportFhirPreview', ['filter' => $abdmPermFilter]); $routes->get('AbdmGateway/ipd_discharge_fhir_preview', 'AbdmGateway::ipdDischargeFhirPreview', ['filter' => $abdmPermFilter]); +$routes->get('AbdmGateway/immunization_fhir_preview', 'AbdmGateway::immunizationFhirPreview', ['filter' => $abdmPermFilter]); +$routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmunizationBundle', ['filter' => $abdmPermFilter]); +$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); +$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); +$routes->post('Immunization/update_schedule/(:num)', 'Immunization::updateSchedule/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); +$routes->get('Immunization/schedule', 'Immunization::schedule', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); +$routes->get('Immunization/patient/(:num)', 'Immunization::patient/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); +$routes->post('Immunization/generate_patient_schedule/(:num)', 'Immunization::generatePatientSchedule/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); +$routes->post('Immunization/complete/(:num)', 'Immunization::complete/$1', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']); $routes->post('AbdmGateway/push_health_record', 'AbdmGateway::pushHealthRecord', ['filter' => $abdmPermFilter]); $routes->get('AbdmGateway/health_records_list', 'AbdmGateway::healthRecordsList', ['filter' => $abdmPermFilter]); $routes->get('AbdmGateway/bridge_record_status/(:num)', 'AbdmGateway::bridgeRecordStatus/$1', ['filter' => $abdmPermFilter]); @@ -767,6 +776,7 @@ $routes->get('Opd_prescription/section_template_list', 'Opd_prescription::sectio $routes->get('Opd_prescription/section_past_data', 'Opd_prescription::section_past_data'); $routes->get('Opd_prescription/template_workspace', 'Opd_prescription::template_workspace', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']); $routes->get('doctor_work/document_workspace', 'DoctorDocument::workspace', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']); +$routes->get('doctor_work/patient_search', 'DoctorDocument::patient_search', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']); // Legacy-compatible doctor document templates and issue flow. $routes->get('Doc_Admin/doc_list', 'DoctorDocument::doc_list', ['filter' => 'permission:doctor_work.template_workspace.access,doctor_work.access']); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 99cc52c..498eb11 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -2020,6 +2020,174 @@ class AbdmGateway extends BaseController ]); } + public function immunizationFhirPreview() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(403)->setJSON(['status' => 'error', 'message' => 'AJAX only']); + } + + $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') ?? '')); + + if ($patientId <= 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 'error', + 'message' => 'patient_id is required.', + ]); + } + + $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId); + if ($payload === null) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 'error', + 'message' => 'Unable to prepare ImmunizationRecord FHIR payload.', + ]); + } + + return $this->response->setJSON([ + 'status' => 'ok', + 'patient_id' => $patientId, + 'record_id' => $recordId > 0 ? $recordId : null, + 'abha_id' => $abhaId, + 'care_context_reference' => (string) ($payload['care_context_reference'] ?? ''), + 'bundle' => (array) ($payload['bundle'] ?? []), + ]); + } + + public function shareImmunizationBundle() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $patientId = (int) $this->request->getPost('patient_id'); + $recordId = (int) $this->request->getPost('record_id'); + $abhaId = trim((string) $this->request->getPost('abha_id')); + $abhaAddressPost = trim((string) $this->request->getPost('abha_address')); + $consentHandle = trim((string) $this->request->getPost('consent_handle')); + $pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1; + + if ($abhaId === '' && $abhaAddressPost !== '') { + $abhaId = $abhaAddressPost; + } + if ($patientId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']); + } + + $payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId); + if ($payload === null) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Unable to prepare ImmunizationRecord FHIR payload']); + } + + $bundle = (array) ($payload['bundle'] ?? []); + $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $ccRef = (string) ($payload['care_context_reference'] ?? ('IMM-' . $patientId . '-' . date('Y-m-d'))); + $visitDate = (string) ($payload['visit_date'] ?? date('Y-m-d')); + $patientName = (string) ($payload['patient_name'] ?? ('PATIENT-' . $patientId)); + + $consent = $abhaId !== '' ? $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle) : null; + $effectiveConsent = (string) ($consent['consent_handle'] ?? $consentHandle); + + $healthRecordId = $this->storeHealthRecord([ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'hi_type' => 'ImmunizationRecord', + 'entity_type' => 'immunization', + 'entity_id' => $recordId > 0 ? (string) $recordId : (string) $patientId, + 'fhir_bundle' => $bundleJson, + 'care_context_reference' => $ccRef, + 'consent_handle' => $effectiveConsent, + ]); + + if ($abhaId === '') { + if ($healthRecordId > 0 && $this->db->tableExists('health_records')) { + $this->db->table('health_records')->where('id', $healthRecordId)->update([ + 'push_status' => 'local_only', + 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => 'local_stored', + 'health_record_id' => $healthRecordId, + 'care_context_reference' => $ccRef, + 'message' => 'ABHA not available. Immunization record stored locally for later discovery.', + ]); + } + + if (! $pushToGateway) { + if ($healthRecordId > 0 && $this->db->tableExists('health_records')) { + $this->db->table('health_records')->where('id', $healthRecordId)->update([ + 'push_status' => 'local_discovery_ready', + 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => 'local_discovery_ready', + 'health_record_id' => $healthRecordId, + 'care_context_reference' => $ccRef, + 'consent_handle' => $effectiveConsent, + 'mode' => 'm2_hms_source', + 'message' => 'Immunization record registered in HMS for ABDM discovery/fetch callbacks.', + ]); + } + + $queueId = null; + $bridgeRecordId = 0; + $connectorError = null; + try { + $result = $this->connector->pushRecord([ + 'patient_id' => (string) $patientId, + 'patient_name' => $patientName, + 'abha_id' => preg_match('/^\d{14}$/', $abhaId) === 1 ? $abhaId : '', + 'abha_address' => str_contains($abhaId, '@') ? $abhaId : '', + 'hi_type' => 'ImmunizationRecord', + 'record_type' => 'ImmunizationRecord', + 'visit_date' => $visitDate, + 'care_context_reference' => $ccRef, + 'care_context_display' => 'Immunization Record - ' . $visitDate, + 'notes' => 'Immunization Record - ' . $visitDate, + 'queue_id' => $ccRef, + 'record_data' => $bundle, + ]); + $queueId = (string) ($result['queue_id'] ?? ''); + $bridgeRecordId = (int) ($result['id'] ?? 0); + } catch (\Throwable $e) { + $connectorError = $e->getMessage(); + } + + if ($healthRecordId > 0) { + $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError, $bridgeRecordId); + } + + $this->getAuditService()->log([ + 'action' => 'push_record', + 'entity_type' => 'immunization', + 'entity_id' => $recordId > 0 ? (string) $recordId : (string) $patientId, + 'abha_id' => $abhaId, + 'patient_id' => $patientId, + 'request' => ['patient_id' => $patientId, 'record_id' => $recordId, 'hi_type' => 'ImmunizationRecord'], + 'response' => ['queue_id' => $queueId], + 'outcome' => $connectorError === null ? 'success' : 'failure', + 'error_message' => (string) ($connectorError ?? ''), + ]); + + return $this->response->setJSON([ + 'ok' => $connectorError === null ? 1 : 0, + 'queue_id' => $queueId, + 'bridge_record_id' => $bridgeRecordId > 0 ? $bridgeRecordId : null, + 'health_record_id' => $healthRecordId, + 'consent_handle' => $effectiveConsent, + 'care_context_reference' => $ccRef, + 'status' => $connectorError === null ? 'queued' : 'failed', + 'error' => $connectorError, + ]); + } + public function ipdDischargeFhirPreview() { if (! $this->request->isAJAX()) { @@ -2372,6 +2540,120 @@ class AbdmGateway extends BaseController ]; } + /** + * @return array|null + */ + private function buildImmunizationGatewayPayload(int $patientId, int $recordId = 0, string $preferredAbhaId = ''): ?array + { + if ($patientId <= 0 || ! $this->db->tableExists('patient_master') || ! $this->db->tableExists('immunization_records')) { + return null; + } + + $patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? []; + if (empty($patientRow)) { + return null; + } + + $recordsBuilder = $this->db->table('immunization_records r') + ->select('r.*, s.series_name, s.series_doses, s.age_label, v.target_disease_code, v.target_disease_name') + ->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) + ->orderBy('COALESCE(r.given_date, r.due_date)', 'ASC', false) + ->orderBy('r.id', 'ASC'); + + if ($recordId > 0) { + $recordsBuilder->where('r.id', $recordId); + } + + $records = $recordsBuilder->get()->getResultArray(); + if (empty($records)) { + return null; + } + + $patientName = trim(trim((string) ($patientRow['p_fname'] ?? '')) . ' ' . trim((string) ($patientRow['p_lname'] ?? ''))); + $rawAbha = trim($preferredAbhaId); + if ($rawAbha === '') { + foreach (['abha_address', 'abha_id', 'abha_no', 'abha'] as $field) { + $candidate = trim((string) ($patientRow[$field] ?? '')); + if ($candidate !== '') { + $rawAbha = $candidate; + break; + } + } + } + + $hospital = $this->getHospitalProfileForFhir(); + $practitioner = null; + $performerId = 0; + foreach ($records as $record) { + $performerId = (int) ($record['performer_id'] ?? 0); + if ($performerId > 0) { + break; + } + } + if ($performerId > 0 && $this->db->tableExists('doctor_master')) { + $doctorRow = $this->db->table('doctor_master')->where('id', $performerId)->get(1)->getRowArray() ?? []; + if (! empty($doctorRow)) { + $doctorName = trim(trim((string) ($doctorRow['p_fname'] ?? '')) . ' ' . trim((string) ($doctorRow['p_lname'] ?? ''))); + $doctorRegNo = ''; + foreach (['doctor_reg_no', 'registration_no', 'reg_no'] as $field) { + $candidate = trim((string) ($doctorRow[$field] ?? '')); + if ($candidate !== '') { + $doctorRegNo = $candidate; + break; + } + } + $practitioner = ['id' => (string) $performerId, 'name' => $doctorName, 'registration_number' => $doctorRegNo]; + } + } + + $latestDate = ''; + foreach ($records as $record) { + $candidate = trim((string) ($record['given_date'] ?? $record['due_date'] ?? '')); + if ($candidate !== '' && ($latestDate === '' || strtotime($candidate) > strtotime($latestDate))) { + $latestDate = $candidate; + } + } + $visitDate = $latestDate !== '' ? date('Y-m-d', strtotime($latestDate)) : date('Y-m-d'); + $ccRef = $recordId > 0 ? ('IMM-' . $recordId . '-' . $visitDate) : ('IMM-' . $patientId . '-' . $visitDate); + + $birthDate = ''; + foreach (['dob', 'birth_date', 'date_of_birth', 'p_dob'] as $field) { + $candidate = trim((string) ($patientRow[$field] ?? '')); + if ($candidate !== '' && strtotime($candidate) !== false) { + $birthDate = date('Y-m-d', strtotime($candidate)); + break; + } + } + + $patient = [ + 'id' => (string) $patientId, + 'name' => $patientName !== '' ? $patientName : ('PATIENT-' . $patientId), + 'gender' => (string) ($patientRow['gender'] ?? $patientRow['xgender'] ?? ''), + 'birthDate' => $birthDate, + 'abhaAddress' => $rawAbha, + 'phone' => (string) ($patientRow['mphone1'] ?? ''), + ]; + + $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], + 'care_context_reference' => $ccRef, + ]); + + return [ + 'bundle' => $bundle, + 'care_context_reference' => $ccRef, + 'care_context_display' => 'Immunization Record - ' . $visitDate, + 'patient_name' => $patient['name'], + 'visit_date' => $visitDate, + 'records' => $records, + ]; + } + /** * @param array $columns * @return array> @@ -3979,6 +4261,7 @@ class AbdmGateway extends BaseController 'IPD' => 'HealthDocumentRecord', 'LAB' => 'DiagnosticReportRecord', 'DISCHARGE' => 'DischargeSummaryRecord', + 'IMMUNIZATION' => 'ImmunizationRecord', 'MLC' => 'HealthDocumentRecord', default => 'HealthDocumentRecord', }; @@ -4179,6 +4462,9 @@ class AbdmGateway extends BaseController if (str_contains($v, 'discharge')) { return 'DISCHARGE'; } + if (str_contains($v, 'immunization')) { + return 'IMMUNIZATION'; + } if ($entityType === 'ipd') { return 'IPD'; } diff --git a/app/Controllers/Immunization.php b/app/Controllers/Immunization.php new file mode 100644 index 0000000..bb453cd --- /dev/null +++ b/app/Controllers/Immunization.php @@ -0,0 +1,340 @@ + 'Immunization Record', + 'patient_id' => (int) ($this->request->getGet('patient_id') ?? 0), + ]); + } + + public function schedule() + { + if (! $this->db->tableExists('immunization_schedule_master') || ! $this->db->tableExists('immunization_vaccine_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error_text' => 'Immunization tables not found. Run migrations first.', + ]); + } + + $rows = $this->db->table('immunization_schedule_master s') + ->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.target_disease_name, v.route_name, v.site_name') + ->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'left') + ->where('s.is_active', 1) + ->orderBy('s.sort_order', 'ASC') + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'ok' => 1, + 'count' => count($rows), + 'items' => $rows, + ]); + } + + public function scheduleMaster() + { + return view('immunization/schedule_master', [ + 'page_title' => 'UIP Schedule Master', + 'items' => $this->loadScheduleRows(true), + ]); + } + + public function updateSchedule(int $scheduleId = 0) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + if ($scheduleId <= 0 || ! $this->db->tableExists('immunization_schedule_master') || ! $this->db->tableExists('immunization_vaccine_master')) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid schedule record']); + } + + $schedule = $this->db->table('immunization_schedule_master')->where('id', $scheduleId)->get(1)->getRowArray(); + if (empty($schedule)) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Schedule not found']); + } + + $now = date('Y-m-d H:i:s'); + $ageLabel = $this->nullablePost('age_label') ?? (string) ($schedule['age_label'] ?? ''); + $doseNumber = $this->nullablePost('dose_number') ?? (string) ($schedule['dose_number'] ?? ''); + $notes = $this->nullablePost('notes'); + $scheduleUpdate = [ + 'age_label' => $ageLabel, + 'age_value' => max(0, (int) ($this->request->getPost('age_value') ?? ($schedule['age_value'] ?? 0))), + 'age_unit' => $this->nullablePost('age_unit') ?? (string) ($schedule['age_unit'] ?? 'days'), + 'age_offset_days' => max(0, (int) ($this->request->getPost('age_offset_days') ?? ($schedule['age_offset_days'] ?? 0))), + 'dose_number' => $doseNumber, + 'series_doses' => $this->nullablePost('series_doses'), + 'notes' => $notes, + 'sort_order' => max(0, (int) ($this->request->getPost('sort_order') ?? ($schedule['sort_order'] ?? 0))), + 'is_active' => (int) ($this->request->getPost('is_active') ?? 0) === 1 ? 1 : 0, + 'updated_at' => $now, + ]; + + $vaccineUpdate = [ + 'vaccine_name' => $this->nullablePost('vaccine_name') ?? '', + 'target_disease_name' => $this->nullablePost('target_disease_name'), + 'route_name' => $this->nullablePost('route_name'), + 'site_name' => $this->nullablePost('site_name'), + 'updated_at' => $now, + ]; + if ($vaccineUpdate['vaccine_name'] === '') { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Vaccine name is required']); + } + + $this->db->table('immunization_schedule_master')->where('id', $scheduleId)->update($scheduleUpdate); + $vaccineId = (int) ($schedule['vaccine_master_id'] ?? 0); + if ($vaccineId > 0) { + $this->db->table('immunization_vaccine_master')->where('id', $vaccineId)->update($vaccineUpdate); + } + + if ($this->db->tableExists('immunization_records')) { + $this->db->table('immunization_records') + ->where('schedule_id', $scheduleId) + ->where('status !=', 'completed') + ->update([ + 'vaccine_name' => $vaccineUpdate['vaccine_name'], + 'dose_number' => $doseNumber, + 'route_name' => $vaccineUpdate['route_name'], + 'site_name' => $vaccineUpdate['site_name'], + 'notes' => $notes, + 'updated_at' => $now, + ]); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'item' => $this->loadScheduleRow($scheduleId), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function patient(int $patientId = 0) + { + if ($patientId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid patient_id']); + } + + foreach (['patient_master', 'immunization_records'] as $table) { + if (! $this->db->tableExists($table)) { + return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $table . ' table not found']); + } + } + + $patient = $this->loadPatientHeader($patientId); + if (empty($patient)) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Patient not found']); + } + + $rows = $this->db->table('immunization_records r') + ->select('r.*, s.age_label, s.age_offset_days, s.series_name, s.series_doses, v.target_disease_name') + ->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) + ->orderBy('COALESCE(r.due_date, r.given_date)', 'ASC', false) + ->orderBy('r.id', 'ASC') + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'ok' => 1, + 'patient' => $patient, + 'summary' => $this->buildSummary($rows), + 'count' => count($rows), + 'items' => $rows, + ]); + } + + public function generatePatientSchedule(int $patientId = 0) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $result = (new ImmunizationScheduleService($this->db))->generateUipDueRecordsForPatient($patientId, [ + 'generation_mode' => (string) ($this->request->getPost('generation_mode') ?? 'full'), + ]); + $httpCode = (int) ($result['ok'] ?? 0) === 1 ? 200 : 400; + + return $this->response->setStatusCode($httpCode)->setJSON($result + [ + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function complete(int $recordId = 0) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + if ($recordId <= 0 || ! $this->db->tableExists('immunization_records')) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid immunization record']); + } + + $record = $this->db->table('immunization_records')->where('id', $recordId)->get(1)->getRowArray(); + if (empty($record)) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Immunization record not found']); + } + + $givenDate = $this->normalizeDateTime((string) ($this->request->getPost('given_date') ?? '')); + if ($givenDate === null) { + $givenDate = date('Y-m-d H:i:s'); + } + + $expiryDate = $this->normalizeDate((string) ($this->request->getPost('expiry_date') ?? '')); + $user = function_exists('auth') ? auth()->user() : null; + $userId = (int) ($user->id ?? 0); + + $update = [ + 'given_date' => $givenDate, + 'status' => 'completed', + 'lot_number' => $this->nullablePost('lot_number'), + 'expiry_date' => $expiryDate, + 'manufacturer' => $this->nullablePost('manufacturer'), + 'performer_id' => (int) ($this->request->getPost('performer_id') ?? 0) ?: null, + 'location_name' => $this->nullablePost('location_name'), + 'route_code' => $this->nullablePost('route_code') ?? (string) ($record['route_code'] ?? ''), + 'route_name' => $this->nullablePost('route_name') ?? (string) ($record['route_name'] ?? ''), + 'site_code' => $this->nullablePost('site_code') ?? (string) ($record['site_code'] ?? ''), + 'site_name' => $this->nullablePost('site_name') ?? (string) ($record['site_name'] ?? ''), + 'reaction_notes' => $this->nullablePost('reaction_notes'), + 'notes' => $this->nullablePost('notes') ?? (string) ($record['notes'] ?? ''), + 'updated_by' => $userId > 0 ? $userId : null, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->db->table('immunization_records')->where('id', $recordId)->update($update); + + return $this->response->setJSON([ + 'ok' => 1, + 'record_id' => $recordId, + 'status' => 'completed', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + /** + * @return array + */ + private function loadPatientHeader(int $patientId): array + { + $fields = $this->db->getFieldNames('patient_master') ?? []; + $select = ['id']; + foreach (['p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'age_in_month', 'mphone1', 'abha_id', 'abha_no', 'abha_address', 'abha'] as $field) { + if (in_array($field, $fields, true)) { + $select[] = $field; + } + } + + return $this->db->table('patient_master') + ->select(implode(',', array_unique($select))) + ->where('id', $patientId) + ->get(1) + ->getRowArray() ?? []; + } + + /** + * @return array> + */ + private function loadScheduleRows(bool $includeInactive = false): array + { + if (! $this->db->tableExists('immunization_schedule_master') || ! $this->db->tableExists('immunization_vaccine_master')) { + return []; + } + + $builder = $this->db->table('immunization_schedule_master s') + ->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.target_disease_name, v.route_name, v.site_name') + ->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'left') + ->orderBy('s.sort_order', 'ASC') + ->orderBy('s.id', 'ASC'); + + if (! $includeInactive) { + $builder->where('s.is_active', 1); + } + + return $builder->get()->getResultArray(); + } + + /** + * @return array + */ + private function loadScheduleRow(int $scheduleId): array + { + if ($scheduleId <= 0) { + return []; + } + + return $this->db->table('immunization_schedule_master s') + ->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.target_disease_name, v.route_name, v.site_name') + ->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'left') + ->where('s.id', $scheduleId) + ->get(1) + ->getRowArray() ?? []; + } + + /** + * @param array> $rows + * @return array + */ + private function buildSummary(array $rows): array + { + $summary = [ + 'total' => count($rows), + 'due' => 0, + 'overdue' => 0, + 'completed' => 0, + 'missed' => 0, + 'postponed' => 0, + ]; + $today = date('Y-m-d'); + + foreach ($rows as $row) { + $status = strtolower(trim((string) ($row['status'] ?? 'due'))); + if (isset($summary[$status])) { + $summary[$status]++; + } + $dueDate = trim((string) ($row['due_date'] ?? '')); + if ($status === 'due' && $dueDate !== '' && $dueDate < $today) { + $summary['overdue']++; + } + } + + return $summary; + } + + private function nullablePost(string $key): ?string + { + $value = trim((string) ($this->request->getPost($key) ?? '')); + return $value !== '' ? $value : null; + } + + private function normalizeDateTime(string $value): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + $timestamp = strtotime($value); + return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp); + } + + private function normalizeDate(string $value): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + $timestamp = strtotime($value); + return $timestamp === false ? null : date('Y-m-d', $timestamp); + } +} \ No newline at end of file diff --git a/app/Database/Migrations/2026-07-18-000001_CreateImmunizationModuleTables.php b/app/Database/Migrations/2026-07-18-000001_CreateImmunizationModuleTables.php new file mode 100644 index 0000000..fcc96d3 --- /dev/null +++ b/app/Database/Migrations/2026-07-18-000001_CreateImmunizationModuleTables.php @@ -0,0 +1,497 @@ +createVaccineMasterTable(); + $this->createScheduleMasterTable(); + $this->createRecordsTable(); + $this->seedUipSchedule(); + } + + public function down(): void + { + $this->forge->dropTable('immunization_records', true); + $this->forge->dropTable('immunization_schedule_master', true); + $this->forge->dropTable('immunization_vaccine_master', true); + } + + private function createVaccineMasterTable(): void + { + if ($this->tableExists('immunization_vaccine_master')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'vaccine_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => false, + ], + 'vaccine_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'vaccine_code_system' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'vaccine_display' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'target_disease_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'target_disease_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'route_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'route_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'site_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'site_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'is_active' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('vaccine_name'); + $this->forge->addKey('vaccine_code'); + $this->forge->addKey('is_active'); + $this->forge->createTable('immunization_vaccine_master', true); + } + + private function createScheduleMasterTable(): void + { + if ($this->tableExists('immunization_schedule_master')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'schedule_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'default' => 'UIP', + ], + 'age_label' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => false, + ], + 'age_value' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'age_unit' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'days', + ], + 'age_offset_days' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'vaccine_master_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'dose_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'series_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'default' => 'Indian Universal Immunization Programme', + ], + 'series_doses' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'sort_order' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'is_uip' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'is_active' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('vaccine_master_id'); + $this->forge->addKey('age_offset_days'); + $this->forge->addKey('is_uip'); + $this->forge->addKey('is_active'); + $this->forge->createTable('immunization_schedule_master', true); + } + + private function createRecordsTable(): void + { + if ($this->tableExists('immunization_records')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'opd_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'ipd_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'schedule_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'vaccine_master_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'vaccine_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => false, + ], + 'vaccine_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'vaccine_code_system' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'dose_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'due_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'given_date' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'due', + ], + 'lot_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'expiry_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'manufacturer' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'performer_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'location_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'route_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'route_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'site_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'site_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'reaction_notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'notes' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'abdm_care_context_reference' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'abdm_health_record_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'abdm_push_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'created_by' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'updated_by' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('patient_id'); + $this->forge->addKey('schedule_id'); + $this->forge->addKey('vaccine_master_id'); + $this->forge->addKey('due_date'); + $this->forge->addKey('given_date'); + $this->forge->addKey('status'); + $this->forge->addKey('abdm_care_context_reference'); + $this->forge->createTable('immunization_records', true); + } + + private function seedUipSchedule(): void + { + if (! $this->tableExists('immunization_vaccine_master') || ! $this->tableExists('immunization_schedule_master')) { + return; + } + + $existing = $this->db->table('immunization_schedule_master')->where('is_uip', 1)->countAllResults(); + if ($existing > 0) { + return; + } + + $now = date('Y-m-d H:i:s'); + $codeSystem = 'https://hms.local/immunization/uip'; + $vaccines = [ + 'UIP-BCG' => ['BCG', 'Tuberculosis', 'Intradermal'], + 'UIP-OPV' => ['Oral Polio Vaccine', 'Poliomyelitis', 'Oral'], + 'UIP-HEPB' => ['Hepatitis B Vaccine', 'Hepatitis B', 'Intramuscular'], + 'UIP-PENTA' => ['Pentavalent Vaccine', 'Diphtheria, Pertussis, Tetanus, Hepatitis B and Hib', 'Intramuscular'], + 'UIP-ROTA' => ['Rotavirus Vaccine', 'Rotavirus gastroenteritis', 'Oral'], + 'UIP-FIPV' => ['Fractional IPV', 'Poliomyelitis', 'Intradermal'], + 'UIP-PCV' => ['Pneumococcal Conjugate Vaccine', 'Pneumococcal disease', 'Intramuscular'], + 'UIP-MR' => ['Measles Rubella Vaccine', 'Measles and rubella', 'Subcutaneous'], + 'UIP-JE' => ['Japanese Encephalitis Vaccine', 'Japanese encephalitis', 'Subcutaneous'], + 'UIP-DPT' => ['DPT Vaccine', 'Diphtheria, Pertussis and Tetanus', 'Intramuscular'], + 'UIP-TD' => ['Td Vaccine', 'Tetanus and diphtheria', 'Intramuscular'], + ]; + + $vaccineRows = []; + foreach ($vaccines as $code => $vaccine) { + $vaccineRows[] = [ + 'vaccine_name' => $vaccine[0], + 'vaccine_code' => $code, + 'vaccine_code_system' => $codeSystem, + 'vaccine_display' => $vaccine[0], + 'target_disease_name' => $vaccine[1], + 'route_name' => $vaccine[2], + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + $this->db->table('immunization_vaccine_master')->insertBatch($vaccineRows); + + $vaccineIds = []; + $rows = $this->db->table('immunization_vaccine_master')->select('id,vaccine_code')->whereIn('vaccine_code', array_keys($vaccines))->get()->getResultArray(); + foreach ($rows as $row) { + $vaccineIds[(string) $row['vaccine_code']] = (int) $row['id']; + } + + $schedule = [ + ['At Birth', 0, 'days', 0, 'UIP-BCG', '1', '1', 'Preferably at birth or as early as possible.'], + ['At Birth', 0, 'days', 0, 'UIP-OPV', '0', '4', 'OPV birth dose.'], + ['At Birth', 0, 'days', 0, 'UIP-HEPB', '0', '4', 'Within 24 hours of birth where possible.'], + ['6 Weeks', 6, 'weeks', 42, 'UIP-OPV', '1', '4', ''], + ['6 Weeks', 6, 'weeks', 42, 'UIP-PENTA', '1', '3', ''], + ['6 Weeks', 6, 'weeks', 42, 'UIP-ROTA', '1', '3', ''], + ['6 Weeks', 6, 'weeks', 42, 'UIP-FIPV', '1', '2', ''], + ['6 Weeks', 6, 'weeks', 42, 'UIP-PCV', '1', '3', 'Where applicable as per local programme.'], + ['10 Weeks', 10, 'weeks', 70, 'UIP-OPV', '2', '4', ''], + ['10 Weeks', 10, 'weeks', 70, 'UIP-PENTA', '2', '3', ''], + ['10 Weeks', 10, 'weeks', 70, 'UIP-ROTA', '2', '3', ''], + ['14 Weeks', 14, 'weeks', 98, 'UIP-OPV', '3', '4', ''], + ['14 Weeks', 14, 'weeks', 98, 'UIP-PENTA', '3', '3', ''], + ['14 Weeks', 14, 'weeks', 98, 'UIP-ROTA', '3', '3', ''], + ['14 Weeks', 14, 'weeks', 98, 'UIP-FIPV', '2', '2', ''], + ['14 Weeks', 14, 'weeks', 98, 'UIP-PCV', '2', '3', 'Where applicable as per local programme.'], + ['9-12 Months', 9, 'months', 274, 'UIP-MR', '1', '2', ''], + ['9-12 Months', 9, 'months', 274, 'UIP-JE', '1', '2', 'In JE endemic districts.'], + ['9-12 Months', 9, 'months', 274, 'UIP-PCV', 'Booster', '3', 'Where applicable as per local programme.'], + ['16-24 Months', 16, 'months', 487, 'UIP-DPT', 'Booster 1', '3', ''], + ['16-24 Months', 16, 'months', 487, 'UIP-OPV', 'Booster', '4', ''], + ['16-24 Months', 16, 'months', 487, 'UIP-MR', '2', '2', ''], + ['16-24 Months', 16, 'months', 487, 'UIP-JE', '2', '2', 'In JE endemic districts.'], + ['5-6 Years', 5, 'years', 1826, 'UIP-DPT', 'Booster 2', '3', ''], + ['10 Years', 10, 'years', 3652, 'UIP-TD', '1', '2', ''], + ['16 Years', 16, 'years', 5844, 'UIP-TD', '2', '2', ''], + ]; + + $scheduleRows = []; + foreach ($schedule as $index => $item) { + $vaccineId = $vaccineIds[$item[4]] ?? 0; + if ($vaccineId <= 0) { + continue; + } + + $scheduleRows[] = [ + 'schedule_name' => 'UIP', + 'age_label' => $item[0], + 'age_value' => $item[1], + 'age_unit' => $item[2], + 'age_offset_days' => $item[3], + 'vaccine_master_id' => $vaccineId, + 'dose_number' => $item[5], + 'series_name' => 'Indian Universal Immunization Programme', + 'series_doses' => $item[6], + 'notes' => $item[7] !== '' ? $item[7] : null, + 'sort_order' => $index + 1, + 'is_uip' => 1, + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($scheduleRows)) { + $this->db->table('immunization_schedule_master')->insertBatch($scheduleRows); + } + } + + private function tableExists(string $table): bool + { + $rows = $this->db->query('SHOW TABLES LIKE ?', [$table])->getResultArray(); + return ! empty($rows); + } +} \ No newline at end of file diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index e42df43..1e4b668 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -1389,6 +1389,340 @@ class FhirR4Builder ]; } + /** + * Build an ABDM ImmunizationRecord FHIR DocumentBundle. + * + * @param array $patient + * @param array> $immunizations + * @param array $context practitioner, organization, encounter, care_context_reference + * + * @return array + */ + public function buildImmunizationRecordBundle(array $patient, array $immunizations, array $context = []): array + { + $issuedAt = $this->isoTimestamp(); + $bundleUuid = $this->generateUuid(); + $compositionUuid = $this->generateUuid(); + $patientUuid = $this->generateUuid(); + $patientRef = 'urn:uuid:' . $patientUuid; + + $practitioner = is_array($context['practitioner'] ?? null) ? (array) $context['practitioner'] : []; + $organization = is_array($context['organization'] ?? null) ? (array) $context['organization'] : []; + $encounter = is_array($context['encounter'] ?? null) ? (array) $context['encounter'] : []; + + $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 = []; + + $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 + ); + + $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( + (string) ($immunization['vaccine_code_system'] ?? ''), + (string) ($immunization['vaccine_code'] ?? ''), + $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]; + $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]]; + } elseif ($organizationRef !== '') { + $composition['author'] = [['reference' => $organizationRef]]; + } + + $careContextReference = trim((string) ($context['care_context_reference'] ?? ('IMM-' . (string) ($patient['id'] ?? 'unknown') . '-' . date('YmdHis')))); + $hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? '')); + + return [ + 'resourceType' => 'Bundle', + 'id' => $bundleUuid, + 'meta' => [ + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle'], + 'security' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-Confidentiality', + 'code' => 'V', + 'display' => 'very restricted', + ]], + ], + 'identifier' => [ + 'system' => $hfrId !== '' ? ('https://' . $hfrId . '.hfr.abdm.gov.in') : 'urn:ietf:rfc:3986', + 'value' => $careContextReference, + ], + 'type' => 'document', + 'timestamp' => $issuedAt, + 'entry' => array_merge([['fullUrl' => 'urn:uuid:' . $compositionUuid, 'resource' => $composition]], $resourceEntries), + ]; + } + + private function normalizeImmunizationStatus(string $status): string + { + $status = strtolower(trim($status)); + return match ($status) { + 'completed', 'done', 'given' => 'completed', + 'entered-in-error', 'entered_in_error', 'error' => 'entered-in-error', + default => 'not-done', + }; + } + + /** + * @return array + */ + private function buildSimpleCodeableConcept(string $system, string $code, string $display, string $text): array + { + $concept = []; + $text = trim($text); + if ($text !== '') { + $concept['text'] = $text; + } + + $system = trim($system); + $code = trim($code); + $display = trim($display); + if ($system !== '' && $code !== '' && $display !== '') { + $concept['coding'] = [[ + 'system' => $system, + 'code' => $code, + 'display' => $display, + ]]; + } + + return $concept; + } + + /** + * @return array + */ + private function buildDoseNumberElement(string $value, string $fieldPrefix = 'doseNumber'): array + { + $value = trim($value); + if ($value === '') { + return []; + } + + if (preg_match('/^[1-9]\d*$/', $value) === 1) { + return [$fieldPrefix . 'PositiveInt' => (int) $value]; + } + + return [$fieldPrefix . 'String' => $value]; + } + /** Generate a cryptographically random UUID v4. */ private function generateUuid(): string { diff --git a/app/Libraries/ImmunizationScheduleService.php b/app/Libraries/ImmunizationScheduleService.php new file mode 100644 index 0000000..ef051b9 --- /dev/null +++ b/app/Libraries/ImmunizationScheduleService.php @@ -0,0 +1,153 @@ +db = $db ?? Database::connect(); + } + + /** + * @return array{ok:int,created:int,skipped:int,error?:string,patient_id:int} + */ + public function generateUipDueRecordsForPatient(int $patientId, array $options = []): array + { + if ($patientId <= 0) { + return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => 'Invalid patient_id']; + } + + foreach (['patient_master', 'immunization_schedule_master', 'immunization_vaccine_master', 'immunization_records'] as $table) { + if (! $this->db->tableExists($table)) { + return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => $table . ' table not found']; + } + } + + $patient = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray(); + if (empty($patient)) { + return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => 'Patient not found']; + } + + $dob = $this->resolvePatientDob($patient); + if ($dob === null) { + return ['ok' => 0, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId, 'error' => 'Patient DOB is required']; + } + + $today = new DateTimeImmutable('today', new DateTimeZone('Asia/Kolkata')); + $diff = $dob->diff($today); + $ageDays = $diff->invert === 0 ? (int) $diff->days : 0; + $generationMode = strtolower(trim((string) ($options['generation_mode'] ?? 'full'))); + if (! in_array($generationMode, ['eligible', 'full'], true)) { + $generationMode = 'full'; + } + + $scheduleRows = $this->db->table('immunization_schedule_master s') + ->select('s.*, v.vaccine_name, v.vaccine_code, v.vaccine_code_system, v.route_code, v.route_name, v.site_code, v.site_name') + ->join('immunization_vaccine_master v', 'v.id = s.vaccine_master_id', 'inner') + ->where('s.is_uip', 1) + ->where('s.is_active', 1) + ->where('v.is_active', 1) + ->orderBy('s.sort_order', 'ASC') + ->get() + ->getResultArray(); + + if (empty($scheduleRows)) { + return ['ok' => 1, 'created' => 0, 'skipped' => 0, 'patient_id' => $patientId]; + } + + $existingRows = $this->db->table('immunization_records') + ->select('schedule_id') + ->where('patient_id', $patientId) + ->where('schedule_id IS NOT NULL', null, false) + ->get() + ->getResultArray(); + + $existingScheduleIds = []; + foreach ($existingRows as $row) { + $existingScheduleIds[(int) ($row['schedule_id'] ?? 0)] = true; + } + + $now = date('Y-m-d H:i:s'); + $insertRows = []; + $skipped = 0; + $futureSkipped = 0; + foreach ($scheduleRows as $schedule) { + $scheduleId = (int) ($schedule['id'] ?? 0); + if ($scheduleId <= 0 || isset($existingScheduleIds[$scheduleId])) { + $skipped++; + continue; + } + + $offsetDays = max(0, (int) ($schedule['age_offset_days'] ?? 0)); + if ($generationMode === 'eligible' && $offsetDays > $ageDays) { + $futureSkipped++; + continue; + } + + $dueDate = $dob->modify('+' . $offsetDays . ' days')->format('Y-m-d'); + $insertRows[] = [ + 'patient_id' => $patientId, + 'schedule_id' => $scheduleId, + 'vaccine_master_id' => (int) ($schedule['vaccine_master_id'] ?? 0), + 'vaccine_name' => (string) ($schedule['vaccine_name'] ?? ''), + 'vaccine_code' => (string) ($schedule['vaccine_code'] ?? ''), + 'vaccine_code_system' => (string) ($schedule['vaccine_code_system'] ?? ''), + 'dose_number' => (string) ($schedule['dose_number'] ?? ''), + 'due_date' => $dueDate, + 'status' => 'due', + 'route_code' => (string) ($schedule['route_code'] ?? ''), + 'route_name' => (string) ($schedule['route_name'] ?? ''), + 'site_code' => (string) ($schedule['site_code'] ?? ''), + 'site_name' => (string) ($schedule['site_name'] ?? ''), + 'notes' => (string) ($schedule['notes'] ?? ''), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if (! empty($insertRows)) { + $this->db->table('immunization_records')->insertBatch($insertRows); + } + + return [ + 'ok' => 1, + 'created' => count($insertRows), + 'skipped' => $skipped, + 'future_skipped' => $futureSkipped, + 'patient_id' => $patientId, + 'generation_mode' => $generationMode, + 'patient_age_days' => $ageDays, + 'patient_gender' => (string) ($patient['gender'] ?? $patient['xgender'] ?? ''), + ]; + } + + /** + * @param array $patient + */ + private function resolvePatientDob(array $patient): ?DateTimeImmutable + { + foreach (['dob', 'birth_date', 'date_of_birth', 'p_dob'] as $field) { + $raw = trim((string) ($patient[$field] ?? '')); + if ($raw === '' || $raw === '0000-00-00') { + continue; + } + + $timestamp = strtotime($raw); + if ($timestamp === false) { + continue; + } + + return (new DateTimeImmutable('@' . $timestamp))->setTimezone(new DateTimeZone('Asia/Kolkata')); + } + + return null; + } +} \ No newline at end of file diff --git a/app/Models/ImmunizationRecordModel.php b/app/Models/ImmunizationRecordModel.php new file mode 100644 index 0000000..bf2486f --- /dev/null +++ b/app/Models/ImmunizationRecordModel.php @@ -0,0 +1,45 @@ + $historyBackUrl, 'back_title' => 'Doctor Patient Search', ]); + $immunizationUrl = base_url('Immunization') . '?' . http_build_query(['patient_id' => $patientId]); $patientRawName = trim((string) ($patient['p_fname'] ?? '') . ' { ' . (string) ($patient['p_rname'] ?? '') . ' }'); $patientAge = function_exists('get_age_1') ? trim((string) get_age_1($patient['dob'] ?? null, $patient['age'] ?? '', $patient['age_in_month'] ?? '', $patient['estimate_dob'] ?? '', $patient['last_visit'] ?? null)) @@ -96,6 +97,9 @@ Issue Document + + Immunization + diff --git a/app/Views/doctor_document/workspace.php b/app/Views/doctor_document/workspace.php index a597646..19ac7ff 100644 --- a/app/Views/doctor_document/workspace.php +++ b/app/Views/doctor_document/workspace.php @@ -31,6 +31,7 @@
+
@@ -68,4 +69,32 @@ function openPatientDoc() { alert('Unable to resolve patient. Try again.'); }); } + +function openPatientImmunization() { + var patientKey = (document.getElementById('doc_workspace_patient_id').value || '').trim(); + if (!patientKey) { + alert('Enter patient id'); + return; + } + + var resolveUrl = '?patient_key=' + encodeURIComponent(patientKey); + fetch(resolveUrl, { + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (data && Number(data.status) === 1 && Number(data.patient_id) > 0) { + load_form('?patient_id=' + Number(data.patient_id), 'Immunization Record'); + return; + } + alert((data && data.message) ? data.message : 'Patient not found'); + }) + .catch(function() { + alert('Unable to resolve patient. Try again.'); + }); +} diff --git a/app/Views/immunization/index.php b/app/Views/immunization/index.php new file mode 100644 index 0000000..bb0150a --- /dev/null +++ b/app/Views/immunization/index.php @@ -0,0 +1,465 @@ + +
+
+
+
+
Immunization Record
+
Patient immunization schedule and vaccination completion
+
+
+ +
+
+
+ + + +
+
+ + +
+
+ +
+
+
Enter UHID like P26071000012 or internal patient id.
+
+
+ +
+
+
+ +
+
+
+
Patient
+
+
Selected patient
+
-
+
Enter a patient ID and load.
+
+
+ + +
Load patient first.
+
+ +
+
0Total
+
0Due
+
0Overdue
+
0Completed
+
+
+
+
+
+
+
+
Vaccination Timeline
+
No records loaded
+
+
+
+ + + + + + + + + + + + + + + + +
AgeVaccineDoseDueGivenStatusLotAction
No patient loaded.
+
+
+
+
+
+ +
+
+
UIP Master Schedule
+
+
+
+
+ + + + + + + + + + + + +
AgeVaccineDoseRouteTarget DiseaseNotes
+
+
+
+ + + + +
+ + + + \ No newline at end of file diff --git a/app/Views/immunization/schedule_master.php b/app/Views/immunization/schedule_master.php new file mode 100644 index 0000000..56db57a --- /dev/null +++ b/app/Views/immunization/schedule_master.php @@ -0,0 +1,241 @@ + +
+
+
+
+
UIP Schedule Master
+
Edit vaccine, age, route, dose and notes used for new patient schedules.
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SortAgeVaccineDoseOffset DaysRouteTarget DiseaseStatusAction
No UIP schedule found.
+
+
+
Active' : 'Inactive' ?> + +
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index 9ad389e..ab5bb6d 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -379,6 +379,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) { Document Issue +