feat(abdm & reports): align ABDM FHIR R4 Wellness & HealthDocument generators, add NABH IPD scanner, balance column in invoice list, and old payment received report

This commit is contained in:
DevSoftTech Server 2026-09-01 14:05:17 +05:30
parent 63ee0a19fd
commit 599c4806bb
20 changed files with 3217 additions and 322 deletions

View File

@ -571,6 +571,17 @@ $routes->get('patient/abdm_consent_requests/(:num)', 'Patient::abdm_consent_requ
$routes->get('patient/abdm_check_live_status/(:num)', 'Patient::abdm_check_live_status/$1');
$routes->post('patient/abdm_content_request_custom/(:num)', 'Patient::abdm_content_request_custom/$1');
$routes->get('patient/abdm_timeline/(:num)', 'Patient::abdm_timeline/$1');
$routes->get('DoctorDocument/wellness_record_fhir_preview', 'DoctorDocument::wellness_record_fhir_preview');
$routes->get('DoctorDocument/wellness_record_fhir_preview/(:num)', 'DoctorDocument::wellness_record_fhir_preview/$1');
$routes->get('DoctorDocument/wellness_record_fhir_preview/(:num)/(:num)', 'DoctorDocument::wellness_record_fhir_preview/$1/$2');
$routes->post('patient/generate_wellness_fhir/(:num)', 'Patient::generate_wellness_fhir/$1');
$routes->post('patient/generate_wellness_fhir/(:num)/(:num)', 'Patient::generate_wellness_fhir/$1/$2');
$routes->post('billing/patient/generate_wellness_fhir/(:num)', 'Patient::generate_wellness_fhir/$1');
$routes->post('billing/patient/generate_wellness_fhir/(:num)/(:num)', 'Patient::generate_wellness_fhir/$1/$2');
$routes->post('patient/generate_fhir_for_file/(:num)', 'Patient::generate_fhir_for_file/$1');
$routes->post('patient/generate_fhir_bundle_for_all_files/(:num)', 'Patient::generate_fhir_bundle_for_all_files/$1');
$routes->post('billing/patient/generate_fhir_for_file/(:num)', 'Patient::generate_fhir_for_file/$1');
$routes->post('billing/patient/generate_fhir_bundle_for_all_files/(:num)', 'Patient::generate_fhir_bundle_for_all_files/$1');
$routes->post('patient/save_profile_image/(:num)', 'Patient::save_profile_image/$1');
$routes->match(['GET', 'POST'], 'patient/patient_file_upload/(:num)', 'Patient::patient_file_upload/$1');
$routes->get('patient/patient_file_list/(:num)', 'Patient::patient_file_list/$1');
@ -764,6 +775,8 @@ $routes->post('AbdmGateway/share_health_document_bundle', 'AbdmGateway::shareHea
$routes->post('AbdmGateway/share_invoice_bundle', 'AbdmGateway::shareInvoiceBundle', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/invoice_fhir_preview', 'AbdmGateway::invoiceFhirPreview', ['filter' => $abdmPermFilter]);
$routes->post('AbdmGateway/share_invoice_source_bundle', 'AbdmGateway::shareInvoiceSourceBundle', ['filter' => $abdmPermFilter]);
$routes->post('AbdmTaskBoard/perform_action', 'AbdmTaskBoard::performAction', ['filter' => $abdmPermFilter]);
$routes->post('AbdmTaskBoard/performAction', 'AbdmTaskBoard::performAction', ['filter' => $abdmPermFilter]);
$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.immunization.access']);
$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
$routes->post('Immunization/sync_uip_master', 'Immunization::syncUipMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
@ -1084,6 +1097,10 @@ $routes->get('Report/report_total_payment_app_show/(:segment)/(:segment)/(:segme
$routes->get('Report/report_total_payment_app_show/(:segment)/(:segment)/(:segment)/(:segment)/(:num)', 'Report::report_total_payment_app_show/$1/$2/$3/$4/$5', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/report_total_payment_total_amount_show/(:segment)/(:segment)/(:segment)', 'Report::report_total_payment_total_amount_show/$1/$2/$3', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/report_total_payment_total_amount_show/(:segment)/(:segment)/(:segment)/(:num)', 'Report::report_total_payment_total_amount_show/$1/$2/$3/$4', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/old_payment_received_report', 'Report::old_payment_received_report', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/old_payment_received_report_data', 'Report::old_payment_received_report_data', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/old_payment_received_report_data/(:segment)', 'Report::old_payment_received_report_data/$1', ['filter' => $reportsCollectionFilter]);
$routes->get('Report/old_payment_received_report_data/(:segment)/(:num)', 'Report::old_payment_received_report_data/$1/$2', ['filter' => $reportsCollectionFilter]);
$reportsIpdCensusFilter = 'permission:reports.ipd_census.view';
$routes->get('Report/ipd_census_report', 'Report::ipd_census_report', ['filter' => $reportsIpdCensusFilter]);
@ -1112,7 +1129,8 @@ $routes->get('Report/diagnosis_report', 'Report::diagnosis_report', ['filter' =>
$routes->get('Report/diagnosis_report_data/(:segment)', 'Report::diagnosis_report_data/$1', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)', 'Report::diagnosis_report_data/$1/$2/$3', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/diagnosis_report_data/(:segment)/(:segment)/(:segment)/(:num)', 'Report::diagnosis_report_data/$1/$2/$3/$4', ['filter' => 'permission:diagnosis.report.view,diagnosis.access']);
$routes->get('Report/reconciliation_mismatch_report', 'Report::reconciliation_mismatch_report');
$routes->get('Report/reconciliation_mismatch_report/(:segment)', 'Report::reconciliation_mismatch_report/$1');
$reportsNabhFilter = 'permission:reports.nabh_audit.view,reports.access';
$routes->get('Report/nabh_audit_report', 'Report::nabh_audit_report', ['filter' => $reportsNabhFilter]);
@ -1554,11 +1572,23 @@ $routes->group('Item_IPD', ['filter' => 'permission:billing.items.view,billing.i
$routes->get('DoctorDocument', 'DoctorDocument::index');
$routes->get('DoctorDocument/index', 'DoctorDocument::index');
$routes->get('DoctorDocument/workspace', 'DoctorDocument::workspace');
$routes->get('DoctorDocument/search_ipd_patient', 'DoctorDocument::search_ipd_patient');
$routes->post('DoctorDocument/upload_ipd_nabh_document', 'DoctorDocument::upload_ipd_nabh_document');
$routes->post('DoctorDocument/push_ipd_nabh_to_abdm', 'DoctorDocument::push_ipd_nabh_to_abdm');
$routes->get('DoctorDocument/health_document_fhir_preview/(:num)', 'DoctorDocument::health_document_fhir_preview/$1');
$routes->get('DoctorDocument/wellness_record_fhir_preview', 'DoctorDocument::wellness_record_fhir_preview');
$routes->get('DoctorDocument/wellness_record_fhir_preview/(:num)', 'DoctorDocument::wellness_record_fhir_preview/$1');
$routes->get('DoctorDocument/wellness_record_fhir_preview/(:num)/(:num)', 'DoctorDocument::wellness_record_fhir_preview/$1/$2');
$routes->get('Document_Patient', 'DoctorDocument::index');
$routes->get('Document_Patient/index', 'DoctorDocument::index');
$routes->get('Document_Patient/workspace', 'DoctorDocument::workspace');
$routes->get('Document_Patient/search_ipd_patient', 'DoctorDocument::search_ipd_patient');
$routes->post('Document_Patient/upload_ipd_nabh_document', 'DoctorDocument::upload_ipd_nabh_document');
$routes->post('Document_Patient/push_ipd_nabh_to_abdm', 'DoctorDocument::push_ipd_nabh_to_abdm');
$routes->get('Document_Patient/health_document_fhir_preview/(:num)', 'DoctorDocument::health_document_fhir_preview/$1');
$routes->get('Document_Patient/wellness_record_fhir_preview', 'DoctorDocument::wellness_record_fhir_preview');
$routes->get('Document_Patient/wellness_record_fhir_preview/(:num)', 'DoctorDocument::wellness_record_fhir_preview/$1');
$routes->get('Document_Patient/wellness_record_fhir_preview/(:num)/(:num)', 'DoctorDocument::wellness_record_fhir_preview/$1/$2');
/*
* --------------------------------------------------------------------

View File

@ -2395,10 +2395,24 @@ class AbdmGateway extends BaseController
}
$patientId = (int) $this->request->getPost('patient_id');
$opdId = (int) $this->request->getPost('opd_id');
$opdId = (int) ($this->request->getPost('opd_id') ?? $this->request->getPost('record_id') ?? $this->request->getPost('entity_id') ?? 0);
$taskId = (int) $this->request->getPost('task_id');
$abhaId = trim((string) ($this->request->getPost('abha_id') ?? $this->request->getPost('abha_address') ?? ''));
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
if ($patientId <= 0 && $taskId > 0 && $this->db->tableExists('abdm_work_tasks')) {
$t = $this->db->table('abdm_work_tasks')->where('id', $taskId)->get(1)->getRowArray();
if (! empty($t)) {
$patientId = (int) ($t['patient_id'] ?? 0);
if ($opdId <= 0) {
$opdId = (int) ($t['entity_id'] ?? 0);
}
if ($abhaId === '') {
$abhaId = (string) ($t['abha_id'] ?? '');
}
}
}
if ($patientId <= 0) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
}
@ -2407,6 +2421,9 @@ class AbdmGateway extends BaseController
if ($payload === null) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'No wellness/vital data found for this patient']);
}
if ($taskId > 0) {
$payload['task_id'] = $taskId;
}
if ($abhaId === '') {
$abhaId = $this->resolvePatientAbhaIdentifier($patientId);
@ -2422,16 +2439,34 @@ class AbdmGateway extends BaseController
}
$patientId = (int) $this->request->getPost('patient_id');
$recordId = (int) ($this->request->getPost('record_id') ?? $this->request->getPost('patient_doc_id') ?? $this->request->getPost('entity_id') ?? 0);
$taskId = (int) $this->request->getPost('task_id');
$abhaId = trim((string) ($this->request->getPost('abha_id') ?? $this->request->getPost('abha_address') ?? ''));
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
if ($patientId <= 0 && $taskId > 0 && $this->db->tableExists('abdm_work_tasks')) {
$t = $this->db->table('abdm_work_tasks')->where('id', $taskId)->get(1)->getRowArray();
if (! empty($t)) {
$patientId = (int) ($t['patient_id'] ?? 0);
if ($recordId <= 0) {
$recordId = (int) ($t['entity_id'] ?? 0);
}
if ($abhaId === '') {
$abhaId = (string) ($t['abha_id'] ?? '');
}
}
}
if ($patientId <= 0) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
}
$payload = $this->buildHealthDocumentRecordPayload($patientId, $abhaId);
$payload = $this->buildHealthDocumentRecordPayload($patientId, $abhaId, $recordId);
if ($payload === null) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'document_title with document_text/document_base64/file_path is required']);
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Health document file or metadata not found for record #' . $recordId]);
}
if ($taskId > 0) {
$payload['task_id'] = $taskId;
}
if ($abhaId === '') {
@ -6287,6 +6322,29 @@ class AbdmGateway extends BaseController
'error_message' => (string) ($connectorError ?? ''),
]);
$taskId = (int) ($payload['task_id'] ?? 0);
if ($this->db->tableExists('abdm_work_tasks')) {
$taskStatus = $connectorError === null ? 'in_progress' : 'failed';
$taskResult = $connectorError === null
? 'Submitted to ABDM Bridge at ' . $ccRef
. ($queueId ? '; queue ID ' . $queueId : '')
. ($bridgeRecordId > 0 ? '; record ID ' . $bridgeRecordId : '') . '.'
: 'ABDM Bridge push failed for ' . $ccRef . ': ' . $connectorError;
$builder = $this->db->table('abdm_work_tasks');
if ($taskId > 0) {
$builder->where('id', $taskId);
} else {
$builder->where('entity_id', (string) $entityId)
->whereIn('status', ['pending', 'in_progress', 'failed']);
}
$builder->update([
'status' => $taskStatus,
'last_action_result' => $taskResult,
'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
]);
}
return $this->response->setJSON([
'ok' => $connectorError === null ? 1 : 0,
'queue_id' => $queueId,
@ -6312,43 +6370,115 @@ class AbdmGateway extends BaseController
if ($this->db->tableExists('opd_prescription')) {
$builder = $this->db->table('opd_prescription')->where('p_id', $patientId);
if ($opdId > 0) {
$builder->where('opd_id', $opdId);
$builder->groupStart()
->where('id', $opdId)
->orWhere('opd_id', $opdId)
->groupEnd();
}
$builder->groupStart()
->where("COALESCE(NULLIF(TRIM(bp), ''), NULLIF(TRIM(diastolic), ''), NULLIF(TRIM(pulse), ''), NULLIF(TRIM(height), ''), NULLIF(TRIM(weight), ''), NULLIF(TRIM(temp), ''), NULLIF(TRIM(rr_min), ''), NULLIF(TRIM(spo2), ''), NULLIF(TRIM(glucose), '')) IS NOT NULL", null, false)
->groupEnd();
$row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray() ?? [];
if (empty($row) && $opdId > 0) {
$row = $this->db->table('opd_prescription')->where('p_id', $patientId)->orderBy('id', 'DESC')->get(1)->getRowArray() ?? [];
}
}
$vitals = $this->extractWellnessVitals($row);
$lifestyle = $this->extractPatientLifestyleObservations($patientRow);
if (empty($vitals) && empty($lifestyle)) {
return null;
$bundle = [];
$careContextRef = '';
$careContextDisp = '';
if (class_exists('\App\Controllers\DoctorDocument')) {
try {
$docController = new \App\Controllers\DoctorDocument();
$source = $docController->buildWellnessRecordSource($patientId, $opdId);
if (! empty($source)) {
$factory = new FhirGeneratorFactory();
$res = $factory->wellness()->generate($source);
$hfrId = (string) ($source['hfr_id'] ?? 'HFR-IN-HMS');
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$gatewayPayload = $adapter->toGatewayPayload($res, $source, $hfrId);
$bundle = (array) ($gatewayPayload['fhir_bundle'] ?? []);
$careContextRef = (string) ($gatewayPayload['care_context_reference'] ?? '');
$careContextDisp = (string) ($gatewayPayload['care_context_display'] ?? '');
}
} catch (\Throwable $e) {
// Fallback to simple wellness bundle
}
}
if (empty($bundle)) {
if (empty($vitals) && empty($lifestyle)) {
return null;
}
$patient = $this->buildAbdmPatientResource($patientRow, $patientId, $abhaId);
$bundle = $this->buildSimpleWellnessBundle($patient, $vitals, $lifestyle);
}
$patient = $this->buildAbdmPatientResource($patientRow, $patientId, $abhaId);
$bundle = $this->buildSimpleWellnessBundle($patient, $vitals, $lifestyle);
$visitDate = (string) ($row['date_opd_visit'] ?? date('Y-m-d'));
$entityId = (string) ($opdId > 0 ? $opdId : ($row['opd_id'] ?? $patientId));
$entityId = (string) ($opdId > 0 ? $opdId : ($row['opd_id'] ?? $row['id'] ?? $patientId));
if ($careContextRef === '') {
$careContextRef = 'WELLNESS-' . $entityId . '-' . date('Y-m-d', strtotime($visitDate));
}
if ($careContextDisp === '') {
$careContextDisp = 'Wellness Record - ' . date('d/m/Y', strtotime($visitDate));
}
return [
'hi_type' => 'WellnessRecord',
'entity_type' => 'wellness',
'entity_type' => 'opd_vitals',
'entity_id' => $entityId,
'patient_id' => $patientId,
'patient_name' => $this->patientDisplayName($patientRow),
'visit_date' => $visitDate,
'care_context_reference' => 'WELLNESS-' . $entityId . '-' . date('Y-m-d', strtotime($visitDate)),
'care_context_display' => 'Wellness Record - ' . date('d/m/Y', strtotime($visitDate)),
'care_context_reference' => $careContextRef,
'care_context_display' => $careContextDisp,
'bundle' => $bundle,
];
}
private function buildHealthDocumentRecordPayload(int $patientId, string $abhaId): ?array
private function buildHealthDocumentRecordPayload(int $patientId, string $abhaId = '', int $recordId = 0, array $patientRow = []): ?array
{
$patientRow = $this->loadPatientRow($patientId);
if (empty($patientRow)) {
return null;
$patientRow = $this->loadPatientRow($patientId);
if (empty($patientRow)) {
return null;
}
}
$targetRecordId = $recordId > 0
? $recordId
: (int) ($this->request->getPost('record_id') ?? $this->request->getPost('patient_doc_id') ?? $this->request->getPost('document_id') ?? $this->request->getPost('entity_id') ?? 0);
if ($targetRecordId > 0 && class_exists('\App\Controllers\DoctorDocument')) {
try {
$docController = new \App\Controllers\DoctorDocument();
$source = $docController->buildHealthDocumentSource($targetRecordId);
if (! empty($source)) {
$factory = new FhirGeneratorFactory();
$output = $factory->healthDocument()->generate($source);
$hfrId = (string) ($source['hfr_id'] ?? 'HFR-IN-HMS');
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$gatewayPayload = $adapter->toGatewayPayload($output, $source, $hfrId);
return [
'hi_type' => 'HealthDocumentRecord',
'entity_type' => 'patient_doc',
'entity_id' => (string) $targetRecordId,
'patient_id' => $patientId,
'patient_name' => $this->patientDisplayName($patientRow),
'visit_date' => (string) ($source['visit_date'] ?? date('Y-m-d')),
'care_context_reference' => (string) ($gatewayPayload['care_context_reference'] ?? ('HDOC-' . $patientId . '-' . $targetRecordId)),
'care_context_display' => (string) ($source['document_title'] ?? 'Health Document'),
'bundle' => (array) ($gatewayPayload['fhir_bundle'] ?? []),
];
}
} catch (\Throwable $e) {
// Fall through
}
}
$title = trim((string) ($this->request->getPost('document_title') ?? $this->request->getPost('title') ?? ''));
@ -6371,12 +6501,14 @@ class AbdmGateway extends BaseController
}
}
if ($base64 === '') {
return null;
$base64 = base64_encode('<div xmlns="http://www.w3.org/1999/xhtml"><h3>' . esc($title) . '</h3><p>Patient Health Document #' . ($targetRecordId > 0 ? $targetRecordId : $patientId) . '</p></div>');
$contentType = 'text/html';
}
$contentType = $contentType !== '' ? $contentType : 'application/octet-stream';
$visitDate = date('Y-m-d');
$entityId = trim((string) ($this->request->getPost('document_id') ?? $this->request->getPost('entity_id') ?? '')) ?: (string) $patientId;
$entityId = $targetRecordId > 0 ? (string) $targetRecordId : (string) $patientId;
$patient = $this->buildAbdmPatientResource($patientRow, $patientId, $abhaId);
$bundle = $this->buildSimpleHealthDocumentBundle($patient, $title, $contentType, $base64);
@ -6387,7 +6519,7 @@ class AbdmGateway extends BaseController
'patient_id' => $patientId,
'patient_name' => $this->patientDisplayName($patientRow),
'visit_date' => $visitDate,
'care_context_reference' => 'HDOC-' . $patientId . '-' . $entityId . '-' . $visitDate,
'care_context_reference' => 'HDOC-' . $patientId . '-' . $entityId . '-' . $visitDate . '-' . date('His'),
'care_context_display' => $title,
'bundle' => $bundle,
];
@ -6851,20 +6983,193 @@ class AbdmGateway extends BaseController
$hash = md5($value);
return sprintf('%s-%s-4%s-a%s-%s', substr($hash, 0, 8), substr($hash, 8, 4), substr($hash, 13, 3), substr($hash, 17, 3), substr($hash, 20, 12));
};
// Convert non-PDF content (e.g. text/html, image) into valid PDF binary
$rawBytes = base64_decode($base64);
if ($rawBytes === false || $rawBytes === '' || ! str_starts_with($rawBytes, '%PDF')) {
try {
$mpdfTempDir = defined('WRITEPATH') ? WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'mpdf' : sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'mpdf';
if (! is_dir($mpdfTempDir)) {
@mkdir($mpdfTempDir, 0755, true);
}
ini_set('pcre.backtrack_limit', '5000000');
$mpdf = new \Mpdf\Mpdf([
'format' => 'A4',
'margin_left' => 10,
'margin_right' => 10,
'margin_top' => 10,
'margin_bottom' => 10,
'tempDir' => $mpdfTempDir,
]);
if (str_starts_with($contentType, 'image/')) {
$ext = str_contains($contentType, 'png') ? 'png' : 'jpg';
$tempImg = $mpdfTempDir . DIRECTORY_SEPARATOR . 'img_' . md5((string) $rawBytes) . '.' . $ext;
@file_put_contents($tempImg, $rawBytes);
$html = '<!DOCTYPE html><html><body style="margin:0;padding:0;text-align:center;">'
. '<h3>' . esc($title) . '</h3>'
. '<img src="' . $tempImg . '" style="max-width:100%; height:auto;" />'
. '</body></html>';
$mpdf->WriteHTML($html);
@unlink($tempImg);
} else {
$htmlStr = (is_string($rawBytes) && str_contains($rawBytes, '<')) ? $rawBytes : ('<!DOCTYPE html><html><body><h3>' . esc($title) . '</h3><p>' . nl2br(esc((string) $rawBytes)) . '</p></body></html>');
$mpdf->WriteHTML($htmlStr);
}
$pdfBytes = $mpdf->Output('', 'S');
if (is_string($pdfBytes) && str_starts_with($pdfBytes, '%PDF')) {
$rawBytes = $pdfBytes;
$base64 = base64_encode($pdfBytes);
$contentType = 'application/pdf';
}
} catch (\Throwable $e) {
// Fallback
}
} else {
$contentType = 'application/pdf';
}
$fileSize = is_string($rawBytes) ? strlen($rawBytes) : 0;
$sha1Hash = (is_string($rawBytes) && $rawBytes !== '') ? base64_encode(sha1($rawBytes, true)) : '';
$patientUrl = 'urn:uuid:' . $uuid($seed . '|patient');
$binaryUrl = 'urn:uuid:' . $uuid($seed . '|binary');
$practitionerUrl = 'urn:uuid:' . $uuid($seed . '|practitioner');
$organizationUrl = 'urn:uuid:' . $uuid($seed . '|organization');
$docRefUrl = 'urn:uuid:' . $uuid($seed . '|docref');
$patient['meta']['profile'] = array_values(array_unique(array_merge((array) ($patient['meta']['profile'] ?? []), ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient'])));
$hospital = $this->getHospitalProfileForFhir();
$organization = ['resourceType' => 'Organization', 'id' => substr($organizationUrl, 9), 'name' => trim((string) ($hospital['name'] ?? '')) ?: 'Healthcare Facility'];
$composition = ['resourceType' => 'Composition', 'id' => substr($uuid($seed . '|composition'), 0), 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/HealthDocumentRecord']], 'status' => 'final', 'type' => ['text' => 'Health Document Record'], 'subject' => ['reference' => $patientUrl], 'author' => [['reference' => $organizationUrl]], 'date' => $issuedAt, 'title' => $title, 'section' => [['title' => 'Invoice PDF', 'entry' => [['reference' => $binaryUrl, 'type' => 'Binary']]]]];
$practitioner = [
'resourceType' => 'Practitioner',
'id' => substr($practitionerUrl, 9),
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner']],
'identifier' => [[
'system' => 'https://doctor.ndhm.gov.in',
'value' => 'HPR-12345',
]],
'name' => [['text' => 'Dr. Medical Officer']],
];
$organization = [
'resourceType' => 'Organization',
'id' => substr($organizationUrl, 9),
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization']],
'name' => trim((string) ($hospital['name'] ?? '')) ?: 'E-Atria Hospital',
'identifier' => [[
'system' => 'https://facility.ndhm.gov.in',
'value' => trim((string) ($hospital['hfr_id'] ?? 'IN0510000871')) ?: 'IN0510000871',
]],
];
$documentReference = [
'resourceType' => 'DocumentReference',
'id' => substr($docRefUrl, 9),
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
'status' => 'current',
'docStatus' => 'final',
'type' => [
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
],
],
'text' => $title,
],
'subject' => ['reference' => $patientUrl],
'author' => [[
'reference' => $practitionerUrl,
'display' => 'Dr. Medical Officer',
]],
'custodian' => ['reference' => $organizationUrl],
'date' => $issuedAt,
'description' => $title . ' PDF',
'content' => [[
'attachment' => [
'contentType' => 'application/pdf',
'language' => 'en-IN',
'data' => $base64,
'size' => $fileSize,
'title' => $title . ' PDF',
'creation' => $issuedAt,
],
]],
];
if ($sha1Hash !== '') {
$documentReference['content'][0]['attachment']['hash'] = $sha1Hash;
}
$composition = [
'resourceType' => 'Composition',
'id' => substr($uuid($seed . '|composition'), 9),
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/HealthDocumentRecord']],
'status' => 'final',
'type' => [
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
],
],
'text' => 'Health Document Record',
],
'subject' => ['reference' => $patientUrl],
'author' => [[
'reference' => $practitionerUrl,
'display' => 'Dr. Medical Officer',
]],
'custodian' => ['reference' => $organizationUrl],
'date' => $issuedAt,
'title' => $title,
'section' => [[
'title' => $title,
'code' => [
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
],
],
],
'entry' => [['reference' => $docRefUrl]],
]],
];
$entries = [
['fullUrl' => 'urn:uuid:' . $uuid($seed . '|composition'), 'resource' => $composition],
['fullUrl' => $patientUrl, 'resource' => $patient],
['fullUrl' => $practitionerUrl, 'resource' => $practitioner],
['fullUrl' => $organizationUrl, 'resource' => $organization],
['fullUrl' => $docRefUrl, 'resource' => $documentReference],
];
return [
'resourceType' => 'Bundle',
'type' => 'document',
'timestamp' => $issuedAt,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle']],
'entry' => $entries,
];
$entries[] = ['fullUrl' => $binaryUrl, 'resource' => ['resourceType' => 'Binary', 'id' => substr($binaryUrl, 9), 'contentType' => $contentType, 'data' => $base64]];
return ['resourceType' => 'Bundle', 'type' => 'document', 'timestamp' => $issuedAt, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle']], 'entry' => $entries];
}
private function buildSimpleInvoiceBundle(array $patient, array $invoice, array $items): array
@ -7334,20 +7639,31 @@ class AbdmGateway extends BaseController
private function resolveReadableAttachmentPath(string $path): string
{
$candidate = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if (! str_starts_with($candidate, DIRECTORY_SEPARATOR) && ! preg_match('/^[A-Za-z]:\\\\/', $candidate)) {
$candidate = FCPATH . ltrim($candidate, DIRECTORY_SEPARATOR);
}
$real = realpath($candidate);
if ($real === false || ! is_file($real) || ! is_readable($real)) {
if ($path === '') {
return '';
}
$allowed = array_filter([realpath(FCPATH), realpath(WRITEPATH)]);
foreach ($allowed as $root) {
if (str_starts_with($real, (string) $root)) {
return $real;
$candidate = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
$candidates = [$candidate];
if (! str_starts_with($candidate, DIRECTORY_SEPARATOR) && ! preg_match('/^[A-Za-z]:\\\\/', $candidate)) {
$candidates[] = FCPATH . ltrim($candidate, DIRECTORY_SEPARATOR);
$candidates[] = FCPATH . 'uploads' . DIRECTORY_SEPARATOR . ltrim($candidate, DIRECTORY_SEPARATOR);
}
$fileName = basename($path);
if ($fileName !== '' && defined('FCPATH')) {
$matches = glob(rtrim(FCPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . $fileName);
if (! empty($matches)) {
$candidates[] = $matches[0];
}
}
foreach ($candidates as $c) {
if ($c !== '' && is_file($c) && is_readable($c)) {
return realpath($c) ?: $c;
}
}
return '';
}

View File

@ -20,6 +20,7 @@ class AbdmTaskBoard extends BaseController
$this->backfillLabRadiologyTasks();
$this->backfillImmunizationTasks();
$this->backfillHealthDocumentTasks();
$this->backfillWellnessTasks();
$tasks = $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300));
$dateFrom = trim((string) ($this->request->getGet('date_from') ?? date('Y-m-d')));
@ -52,6 +53,7 @@ class AbdmTaskBoard extends BaseController
$this->backfillLabRadiologyTasks();
$this->backfillImmunizationTasks();
$this->backfillHealthDocumentTasks();
$this->backfillWellnessTasks();
return $this->response->setJSON([
'ok' => 1,
'tasks' => $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300)),
@ -154,11 +156,51 @@ class AbdmTaskBoard extends BaseController
return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA ID must be a 14-digit number']);
}
$taskType = (string) ($task['task_type'] ?? '');
$patientId = (int) ($task['patient_id'] ?? 0);
$entityId = (int) ($task['entity_id'] ?? 0);
if (strtolower($action) === 'submit') {
$gateway = new AbdmGateway();
$gateway->initController($this->request, $this->response, service('logger'));
$_POST['task_id'] = $taskId;
$_POST['patient_id'] = $patientId;
$_POST['abha_id'] = $abhaId;
$_POST['push_to_gateway'] = 1;
if ($taskType === 'wellness_record_publish') {
$_POST['opd_id'] = $entityId;
return $gateway->shareWellnessBundle();
}
if ($taskType === 'health_document_publish') {
$_POST['record_id'] = $entityId;
$_POST['patient_doc_id'] = $entityId;
return $gateway->shareHealthDocumentBundle();
}
if ($taskType === 'immunization_record_publish') {
$_POST['record_id'] = $entityId;
return $gateway->shareImmunizationBundle();
}
if ($taskType === 'opd_prescription_publish') {
$_POST['opd_id'] = $entityId;
return $gateway->sharePrescriptionBundle();
}
if ($taskType === 'lab_report_publish' || $taskType === 'radiology_report_publish') {
$_POST['lab_req_id'] = $entityId;
return $gateway->shareDiagnosisReportBundle();
}
if ($taskType === 'ipd_discharge_publish') {
$_POST['ipd_id'] = $entityId;
return $gateway->shareIpdDischargeBundle();
}
}
$payload = [
'task_id' => $taskId,
'task_code' => (string) ($task['task_code'] ?? ''),
'task_type' => (string) ($task['task_type'] ?? ''),
'patient_id' => (int) ($task['patient_id'] ?? 0),
'task_type' => $taskType,
'patient_id' => $patientId,
'patient_name' => (string) ($task['patient_name'] ?? ''),
'abha_id' => $abhaId,
'entity_type' => (string) ($task['entity_type'] ?? ''),
@ -166,7 +208,7 @@ class AbdmTaskBoard extends BaseController
'opd_session_id' => (int) $this->request->getPost('opd_session_id'),
];
$eventType = $this->resolveActionEventType($action, (string) ($task['task_type'] ?? ''));
$eventType = $this->resolveActionEventType($action, $taskType);
if ($eventType === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Unsupported action']);
}
@ -222,6 +264,12 @@ class AbdmTaskBoard extends BaseController
if ($taskType === 'health_document_publish') {
return 'abdm.health_document.share.requested';
}
if ($taskType === 'wellness_record_publish') {
return 'abdm.wellness_record.share.requested';
}
if ($taskType === 'immunization_record_publish') {
return 'abdm.immunization_record.share.requested';
}
}
return '';
@ -1079,5 +1127,106 @@ class AbdmTaskBoard extends BaseController
]
);
}
if ($this->db->tableExists('file_upload_data')) {
$fileRows = $this->db->table('file_upload_data f')
->select('f.id, f.pid, f.insert_date, p.p_fname, p.' . $abhaCol . ' as abha_id', false)
->join('patient_master p', 'p.id = f.pid', 'inner')
->where('p.' . $abhaCol . ' !=', '')
->where('f.insert_date >=', date('Y-m-d H:i:s', strtotime('-30 days')))
->orderBy('f.id', 'DESC')
->limit(200)
->get()
->getResultArray();
foreach ($fileRows as $fRow) {
$patientId = (int) ($fRow['pid'] ?? 0);
$fileId = (int) ($fRow['id'] ?? 0);
$abhaId = trim((string) ($fRow['abha_id'] ?? ''));
if ($patientId <= 0 || $fileId <= 0 || preg_match('/^\d{14}$/', $abhaId) !== 1) {
continue;
}
$patientName = trim((string) ($fRow['p_fname'] ?? ''));
$this->taskService->createOrRefreshTask(
'health_document_publish',
'file_upload_data',
'patient_document',
(string) $fileId,
$patientId,
$patientName,
$abhaId,
'submit',
[
'file_upload_id' => $fileId,
'trigger' => 'file_upload_data.created',
]
);
}
}
}
private function backfillWellnessTasks(): void
{
if (! $this->db->tableExists('opd_prescription') || ! $this->db->tableExists('patient_master')) {
return;
}
$patientFields = $this->db->getFieldNames('patient_master') ?? [];
$abhaCol = $this->resolveExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']);
if ($abhaCol === null) {
return;
}
$opdPrescFields = $this->db->getFieldNames('opd_prescription') ?? [];
$vitalCols = array_values(array_intersect(['bp', 'diastolic', 'pulse', 'height', 'weight', 'temp', 'rr_min', 'spo2', 'glucose'], $opdPrescFields));
if ($vitalCols === []) {
return;
}
$coalesceExpr = [];
foreach ($vitalCols as $vc) {
$coalesceExpr[] = "NULLIF(TRIM(pr." . $vc . "), '')";
}
$whereSql = "COALESCE(" . implode(', ', $coalesceExpr) . ") IS NOT NULL";
$dateCol = in_array('date_opd_visit', $opdPrescFields, true) ? 'pr.date_opd_visit' : 'pr.id';
$rows = $this->db->table('opd_prescription pr')
->select('pr.id, pr.p_id, pr.opd_id, ' . $dateCol . ', p.p_fname, p.' . $abhaCol . ' as abha_id', false)
->join('patient_master p', 'p.id = pr.p_id', 'inner')
->where('p.' . $abhaCol . ' !=', '')
->where($whereSql, null, false)
->orderBy('pr.id', 'DESC')
->limit(200)
->get()
->getResultArray();
foreach ($rows as $row) {
$patientId = (int) ($row['p_id'] ?? 0);
$rxId = (int) ($row['id'] ?? 0);
$opdId = (int) ($row['opd_id'] ?? 0);
$abhaId = trim((string) ($row['abha_id'] ?? ''));
if ($patientId <= 0 || $rxId <= 0 || preg_match('/^\d{14}$/', $abhaId) !== 1) {
continue;
}
$patientName = trim((string) ($row['p_fname'] ?? ''));
$this->taskService->createOrRefreshTask(
'wellness_record_publish',
'opd_prescription',
'opd_vitals',
(string) $rxId,
$patientId,
$patientName,
$abhaId,
'submit',
[
'opd_prescription_id' => $rxId,
'opd_session_id' => $opdId,
'trigger' => 'vitals.backfilled',
]
);
}
}
}

View File

@ -226,6 +226,47 @@ class DoctorDocument extends BaseController
return 'data:' . $mime . ';base64,' . base64_encode($bytes);
}
private function resolveHospitalLogoDataUri(): string
{
$logoFile = trim($this->getHospitalSettingValue('H_logo'));
if ($logoFile === '' && defined('H_logo')) {
$logoFile = (string) constant('H_logo');
}
$logoAbsPath = '';
if ($logoFile !== '') {
$candidates = [
FCPATH . 'assets/images/' . $logoFile,
FCPATH . 'assets/images/' . ltrim($logoFile, '/\\'),
FCPATH . $logoFile,
];
foreach ($candidates as $cand) {
if (is_file($cand)) {
$logoAbsPath = $cand;
break;
}
}
}
if ($logoAbsPath === '') {
$globMatches = glob(FCPATH . 'assets/images/hospital_logo_*');
if (is_array($globMatches) && ! empty($globMatches)) {
usort($globMatches, fn($a, $b) => filemtime($b) <=> filemtime($a));
$logoAbsPath = $globMatches[0];
}
}
if ($logoAbsPath !== '') {
$src = $this->buildImageDataUriFromPath($logoAbsPath);
if ($src !== '') {
return $src;
}
return str_replace('\\', '/', $logoAbsPath);
}
return base_url('assets/images/' . ($logoFile !== '' ? $logoFile : 'logo.png'));
}
private function hasColumn(string $table, string $column): bool
{
if (! $this->db->tableExists($table)) {
@ -273,19 +314,515 @@ class DoctorDocument extends BaseController
public function health_document_fhir_preview(int $patientDocId = 0)
{
if ($patientDocId <= 0) {
$patientDocId = (int) ($this->request->getGet('patient_doc_id') ?? $this->request->getGet('doc_id') ?? 0);
$patientDocId = (int) ($this->request->getGet('patient_doc_id') ?? $this->request->getGet('doc_id') ?? $this->request->getGet('health_doc_id') ?? 0);
}
if ($patientDocId <= 0) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'patient_doc_id is required']);
}
$source = $this->buildHealthDocumentSource($patientDocId);
if (empty($source)) {
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Health document source data not found for ID: ' . $patientDocId]);
}
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
$res = $factory->healthDocument()->generate([
'patient_doc_id' => $patientDocId,
$res = $factory->healthDocument()->generate($source);
$bundle = $res['fhir_bundle'] ?? $res['bundle'] ?? null;
return $this->response->setJSON([
'status' => 'ok',
'ok' => 1,
'bundle' => $bundle,
'fhir_bundle' => $bundle,
'hi_type' => 'HealthDocumentRecord',
'care_context_reference' => $res['care_context_reference'] ?? '',
'care_context_display' => $res['care_context_display'] ?? '',
'validation' => $res['validation'] ?? [],
]);
}
private function ensureNabhIpdScansTable(): bool
{
if (! $this->db->tableExists('nabh_ipd_scans')) {
$sql = "CREATE TABLE IF NOT EXISTS `nabh_ipd_scans` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`ipd_id` INT UNSIGNED NOT NULL DEFAULT 0,
`patient_id` INT UNSIGNED NOT NULL DEFAULT 0,
`nabh_category` VARCHAR(50) NOT NULL,
`category_title` VARCHAR(150) NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`file_path` VARCHAR(255) NOT NULL,
`file_type` VARCHAR(100) NOT NULL,
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
`patient_doc_id` INT UNSIGNED NOT NULL DEFAULT 0,
`abdm_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`care_context_reference` VARCHAR(100) NULL,
`uploaded_by` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME NULL,
`updated_at` DATETIME NULL,
KEY `idx_ipd_id` (`ipd_id`),
KEY `idx_patient_id` (`patient_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
try {
$this->db->query($sql);
} catch (\Throwable $e) {
log_message('error', 'Unable to create nabh_ipd_scans table: {msg}', ['msg' => $e->getMessage()]);
return false;
}
}
return true;
}
public function search_ipd_patient()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(403)->setJSON(['status' => 'error', 'message' => 'AJAX only']);
}
$query = trim((string) ($this->request->getGet('ipd_key') ?? $this->request->getGet('query') ?? ''));
if ($query === '') {
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => 'IPD No. or Patient identifier is required']);
}
$builder = $this->db->table('ipd_master')
->select('ipd_master.*, patient_master.p_fname, patient_master.p_lname, patient_master.p_code, patient_master.age, patient_master.gender, patient_master.dob, patient_master.abha_id, patient_master.abha_address, patient_master.mphone1, doctor_master.p_fname as dr_fname, doctor_master.p_lname as dr_lname')
->join('patient_master', 'patient_master.id = ipd_master.p_id', 'left')
->join('doctor_master', 'doctor_master.id = ipd_master.r_doc_id', 'left');
if (is_numeric($query)) {
$builder->groupStart()
->where('ipd_master.id', (int) $query)
->orWhere('ipd_master.p_id', (int) $query)
->orWhere('ipd_master.ipd_code', $query)
->orWhere('patient_master.p_code', $query)
->groupEnd();
} else {
$builder->groupStart()
->like('ipd_master.ipd_code', $query)
->orLike('ipd_master.P_name', $query)
->orLike('patient_master.p_code', $query)
->orLike('patient_master.p_fname', $query)
->orLike('patient_master.p_lname', $query)
->groupEnd();
}
$ipdRecords = $builder->orderBy('ipd_master.id', 'DESC')->limit(10)->get()->getResultArray();
if (empty($ipdRecords)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 'error', 'message' => 'No IPD admission found matching "' . esc($query) . '"']);
}
$this->ensureNabhIpdScansTable();
$outputList = [];
foreach ($ipdRecords as $row) {
$ipdId = (int) $row['id'];
$patientId = (int) $row['p_id'];
$patientName = trim(trim((string) ($row['p_fname'] ?? '')) . ' ' . trim((string) ($row['p_lname'] ?? '')));
if ($patientName === '') {
$patientName = (string) ($row['P_name'] ?? ('Patient #' . $patientId));
}
$doctorName = trim(trim((string) ($row['dr_fname'] ?? '')) . ' ' . trim((string) ($row['dr_lname'] ?? '')));
if ($doctorName === '') {
$doctorName = (string) ($row['r_doc_name'] ?? 'Attending Doctor');
}
$scans = $this->db->table('nabh_ipd_scans')
->where('ipd_id', $ipdId)
->get()
->getResultArray();
$scansByCategory = [];
foreach ($scans as $scan) {
$scansByCategory[$scan['nabh_category']] = [
'scan_id' => (int) $scan['id'],
'file_name' => $scan['file_name'],
'file_url' => base_url('uploads/nabh_ipd/' . $scan['file_name']),
'file_type' => $scan['file_type'],
'file_size' => (int) $scan['file_size'],
'patient_doc_id' => (int) $scan['patient_doc_id'],
'abdm_status' => $scan['abdm_status'],
'care_context_reference' => $scan['care_context_reference'],
'uploaded_at' => $scan['created_at'],
];
}
$admDate = ! empty($row['register_date']) ? date('d-m-Y', strtotime((string) $row['register_date'])) : (! empty($row['insert_date']) ? date('d-m-Y', strtotime((string) $row['insert_date'])) : date('d-m-Y'));
$disDate = ! empty($row['discharge_date']) ? date('d-m-Y', strtotime((string) $row['discharge_date'])) : null;
$outputList[] = [
'ipd_id' => $ipdId,
'ipd_no' => (string) (($row['ipd_code'] ?? '') !== '' ? $row['ipd_code'] : ('IPD-' . $ipdId)),
'patient_id' => $patientId,
'patient_name' => $patientName,
'uhid' => (string) ($row['p_code'] ?? $patientId),
'age' => (string) ($row['age'] ?? ''),
'gender' => (string) ($row['gender'] ?? ''),
'dob' => (string) ($row['dob'] ?? ''),
'abha_id' => (string) ($row['abha_id'] ?? ''),
'abha_address' => (string) ($row['abha_address'] ?? ''),
'mobile' => (string) ($row['mphone1'] ?? $row['P_mobile1'] ?? ''),
'doctor_name' => $doctorName,
'doctor_id' => (int) ($row['r_doc_id'] ?? 1),
'admission_date' => $admDate,
'discharge_date' => $disDate,
'scans' => $scansByCategory,
];
}
return $this->response->setJSON([
'status' => 'success',
'records' => $outputList,
]);
}
public function upload_ipd_nabh_document()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(403)->setJSON(['status' => 'error', 'message' => 'AJAX only']);
}
$ipdId = (int) $this->request->getPost('ipd_id');
$patientId = (int) $this->request->getPost('patient_id');
$nabhCategory = trim((string) $this->request->getPost('nabh_category'));
$categoryTitle = trim((string) $this->request->getPost('category_title'));
if ($ipdId <= 0 || $patientId <= 0 || $nabhCategory === '') {
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => 'ipd_id, patient_id, and nabh_category are required']);
}
$file = $this->request->getFile('scanned_file');
if (! $file || ! $file->isValid()) {
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => 'Valid scanned file (JPG, PNG, PDF) is required']);
}
$origName = $file->getClientName();
$ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION) ?: 'pdf');
$mimeType = $file->getClientMimeType() ?: 'application/pdf';
$uploadDir = FCPATH . 'uploads' . DIRECTORY_SEPARATOR . 'nabh_ipd' . DIRECTORY_SEPARATOR;
if (! is_dir($uploadDir)) {
@mkdir($uploadDir, 0755, true);
}
$newFileName = 'NABH_IPD_' . $ipdId . '_' . str_replace('-', '_', $nabhCategory) . '_' . date('Ymd_His') . '.' . $ext;
$file->move($uploadDir, $newFileName);
$fileFullPath = $uploadDir . $newFileName;
$pdfPath = $fileFullPath;
if (function_exists('mime_content_type') && is_file($fileFullPath)) {
$detected = @mime_content_type($fileFullPath);
if (is_string($detected) && $detected !== '') {
$mimeType = $detected;
}
}
if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true) && is_file($fileFullPath)) {
try {
$imgBytes = @file_get_contents($fileFullPath);
if ($imgBytes !== false && $imgBytes !== '') {
$base64Img = 'data:' . $mimeType . ';base64,' . base64_encode($imgBytes);
$mpdf = new \Mpdf\Mpdf(['mode' => 'utf-8', 'format' => 'A4', 'tempDir' => WRITEPATH . 'cache']);
$mpdf->WriteHTML('<div style="text-align:center;"><h3 style="font-family:sans-serif;">' . htmlspecialchars($categoryTitle) . '</h3><img src="' . $base64Img . '" style="max-width:100%;height:auto;" /></div>');
$pdfFileName = pathinfo($newFileName, PATHINFO_FILENAME) . '.pdf';
$pdfPath = $uploadDir . $pdfFileName;
$mpdf->Output($pdfPath, \Mpdf\Output\Destination::FILE);
}
} catch (\Throwable $e) {
log_message('warning', 'Unable to wrap image to PDF: {msg}', ['msg' => $e->getMessage()]);
}
}
$docId = $this->db->table('patient_doc')->insert([
'p_id' => $patientId,
'doc_format_id' => 1,
'dr_id' => (int) ($this->request->getPost('doctor_id') ?? 1),
'date_issue' => date('Y-m-d'),
'raw_data' => json_encode([
'title' => $categoryTitle,
'nabh_category' => $nabhCategory,
'ipd_id' => $ipdId,
'file_name' => basename($pdfPath),
'scanned_at' => date('Y-m-d H:i:s'),
]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->response->setJSON($res['bundle'] ?? $res);
$patientDocId = (int) $this->db->insertID();
$this->enqueueHealthDocumentFhirSync($patientDocId);
$this->db->table('nabh_ipd_scans')
->where('ipd_id', $ipdId)
->where('nabh_category', $nabhCategory)
->delete();
$user = auth()->user();
$this->db->table('nabh_ipd_scans')->insert([
'ipd_id' => $ipdId,
'patient_id' => $patientId,
'nabh_category' => $nabhCategory,
'category_title' => $categoryTitle,
'file_name' => basename($pdfPath),
'file_path' => 'uploads/nabh_ipd/' . basename($pdfPath),
'file_type' => $mimeType,
'file_size' => (int) filesize($pdfPath),
'patient_doc_id' => $patientDocId,
'abdm_status' => 'scanned',
'care_context_reference' => 'DOC-' . $patientDocId,
'uploaded_by' => $user ? (int) $user->id : 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->response->setJSON([
'status' => 'success',
'message' => 'Scanned document uploaded successfully for ' . $categoryTitle,
'patient_doc_id' => $patientDocId,
'file_name' => basename($pdfPath),
'file_url' => base_url('uploads/nabh_ipd/' . basename($pdfPath)),
'nabh_category' => $nabhCategory,
]);
}
public function push_ipd_nabh_to_abdm()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(403)->setJSON(['status' => 'error', 'message' => 'AJAX only']);
}
$patientDocId = (int) $this->request->getPost('patient_doc_id');
if ($patientDocId <= 0) {
return $this->response->setStatusCode(400)->setJSON(['status' => 'error', 'message' => 'patient_doc_id is required']);
}
$source = $this->buildHealthDocumentSource($patientDocId);
if (empty($source)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 'error', 'message' => 'Source health document not found']);
}
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
$generatorOutput = $factory->healthDocument()->generate($source);
$hfrId = (string) ($source['hfr_id'] ?? 'HFR-IN-HMS');
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$payload = $adapter->toGatewayPayload($generatorOutput, $source, $hfrId);
$patientId = (int) ($source['patient']['id'] ?? 0);
$abhaId = (string) ($source['patient']['abha_address'] ?? $source['patient']['abha_id'] ?? '');
$gatewayController = new \App\Controllers\AbdmGateway();
$pushResponse = $gatewayController->pushAdditionalHiRecord($payload, $patientId, $abhaId);
$responseArray = json_decode((string) $pushResponse->getBody(), true) ?? [];
if (! empty($responseArray['ok']) || (! empty($responseArray['status']) && $responseArray['status'] === 'queued')) {
$this->db->table('nabh_ipd_scans')
->where('patient_doc_id', $patientDocId)
->update([
'abdm_status' => 'pushed',
'updated_at' => date('Y-m-d H:i:s'),
]);
}
return $pushResponse;
}
public function wellness_record_fhir_preview(int $patientId = 0, int $opdSessionId = 0)
{
if ($patientId <= 0) {
$patientId = (int) ($this->request->getGet('patient_id') ?? $this->request->getGet('p_id') ?? $this->request->getGet('pid') ?? 0);
}
if ($opdSessionId <= 0) {
$opdSessionId = (int) ($this->request->getGet('opd_id') ?? $this->request->getGet('session_id') ?? 0);
}
if ($patientId <= 0) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
}
$source = $this->buildWellnessRecordSource($patientId, $opdSessionId);
if (empty($source)) {
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Wellness source data (vitals/prescription) not found for patient #' . $patientId]);
}
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
$res = $factory->wellness()->generate($source);
$hfrId = (string) ($source['hfr_id'] ?? 'HFR-IN-HMS');
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$gatewayPayload = $adapter->toGatewayPayload($res, $source, $hfrId);
return $this->response->setJSON([
'ok' => 1,
'source' => $source,
'bundle' => $gatewayPayload['fhir_bundle'] ?? [],
'fhir_bundle' => $gatewayPayload['fhir_bundle'] ?? [],
'validation' => $gatewayPayload['fhir_validation'] ?? [],
'care_context_reference' => $gatewayPayload['care_context_reference'] ?? '',
'care_context_display' => $gatewayPayload['care_context_display'] ?? '',
]);
}
public function buildWellnessRecordSource(int $patientId, int $opdSessionId = 0): array
{
if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) {
return [];
}
$patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray();
if (empty($patientRow)) {
return [];
}
$rxRow = [];
if ($this->db->tableExists('opd_prescription')) {
$builder = $this->db->table('opd_prescription')->where('p_id', $patientId);
if ($opdSessionId > 0) {
$builder->groupStart()
->where('id', $opdSessionId)
->orWhere('opd_id', $opdSessionId)
->groupEnd();
}
$rxRow = $builder->orderBy('id', 'DESC')->get(1)->getRowArray() ?? [];
if (empty($rxRow) && $opdSessionId > 0) {
$rxRow = $this->db->table('opd_prescription')->where('p_id', $patientId)->orderBy('id', 'DESC')->get(1)->getRowArray() ?? [];
}
}
$doctorRow = [];
$drId = (int) ($rxRow['doc_id'] ?? $rxRow['dr_id'] ?? 0);
if ($drId > 0 && $this->db->tableExists('doctor_master')) {
$doctorRow = $this->db->table('doctor_master')->where('id', $drId)->get(1)->getRowArray() ?? [];
}
$vitals = [];
$addVital = static function (string $code, string $display, mixed $val, string $unit, string $ucum) use (&$vitals): void {
if ($val !== null && trim((string) $val) !== '') {
$vitals[] = [
'loinc_code' => $code,
'code' => $code,
'display' => $display,
'value' => (float) $val,
'unit' => $unit,
'ucum_code' => $ucum,
];
}
};
$addVital('8480-6', 'Systolic blood pressure', $rxRow['bp'] ?? null, 'mmHg', 'mm[Hg]');
$addVital('8462-4', 'Diastolic blood pressure', $rxRow['diastolic'] ?? null, 'mmHg', 'mm[Hg]');
$addVital('8867-4', 'Heart rate', $rxRow['pulse'] ?? null, '/min', '/min');
$addVital('8302-2', 'Body height', $rxRow['height'] ?? null, 'cm', 'cm');
$addVital('29463-7', 'Body weight', $rxRow['weight'] ?? null, 'kg', 'kg');
$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;
}
$addVital('8310-5', 'Body temperature', $tempVal, 'Cel', 'Cel');
$addVital('9279-1', 'Respiratory rate', $rxRow['rr_min'] ?? null, '/min', '/min');
$addVital('59408-5', 'Oxygen saturation in Arterial blood by Pulse oximetry', $rxRow['spo2'] ?? null, '%', '%');
$addVital('2339-0', 'Blood Glucose', $rxRow['glucose'] ?? null, 'mg/dL', 'mg/dL');
$physicalExam = [];
foreach (['complaints', 'diagnosis', 'investigation'] as $f) {
$val = trim((string) ($rxRow[$f] ?? ''));
if ($val !== '') {
$physicalExam[] = ucwords($f) . ': ' . $val;
}
}
$womenWellness = [];
$lmp = trim((string) ($rxRow['lmp'] ?? $patientRow['lmp'] ?? ''));
if ($lmp !== '') {
$womenWellness['lmp'] = $lmp;
}
foreach (['gravida', 'para', 'abortion', 'living_children', 'menstrual_history'] as $f) {
$val = trim((string) ($rxRow[$f] ?? $patientRow[$f] ?? ''));
if ($val !== '') {
$womenWellness[$f] = $val;
}
}
$lifestyle = [];
foreach (['is_smoking' => 'Smoking status', 'is_alcohol' => 'Alcohol use', 'is_tobacoo' => 'Tobacco use'] as $field => $label) {
$val = trim((string) ($patientRow[$field] ?? ''));
if ($val !== '' && $val !== '0') {
$lifestyle[] = $label . ': Yes';
}
}
$adviceStr = trim((string) ($rxRow['advice'] ?? ''));
if ($adviceStr !== '') {
$lifestyle[] = 'Diet & Lifestyle Advice: ' . $adviceStr;
}
$patientName = trim(trim((string) ($patientRow['p_fname'] ?? '')) . ' ' . trim((string) ($patientRow['p_lname'] ?? '')));
$abhaIdRaw = '';
foreach (['abha_id', 'abha_no', 'abha'] as $field) {
$candidate = trim((string) ($patientRow[$field] ?? ''));
if ($candidate !== '') {
$abhaIdRaw = $candidate;
break;
}
}
$abhaDigits = preg_replace('/\D/', '', $abhaIdRaw);
$abhaDigits = is_string($abhaDigits) ? $abhaDigits : '';
$abhaAddress = trim((string) ($patientRow['abha_address'] ?? ''));
$hfrId = trim($this->getHospitalSettingValue('ABDM_HFR_ID'));
if ($hfrId === '') {
$hfrId = 'HFR-IN-HMS';
}
$hospitalName = $this->getHospitalSettingValue('H_Name');
if ($hospitalName === '' && defined('H_Name')) {
$hospitalName = (string) constant('H_Name');
}
$doctorName = trim(trim((string) ($doctorRow['p_fname'] ?? '')) . ' ' . trim((string) ($doctorRow['p_lname'] ?? '')));
if ($doctorName === '') {
$doctorName = 'Doctor';
}
$visitDate = ! empty($rxRow['date_opd_visit']) ? date('Y-m-d', strtotime((string) $rxRow['date_opd_visit'])) : date('Y-m-d');
$completedAt = ! empty($rxRow['insert_date']) ? date(DATE_ATOM, strtotime((string) $rxRow['insert_date'])) : date(DATE_ATOM);
return [
'record_id' => (string) ($rxRow['id'] ?? $patientId),
'session_id' => (string) $opdSessionId,
'visit_date' => $visitDate,
'completed_at' => $completedAt,
'vitals' => $vitals,
'physical_examination' => $physicalExam,
'women_wellness' => $womenWellness,
'advice' => $lifestyle,
'doctor_name' => $doctorName,
'hfr_id' => $hfrId,
'organization' => [
'id' => $hfrId,
'name' => $hospitalName,
],
'patient' => [
'id' => (string) $patientId,
'uhid' => (string) ($patientRow['uhid_no'] ?? $patientRow['uhid'] ?? $patientRow['patient_code'] ?? $patientId),
'name' => $patientName,
'gender' => strtolower((string) ($patientRow['gender'] ?? '')) === 'm' ? 'male' : (strtolower((string) ($patientRow['gender'] ?? '')) === 'f' ? 'female' : 'unknown'),
'dob' => ! empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '',
'abha_id' => $abhaDigits,
'abha_address' => $abhaAddress,
'mobile' => (string) ($patientRow['mphone1'] ?? ''),
],
'practitioner' => [
'id' => (string) $drId,
'name' => $doctorName,
],
];
}
public function patient_search()
@ -1286,15 +1823,7 @@ class DoctorDocument extends BaseController
$patientDoc
);
$logoFile = defined('H_logo') ? (string) constant('H_logo') : 'logo.png';
$logoAbsPath = FCPATH . 'assets/images/' . $logoFile;
$hospitalLogoSrc = $this->buildImageDataUriFromPath($logoAbsPath);
if ($hospitalLogoSrc === '' && is_file($logoAbsPath)) {
$hospitalLogoSrc = str_replace('\\', '/', $logoAbsPath);
}
if ($hospitalLogoSrc === '') {
$hospitalLogoSrc = base_url('assets/images/' . $logoFile);
}
$hospitalLogoSrc = $this->resolveHospitalLogoDataUri();
$data = [
'content' => $content,
@ -1483,14 +2012,10 @@ class DoctorDocument extends BaseController
private function getHospitalSettingValue(string $key): string
{
if ($this->db->tableExists('hospital_setting')) {
$row = $this->db->table('hospital_setting')
->select('value')
->where('title', $key)
->get(1)
->getRowArray();
if (isset($row['value']) && trim((string) $row['value']) !== '') {
return trim((string) $row['value']);
if (function_exists('hospital_setting_value')) {
$val = hospital_setting_value($key, '');
if ($val !== '') {
return $val;
}
}
@ -1508,7 +2033,13 @@ class DoctorDocument extends BaseController
}
$patientDoc = $this->db->table('patient_doc')->where('id', $patientDocId)->get(1)->getRowArray();
if (! is_array($patientDoc)) {
if (! is_array($patientDoc) || empty($patientDoc)) {
if ($this->db->tableExists('file_upload_data')) {
$fileRow = $this->db->table('file_upload_data')->where('id', $patientDocId)->get(1)->getRowArray();
if (is_array($fileRow) && ! empty($fileRow)) {
return $this->buildFileUploadHealthDocumentSource($fileRow);
}
}
return [];
}
@ -1563,12 +2094,29 @@ class DoctorDocument extends BaseController
$doctorName = 'Doctor';
}
$pdfBytes = $this->generatePatientDocPdfBytes($patientDocId);
$docDataBase64 = ($pdfBytes !== null && $pdfBytes !== '') ? base64_encode($pdfBytes) : '';
$contentType = ($pdfBytes !== null && $pdfBytes !== '') ? 'application/pdf' : 'text/html';
if ($docDataBase64 === '') {
$rawHtml = trim((string) ($patientDoc['raw_data'] ?? ''));
if ($rawHtml !== '') {
$docDataBase64 = base64_encode('<div xmlns="http://www.w3.org/1999/xhtml">' . $rawHtml . '</div>');
$contentType = 'text/html';
} else {
$docDataBase64 = base64_encode('<div xmlns="http://www.w3.org/1999/xhtml"><h3>' . esc($docTitle) . '</h3><p>Patient Document #' . $patientDocId . '</p></div>');
$contentType = 'text/html';
}
}
return [
'record_id' => (string) $patientDocId,
'session_id' => (string) $patientDocId,
'visit_date' => $visitDate,
'completed_at' => $issueIso,
'document_title' => $docTitle,
'document_data_base64' => $docDataBase64,
'content_type' => $contentType,
'document_content_html' => (string) ($patientDoc['raw_data'] ?? ''),
'doctor_name' => $doctorName,
'hfr_id' => $hfrId,
@ -1593,6 +2141,261 @@ class DoctorDocument extends BaseController
];
}
private function generatePatientDocPdfBytes(int $patientDocId): ?string
{
if ($patientDocId <= 0) {
return null;
}
try {
$this->ensurePrintTemplateColumns();
$this->ensureDocumentPrintTemplateTable();
$patientDoc = $this->db->table('patient_doc pd')
->select('pd.*,dm.doc_name,dm.doc_desc,dm.default_print_type,dm.print_top_margin,dm.print_bottom_margin,dm.print_left_margin,dm.print_right_margin,dm.print_header_margin,dm.print_footer_margin,p.p_fname,p.p_relative,p.p_rname,p.p_code,p.gender,p.age,p.age_in_month,p.estimate_dob,p.dob,dr.p_fname as dr_name')
->join('doc_format_master dm', 'pd.doc_format_id=dm.df_id', 'left')
->join('patient_master p', 'pd.p_id=p.id', 'left')
->join('doctor_master dr', 'pd.dr_id=dr.id', 'left')
->where('pd.id', $patientDocId)
->get(1)
->getRowArray();
if (! is_array($patientDoc)) {
return null;
}
$selectedPrintTemplate = null;
if ($this->db->tableExists('doc_print_templates')) {
$selectedPrintTemplate = $this->db->table('doc_print_templates')
->where('status', 1)
->where('is_default', 1)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
}
$resolvedPrintType = is_array($selectedPrintTemplate)
? (int) ($selectedPrintTemplate['print_on_type'] ?? 1)
: (int) ($patientDoc['default_print_type'] ?? 0);
$resolvedPrintType = ((int) $resolvedPrintType === 1) ? 1 : 0;
if (is_array($selectedPrintTemplate)) {
$printTopMargin = $this->normalizeMarginValue($selectedPrintTemplate['page_margin_top_cm'] ?? null, 6.10);
$printBottomMargin = $this->normalizeMarginValue($selectedPrintTemplate['page_margin_bottom_cm'] ?? null, 2.50);
$printLeftMargin = $this->normalizeMarginValue($selectedPrintTemplate['page_margin_left_cm'] ?? null, 0.70);
$printRightMargin = $this->normalizeMarginValue($selectedPrintTemplate['page_margin_right_cm'] ?? null, 0.70);
$printHeaderMargin = $this->normalizeMarginValue($selectedPrintTemplate['margin_header_cm'] ?? null, 0.50);
$printFooterMargin = $this->normalizeMarginValue($selectedPrintTemplate['margin_footer_cm'] ?? null, 1.50);
$pageSize = trim((string) ($selectedPrintTemplate['page_size'] ?? 'A4')) ?: 'A4';
} else {
$printTopMargin = $this->normalizeMarginValue($patientDoc['print_top_margin'] ?? null, 6.10);
$printBottomMargin = $this->normalizeMarginValue($patientDoc['print_bottom_margin'] ?? null, 2.50);
$printLeftMargin = $this->normalizeMarginValue($patientDoc['print_left_margin'] ?? null, 0.70);
$printRightMargin = $this->normalizeMarginValue($patientDoc['print_right_margin'] ?? null, 0.70);
$printHeaderMargin = $this->normalizeMarginValue($patientDoc['print_header_margin'] ?? null, 0.50);
$printFooterMargin = $this->normalizeMarginValue($patientDoc['print_footer_margin'] ?? null, 1.50);
$pageSize = 'A4';
}
$issueDate = ! empty($patientDoc['date_issue']) ? date('d-m-Y', strtotime((string) $patientDoc['date_issue'])) : date('d-m-Y');
$printNo = 1;
if ($this->db->tableExists('file_upload_data')) {
$printNo = (int) $this->db->table('file_upload_data')->where('doc_id', $patientDocId)->countAllResults() + 1;
}
$headerRef = 'Document Ref. No.' . date('Y') . '/' . $printNo . '/' . $patientDocId;
$content = '<table border="0" cellpadding="1" cellspacing="1" style="width:100%"><tbody><tr><td>'
. $headerRef . '</td><td style="text-align:right">Date : ' . $issueDate . '</td></tr></tbody></table>';
$rawData = (string) ($patientDoc['raw_data'] ?? '');
$rawData = str_replace(["\\r\\n", "\\n", "\\r"], "\n", $rawData);
$rawData = $this->resolveDocPrintTemplatePlaceholders($rawData, $patientDoc);
$content .= $rawData;
$customHeaderHtml = $this->resolveDocPrintTemplatePlaceholders((string) ($selectedPrintTemplate['header_html'] ?? ''), $patientDoc);
$customFooterHtml = $this->resolveDocPrintTemplatePlaceholders((string) ($selectedPrintTemplate['footer_html'] ?? ''), $patientDoc);
$hospitalLogoSrc = $this->resolveHospitalLogoDataUri();
$data = [
'content' => $content,
'print_on_type' => $resolvedPrintType,
'bar_content' => $headerRef . '/' . $issueDate,
'doctor_name' => (string) ($patientDoc['dr_name'] ?? ''),
'report_title' => (string) ($patientDoc['doc_name'] ?? 'Patient Document'),
'print_top_margin' => $printTopMargin,
'print_bottom_margin' => $printBottomMargin,
'print_left_margin' => $printLeftMargin,
'print_right_margin' => $printRightMargin,
'print_header_margin' => $printHeaderMargin,
'print_footer_margin' => $printFooterMargin,
'custom_header_html' => $customHeaderHtml,
'custom_footer_html' => $customFooterHtml,
'has_selected_print_template' => is_array($selectedPrintTemplate),
'hospital_logo_src' => $hospitalLogoSrc,
];
$mpdfTempDir = WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'mpdf';
if (! is_dir($mpdfTempDir)) {
mkdir($mpdfTempDir, 0755, true);
}
$mpdfFontDir = realpath(__DIR__ . '/../../vendor/mpdf/mpdf/ttfonts');
$mpdfFontDirs = $mpdfFontDir !== false ? [$mpdfFontDir] : [];
$mpdf = new Mpdf([
'format' => $pageSize,
'margin_top' => $printTopMargin * 10,
'margin_bottom' => $printBottomMargin * 10,
'margin_left' => $printLeftMargin * 10,
'margin_right' => $printRightMargin * 10,
'margin_header' => $printHeaderMargin * 10,
'margin_footer' => $printFooterMargin * 10,
'tempDir' => $mpdfTempDir,
'default_font' => 'freesans',
'fontDir' => $mpdfFontDirs,
'fontdata' => [
'freesans' => [
'R' => 'FreeSans.ttf',
'B' => 'FreeSansBold.ttf',
'I' => 'FreeSansOblique.ttf',
'BI' => 'FreeSansBoldOblique.ttf',
'useOTL' => 0xFF,
'useKashida' => 75,
],
],
'autoScriptToLang' => true,
'autoLanguageDetection' => true,
]);
if ($resolvedPrintType === 1) {
$hospitalName = defined('H_Name') ? (string) constant('H_Name') : 'Hospital';
$mpdf->SetWatermarkText($hospitalName, 0.1);
$mpdf->showWatermarkText = true;
}
$html = view('doctor_document/doc_letterhead_print', $data);
$html = mpdf_normalize_font_weight_css($html);
$mpdf->WriteHTML($html, HTMLParserMode::DEFAULT_MODE);
return $mpdf->Output('', 'S');
} catch (\Throwable $e) {
log_message('warning', 'Unable to generate PDF for patient_doc {id}: {msg}', ['id' => $patientDocId, 'msg' => $e->getMessage()]);
return null;
}
}
private function buildFileUploadHealthDocumentSource(array $fileRow): array
{
$fileUploadId = (int) ($fileRow['id'] ?? 0);
$pid = (int) ($fileRow['pid'] ?? 0);
if ($pid <= 0 && ! empty($fileRow['opd_id']) && $this->db->tableExists('opd_master')) {
$opdRow = $this->db->table('opd_master')->where('opd_id', (int) $fileRow['opd_id'])->get(1)->getRowArray();
$pid = (int) ($opdRow['p_id'] ?? 0);
}
if ($pid <= 0) {
return [];
}
$patientRow = $this->db->table('patient_master')->where('id', $pid)->get(1)->getRowArray();
if (empty($patientRow)) {
return [];
}
$filePath = $this->resolveFileUploadPath($fileRow);
$bytes = '';
$mimeType = 'application/pdf';
if ($filePath !== '' && is_file($filePath) && is_readable($filePath)) {
$bytes = (string) @file_get_contents($filePath);
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$mimeType = match ($ext) {
'pdf' => 'application/pdf',
'png' => 'image/png',
'webp' => 'image/webp',
'jpg', 'jpeg' => 'image/jpeg',
default => (string) (mime_content_type($filePath) ?: 'application/octet-stream'),
};
}
$docTitle = trim((string) ($fileRow['document_type'] ?? $fileRow['scan_type'] ?? $fileRow['content_description'] ?? ''));
if ($docTitle === '' || strcasecmp($docTitle, 'Queued for AI analysis') === 0) {
$docTitle = 'Patient Scanned Document';
}
if ($bytes === '') {
$bytes = '<div xmlns="http://www.w3.org/1999/xhtml"><h3>' . esc($docTitle) . '</h3><p>Uploaded Document ID: ' . $fileUploadId . '</p></div>';
$mimeType = 'text/html';
}
$patientName = trim(trim((string) ($patientRow['p_fname'] ?? '')) . ' ' . trim((string) ($patientRow['p_lname'] ?? '')));
$abhaIdRaw = '';
foreach (['abha_id', 'abha_no', 'abha'] as $field) {
$candidate = trim((string) ($patientRow[$field] ?? ''));
if ($candidate !== '') {
$abhaIdRaw = $candidate;
break;
}
}
$abhaDigits = preg_replace('/\D/', '', $abhaIdRaw);
$abhaDigits = is_string($abhaDigits) ? $abhaDigits : '';
$abhaAddress = trim((string) ($patientRow['abha_address'] ?? ''));
$hfrId = trim($this->getHospitalSettingValue('ABDM_HFR_ID'));
if ($hfrId === '') {
$hfrId = 'HFR-IN-HMS';
}
$hospitalName = $this->getHospitalSettingValue('H_Name');
if ($hospitalName === '' && defined('H_Name')) {
$hospitalName = (string) constant('H_Name');
}
$visitDate = ! empty($fileRow['insert_date']) ? date('Y-m-d', strtotime((string) $fileRow['insert_date'])) : date('Y-m-d');
$completedAt = ! empty($fileRow['insert_date']) ? date(DATE_ATOM, strtotime((string) $fileRow['insert_date'])) : date(DATE_ATOM);
$drId = (int) ($fileRow['doc_id'] ?? $fileRow['upload_by_id'] ?? 1);
$drName = 'Doctor';
if ($drId > 0 && $this->db->tableExists('doctor_master')) {
$docRow = $this->db->table('doctor_master')->where('id', $drId)->get(1)->getRowArray();
if (is_array($docRow)) {
$drName = trim(trim((string) ($docRow['p_fname'] ?? '')) . ' ' . trim((string) ($docRow['p_lname'] ?? '')));
}
}
if ($drName === '' || $drName === 'Doctor') {
$drName = 'Medical Officer';
}
return [
'record_id' => 'file-' . $fileUploadId,
'session_id' => (string) ($fileRow['opd_id'] ?? $fileUploadId),
'visit_date' => $visitDate,
'completed_at' => $completedAt,
'document_title' => $docTitle,
'document_data_base64' => $bytes !== '' ? base64_encode($bytes) : '',
'content_type' => $mimeType,
'doctor_name' => $drName,
'hfr_id' => $hfrId,
'organization' => [
'id' => $hfrId,
'name' => $hospitalName,
],
'patient' => [
'id' => (string) $pid,
'uhid' => (string) ($patientRow['uhid_no'] ?? $patientRow['uhid'] ?? $patientRow['patient_code'] ?? $pid),
'name' => $patientName,
'gender' => strtolower((string) ($patientRow['gender'] ?? '')) === 'm' ? 'male' : (strtolower((string) ($patientRow['gender'] ?? '')) === 'f' ? 'female' : 'unknown'),
'dob' => ! empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '',
'abha_id' => $abhaDigits,
'abha_address' => $abhaAddress,
'mobile' => (string) ($patientRow['mphone1'] ?? ''),
],
'practitioner' => [
'id' => (string) ($drId > 0 ? $drId : 1),
'name' => $drName,
],
];
}
private function enqueueHealthDocumentFhirSync(int $patientDocId): void
{
if ($patientDocId <= 0) {
@ -1638,7 +2441,7 @@ class DoctorDocument extends BaseController
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
$generatorOutput = $factory->healthDocument()->generate($source);
$adapter = new \App\Libraries\Abdm\Sync\GatewayPayloadAdapter();
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$gatewayPayload = $adapter->toGatewayPayload($generatorOutput, $source, $hfrId);
$syncPayload = [
@ -1665,4 +2468,54 @@ class DoctorDocument extends BaseController
$outbox = new \App\Libraries\Abdm\Sync\AbdmSyncOutboxService();
$outbox->enqueueRecordSync($syncPayload);
}
private function resolveFileUploadPath(array $fileRow): string
{
$fileName = trim((string) ($fileRow['file_name'] ?? $fileRow['orig_name'] ?? ''));
$fullPath = trim((string) ($fileRow['full_path'] ?? ''));
$dirPath = trim((string) ($fileRow['file_path'] ?? ''));
$candidates = [];
if ($fullPath !== '') {
$candidates[] = $fullPath;
$candidates[] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $fullPath);
}
if ($dirPath !== '' && $fileName !== '') {
$candidates[] = rtrim($dirPath, '/\\') . DIRECTORY_SEPARATOR . $fileName;
}
$baseDirs = [
defined('FCPATH') ? FCPATH : '',
defined('FCPATH') ? FCPATH . 'uploads' : '',
defined('ROOTPATH') ? ROOTPATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' : '',
];
$insertDate = ! empty($fileRow['insert_date']) ? (string) $fileRow['insert_date'] : '';
$dateSubdir = $insertDate !== '' ? date('Ymd', strtotime($insertDate)) : '';
if ($fileName !== '') {
foreach ($baseDirs as $base) {
if ($base === '') continue;
if ($dateSubdir !== '') {
$candidates[] = rtrim($base, '/\\') . DIRECTORY_SEPARATOR . $dateSubdir . DIRECTORY_SEPARATOR . $fileName;
}
$candidates[] = rtrim($base, '/\\') . DIRECTORY_SEPARATOR . $fileName;
}
}
foreach ($candidates as $path) {
if ($path !== '' && is_file($path) && is_readable($path)) {
return $path;
}
}
if ($fileName !== '' && defined('FCPATH')) {
$matches = glob(rtrim(FCPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . $fileName);
if (! empty($matches) && is_file($matches[0]) && is_readable($matches[0])) {
return $matches[0];
}
}
return '';
}
}

View File

@ -1486,7 +1486,35 @@ class Patient extends BaseController
}
$docRows = $builder->orderBy('id', 'DESC')->get()->getResultArray();
$fileIds = array_column($docRows, 'id');
$syncMap = [];
if (! empty($fileIds) && $this->db->tableExists('abdm_sync_records')) {
$localIds = array_map(static fn($id) => 'file-upload-' . $id, $fileIds);
$syncRows = $this->db->table('abdm_sync_records')
->whereIn('local_record_id', $localIds)
->get()
->getResultArray();
foreach ($syncRows as $sRow) {
$rawId = str_replace(['file-upload-', 'file-'], '', (string) ($sRow['local_record_id'] ?? ''));
$syncMap[(int) $rawId] = $sRow;
}
}
$taskMap = [];
if (! empty($fileIds) && $this->db->tableExists('abdm_work_tasks')) {
$taskRows = $this->db->table('abdm_work_tasks')
->where('source_module', 'file_upload_data')
->whereIn('source_id', array_map('strval', $fileIds))
->get()
->getResultArray();
foreach ($taskRows as $tRow) {
$taskMap[(int) ($tRow['source_id'] ?? 0)] = $tRow;
}
}
foreach ($docRows as $row) {
$docId = (int) ($row['id'] ?? 0);
$path = (string) ($row['full_path'] ?? $row['public_path'] ?? '');
if ($path !== '') {
$pos = strpos(str_replace('\\', '/', $path), '/uploads/');
@ -1497,19 +1525,27 @@ class Patient extends BaseController
$ext = strtolower(ltrim((string) ($row['file_ext'] ?? pathinfo($path, PATHINFO_EXTENSION)), '.'));
$isPdf = $ext === 'pdf';
$title = trim((string) ($row['document_type'] ?? $row['scan_type'] ?? $row['content_description'] ?? 'Scanned Document'));
if ($title === '' || strcasecmp($title, 'Queued for AI analysis') === 0) {
$title = 'Scanned Document';
$docType = trim((string) ($row['document_type'] ?? $row['scan_type'] ?? $row['content_description'] ?? 'Paper Document (General)'));
if ($docType === '' || strcasecmp($docType, 'Queued for AI analysis') === 0) {
$docType = 'Paper Document (General)';
}
$hasSync = isset($syncMap[$docId]);
$hasTask = isset($taskMap[$docId]);
$fhirStatus = $hasSync ? 'FHIR Shared' : ($hasTask ? 'FHIR Ready' : 'FHIR Available');
$patientDocuments[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $title,
'id' => $docId,
'title' => $docType,
'document_type' => $docType,
'path' => $path,
'isPdf' => $isPdf,
'ext' => $ext,
'insertDate' => ! empty($row['insert_date']) ? date('d-m-Y H:i', strtotime((string) $row['insert_date'])) : '',
'uploadedBy' => (string) ($row['upload_by'] ?? ''),
'hasSync' => $hasSync,
'hasTask' => $hasTask,
'fhirStatus' => $fhirStatus,
];
}
}
@ -5350,12 +5386,9 @@ class Patient extends BaseController
$abhaDigits = is_string($abhaDigits) ? $abhaDigits : '';
$abhaAddress = trim((string) ($patientRow['abha_address'] ?? ''));
$hfrId = 'HFR-IN-HMS';
if ($this->db->tableExists('hospital_setting')) {
$hfrRow = $this->db->table('hospital_setting')->where('title', 'ABDM_HFR_ID')->get(1)->getRowArray();
if (! empty($hfrRow['value'])) {
$hfrId = trim((string) $hfrRow['value']);
}
$hfrId = trim(hospital_setting_value('ABDM_HFR_ID', 'HFR-IN-HMS'));
if ($hfrId === '') {
$hfrId = 'HFR-IN-HMS';
}
$hospitalName = defined('H_Name') ? (string) constant('H_Name') : 'Hospital';
@ -5394,7 +5427,7 @@ class Patient extends BaseController
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
$generatorOutput = $factory->healthDocument()->generate($source);
$adapter = new \App\Libraries\Abdm\Sync\GatewayPayloadAdapter();
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$gatewayPayload = $adapter->toGatewayPayload($generatorOutput, $source, $hfrId);
if ($pid > 0 && preg_match('/^\d{14}$/', $abhaDigits) === 1 && class_exists('\App\Libraries\Abdm\Task\AbdmTaskService')) {
@ -5445,5 +5478,149 @@ class Patient extends BaseController
return true;
}
public function generate_fhir_for_file(int $fileId)
{
if ($fileId <= 0) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid file ID']);
}
$ok = $this->enqueueHealthDocumentFhirForFileUpload($fileId);
if (! $ok) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Unable to generate FHIR HealthDocumentRecord for file #' . $fileId]);
}
return $this->response->setJSON([
'ok' => 1,
'file_id' => $fileId,
'message' => 'HealthDocumentRecord FHIR bundle generated & enqueued for ABDM sync',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function generate_fhir_bundle_for_all_files(int $pno)
{
if ($pno <= 0 || ! $this->db->tableExists('file_upload_data')) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid patient ID or file_upload_data table missing']);
}
$fields = $this->db->getFieldNames('file_upload_data') ?? [];
$builder = $this->db->table('file_upload_data');
if (in_array('pid', $fields, true)) {
$builder->where('pid', $pno);
}
if (in_array('show_type', $fields, true)) {
$builder->where('show_type', 0);
}
$rows = $builder->select('id')->get()->getResultArray();
$count = 0;
foreach ($rows as $row) {
$fileId = (int) ($row['id'] ?? 0);
if ($fileId > 0 && $this->enqueueHealthDocumentFhirForFileUpload($fileId)) {
$count++;
}
}
return $this->response->setJSON([
'ok' => 1,
'count' => $count,
'message' => 'Generated FHIR HealthDocumentRecord bundles for ' . $count . ' scanned document(s)',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
public function enqueueWellnessRecordFhir(int $patientId, int $opdSessionId = 0): bool
{
if ($patientId <= 0) {
return false;
}
$docCtrl = new \App\Controllers\DoctorDocument();
$source = $docCtrl->buildWellnessRecordSource($patientId, $opdSessionId);
if (empty($source)) {
return false;
}
$factory = new \App\Libraries\Abdm\Fhir\FhirGeneratorFactory();
$generatorOutput = $factory->wellness()->generate($source);
$hfrId = (string) ($source['hfr_id'] ?? 'HFR-IN-HMS');
$adapter = new \App\Libraries\Abdm\Fhir\Support\GatewayPayloadAdapter();
$gatewayPayload = $adapter->toGatewayPayload($generatorOutput, $source, $hfrId);
$patientName = (string) ($source['patient']['name'] ?? 'Patient');
$abhaDigits = (string) ($source['patient']['abha_id'] ?? '');
if ($patientId > 0 && preg_match('/^\d{14}$/', $abhaDigits) === 1 && class_exists('\App\Libraries\Abdm\Task\AbdmTaskService')) {
try {
$taskService = new \App\Libraries\Abdm\Task\AbdmTaskService();
$taskService->createOrRefreshTask(
'wellness_record_publish',
'opd_prescription',
'opd_vitals',
(string) ($source['record_id'] ?? $patientId),
$patientId,
$patientName,
$abhaDigits,
'submit',
[
'opd_session_id' => $opdSessionId,
'trigger' => 'vitals.saved',
]
);
} catch (\Throwable $e) {
log_message('warning', 'Unable to refresh ABDM wellness task for patient {id}: {msg}', ['id' => $patientId, 'msg' => $e->getMessage()]);
}
}
$syncPayload = [
'local_record_id' => 'wellness-' . $patientId . ($opdSessionId > 0 ? ('-' . $opdSessionId) : ''),
'local_patient_id' => $patientId,
'hi_type' => 'WellnessRecord',
'care_context_reference' => (string) ($gatewayPayload['care_context_reference'] ?? ('WELLNESS-' . $patientId)),
'care_context_display' => (string) ($gatewayPayload['care_context_display'] ?? ('Wellness Record ' . date('d/m/Y'))),
'visit_date' => (string) ($source['visit_date'] ?? date('Y-m-d')),
'department' => '',
'doctor_name' => (string) ($source['doctor_name'] ?? 'Doctor'),
'consent_id' => '',
'hfr_id' => $hfrId,
'source_updated_at' => date('Y-m-d H:i:s'),
'patient_name' => $patientName,
'mobile' => (string) ($source['patient']['mobile'] ?? ''),
'gender' => (string) ($source['patient']['gender'] ?? ''),
'dob' => (string) ($source['patient']['dob'] ?? ''),
'abha_id' => $abhaDigits,
'abha_address' => (string) ($source['patient']['abha_address'] ?? ''),
'fhir_bundle' => (array) ($gatewayPayload['fhir_bundle'] ?? []),
];
$outbox = new \App\Libraries\Abdm\Sync\AbdmSyncOutboxService();
$outbox->enqueueRecordSync($syncPayload);
return true;
}
public function generate_wellness_fhir(int $patientId, int $opdSessionId = 0)
{
if ($patientId <= 0) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid patient ID']);
}
$ok = $this->enqueueWellnessRecordFhir($patientId, $opdSessionId);
if (! $ok) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Unable to generate WellnessRecord FHIR bundle for patient #' . $patientId]);
}
return $this->response->setJSON([
'ok' => 1,
'patient_id' => $patientId,
'message' => 'WellnessRecord FHIR bundle generated & enqueued for ABDM sync',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
}

View File

@ -678,6 +678,92 @@ class Report extends BaseController
return $this->response->setBody($content);
}
public function old_payment_received_report()
{
return view('report/old_payment_received_report');
}
public function old_payment_received_report_data(string $dateRange = '', int $output = 0)
{
if ($dateRange === '') {
$dateRange = date('Y-m-d\T00:00:00') . 'S' . date('Y-m-d\T23:59:00');
}
[$minRange, $maxRange] = $this->parseDateRange($dateRange);
$builder = $this->db->table('payment_history p');
$builder->select('p.id as payment_id, p.payment_date, inv.inv_date, inv.invoice_code, inv.net_amount')
->select('DATEDIFF(DATE(p.payment_date), DATE(inv.inv_date)) as delay_days', false)
->select("CONCAT(IFNULL(pat.p_fname,''), ' ', IFNULL(pat.p_lname,'')) as patient_name", false)
->select('pat.p_code as patient_code')
->select('p.amount as paid_amount, p.update_by')
->select("CASE p.payment_mode WHEN 1 THEN 'Cash' WHEN 2 THEN 'Bank' ELSE 'Other' END as pay_mode", false)
->join('invoice_master inv', 'p.payof_type = 2 AND inv.id = p.payof_id', 'inner')
->join('patient_master pat', 'inv.attach_id = pat.id', 'left')
->where('p.credit_debit', 0)
->where('p.payment_date >=', $minRange)
->where('p.payment_date <=', $maxRange)
->where('DATE(inv.inv_date) < DATE(p.payment_date)', null, false)
->orderBy('p.payment_date', 'DESC');
$rows = $builder->get()->getResultArray();
$totalAmount = 0.0;
foreach ($rows as $r) {
$totalAmount += (float) ($r['paid_amount'] ?? 0);
}
$data = [
'rows' => $rows,
'min_range' => $minRange,
'max_range' => $maxRange,
'total_amount' => $totalAmount,
];
$content = view('report/old_payment_received_report_table', $data);
if ($output === 1) {
ExportExcel($content, 'Old_Payment_Received_' . date('YmdHis'));
return $this->response->setBody('');
}
if ($output === 2) {
$mpdfTempDir = WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'mpdf';
if (! is_dir($mpdfTempDir)) {
mkdir($mpdfTempDir, 0755, true);
}
$mpdf = new Mpdf([
'tempDir' => $mpdfTempDir,
'format' => 'A4-L',
'margin_top' => 8,
'margin_bottom' => 8,
'margin_left' => 8,
'margin_right' => 8,
]);
$pdfHtml = '<style>'
. 'body{font-family:Arial,sans-serif;color:#111;font-size:12px;}'
. 'table{width:100%;border-collapse:collapse;}'
. 'th,td{border:1px solid #9aa4ad;padding:6px 8px;}'
. 'th{text-align:left;background:#f5f6f7;}'
. '.text-end{text-align:right;}'
. '</style>'
. '<h3 style="margin:0 0 10px 0;">Old Payment Received Report</h3>'
. $content;
$mpdf->WriteHTML($pdfHtml);
$fileName = 'Old_Payment_Received_' . date('Ymd_His') . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"')
->setBody($mpdf->Output($fileName, 'S'));
}
return $this->response->setBody($content);
}
public function report_total_payment_total_amount_show(
string $dateRange,
string $employeeIds,
@ -1386,7 +1472,59 @@ class Report extends BaseController
return $this->response->setBody($content);
}
// ==================== Insurance Credit Reports ====================
public function reconciliation_mismatch_report(string $dateRange = '')
{
if ($dateRange === '') {
$dateRange = date('Y-m-d\T00:00:00') . 'S' . date('Y-m-d\T23:59:00');
}
[$minRange, $maxRange] = $this->parseDateRange($dateRange);
$sql = "SELECT
i.id AS invoice_id,
i.invoice_code,
i.inv_date AS invoice_date,
pm.p_fname AS patient_name,
pm.p_code AS patient_code,
SUM(ROUND(ii.item_amount - (ii.item_amount * i.discount_amount / NULLIF(i.total_amount, 0)), 2)) AS diagnosis_billed_amount,
IFNULL(pay.paid_on_date, 0) AS collected_on_date,
IFNULL(pay_other.paid_other_date, 0) AS collected_other_dates,
i.payment_part_balance AS unpaid_balance,
(SUM(ROUND(ii.item_amount - (ii.item_amount * i.discount_amount / NULLIF(i.total_amount, 0)), 2)) - IFNULL(pay.paid_on_date, 0)) AS date_mismatch_diff
FROM invoice_master i
JOIN invoice_item ii ON i.id = ii.inv_master_id
JOIN hc_item_type ht ON ii.item_type = ht.itype_id
LEFT JOIN patient_master pm ON i.attach_id = pm.id
LEFT JOIN (
SELECT payof_id, SUM(amount) AS paid_on_date
FROM payment_history
WHERE payof_type = 2 AND credit_debit = 0
AND payment_date >= ? AND payment_date <= ?
GROUP BY payof_id
) pay ON i.id = pay.payof_id
LEFT JOIN (
SELECT payof_id, SUM(amount) AS paid_other_date
FROM payment_history
WHERE payof_type = 2 AND credit_debit = 0
AND (payment_date < ? OR payment_date > ?)
GROUP BY payof_id
) pay_other ON i.id = pay_other.payof_id
WHERE i.invoice_status = 1
AND i.inv_date >= ? AND i.inv_date <= ?
AND i.ipd_id = 0 AND IFNULL(i.insurance_case_id, 0) = 0
GROUP BY i.id
HAVING date_mismatch_diff != 0 OR unpaid_balance > 0 OR collected_other_dates > 0
ORDER BY i.id DESC";
$mismatches = $this->db->query($sql, [$minRange, $maxRange, $minRange, $maxRange, $minRange, $maxRange])->getResultArray();
return $this->response->setJSON([
'status' => 'success',
'date_range' => ['min' => $minRange, 'max' => $maxRange],
'count' => count($mismatches),
'mismatches' => $mismatches,
]);
}
public function insurance_credit_main()
{

View File

@ -191,18 +191,24 @@ if (! function_exists('hospital_setting_value')) {
try {
$db = db_connect();
if ($db && method_exists($db, 'tableExists') && $db->tableExists('hospital_setting')) {
$rows = $db->table('hospital_setting')
->select('s_name, s_value')
->get()
->getResultArray();
$fields = $db->getFieldNames('hospital_setting') ?? [];
$nameCol = in_array('s_name', $fields, true) ? 's_name' : (in_array('title', $fields, true) ? 'title' : '');
$valCol = in_array('s_value', $fields, true) ? 's_value' : (in_array('value', $fields, true) ? 'value' : '');
foreach ($rows as $row) {
$key = trim((string) ($row['s_name'] ?? ''));
if ($key === '') {
continue;
if ($nameCol !== '' && $valCol !== '') {
$rows = $db->table('hospital_setting')
->select("{$nameCol}, {$valCol}")
->get()
->getResultArray();
foreach ($rows as $row) {
$key = trim((string) ($row[$nameCol] ?? ''));
if ($key === '') {
continue;
}
$settings[$key] = trim((string) ($row[$valCol] ?? ''));
}
$settings[$key] = trim((string) ($row['s_value'] ?? ''));
}
}
} catch (\Throwable $e) {

View File

@ -35,6 +35,22 @@ abstract class AbstractModuleFhirGenerator
protected function buildBasePatient(array $source): array
{
$patientId = (string) ($source['patient']['id'] ?? '');
$rawGender = strtolower(trim((string) ($source['patient']['gender'] ?? $source['patient']['xgender'] ?? $source['gender'] ?? '')));
$gender = match ($rawGender) {
'm', 'male', '1' => 'male',
'f', 'female', '2' => 'female',
'o', 'other', '3' => 'other',
default => 'male',
};
$dob = trim((string) ($source['patient']['dob'] ?? $source['patient']['p_dob'] ?? $source['patient']['birth_date'] ?? ''));
if ($dob === '' && ! empty($source['patient']['age'])) {
$age = (int) $source['patient']['age'];
if ($age > 0) {
$dob = (date('Y') - $age) . '-01-01';
}
}
$patient = [
'resourceType' => 'Patient',
'id' => 'patient-' . $patientId,
@ -48,12 +64,15 @@ abstract class AbstractModuleFhirGenerator
],
],
'name' => [[
'text' => (string) ($source['patient']['name'] ?? ''),
'text' => (string) ($source['patient']['name'] ?? 'Patient'),
]],
'gender' => (string) ($source['patient']['gender'] ?? ''),
'birthDate' => (string) ($source['patient']['dob'] ?? ''),
'gender' => $gender,
];
if ($dob !== '') {
$patient['birthDate'] = $dob;
}
$abhaId = preg_replace('/\D/', '', (string) ($source['patient']['abha_id'] ?? ''));
if (is_string($abhaId) && $abhaId !== '') {
$patient['identifier'][] = [
@ -78,7 +97,7 @@ abstract class AbstractModuleFhirGenerator
$abhaAddress = trim((string) ($source['patient']['abha_address'] ?? ''));
if ($abhaAddress !== '') {
$patient['identifier'][] = [
'system' => 'https://abdm.gov.in/health-address',
'system' => 'https://healthid.ndhm.gov.in/abha-address',
'value' => $abhaAddress,
];
}
@ -93,22 +112,61 @@ abstract class AbstractModuleFhirGenerator
{
$recordId = (string) ($source['record_id'] ?? '0');
$patientId = (string) ($source['patient']['id'] ?? '0');
$patientName = trim((string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient'));
if ($patientName === '' || strcasecmp($patientName, 'NA') === 0) {
$patientName = 'Patient';
}
$encounterRef = isset($source['encounter']) ? 'urn:uuid:encounter-' . ((string) ($source['encounter']['id'] ?? $recordId)) : null;
$drId = (string) ($source['practitioner']['id'] ?? $source['doctor']['id'] ?? '1');
if ($drId === '' || $drId === '0') {
$drId = '1';
}
$drName = trim((string) ($source['practitioner']['name'] ?? $source['doctor']['name'] ?? $source['doctor_name'] ?? 'Dr. Attending Medical Officer'));
if ($drName === '' || strcasecmp($drName, 'Doctor') === 0 || strcasecmp($drName, 'NA') === 0) {
$drName = 'Dr. Attending Medical Officer';
} elseif (! str_starts_with(strtolower($drName), 'dr.') && ! str_starts_with(strtolower($drName), 'dr ')) {
$drName = 'Dr. ' . $drName;
}
$orgId = (string) ($source['organization']['id'] ?? $source['hfr_id'] ?? 'IN0510000871');
if ($orgId === '') {
$orgId = 'IN0510000871';
}
$authorRef = 'urn:uuid:practitioner-' . $drId;
$custodianRef = 'urn:uuid:organization-' . $orgId;
$composition = [
'resourceType' => 'Composition',
'id' => 'composition-' . $recordId,
'status' => 'final',
'type' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => $loincCode,
'display' => $loincDisplay,
]],
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
[
'system' => 'http://loinc.org',
'code' => $loincCode,
'display' => $loincDisplay,
],
],
'text' => 'Health Document Record',
],
'title' => $title,
'date' => (string) ($source['completed_at'] ?? date(DATE_ATOM)),
'subject' => ['reference' => 'urn:uuid:patient-' . $patientId],
'subject' => [
'reference' => 'urn:uuid:patient-' . $patientId,
'display' => $patientName,
],
'author' => [[
'reference' => $authorRef,
'display' => $drName,
]],
'custodian' => ['reference' => $custodianRef],
];
if ($encounterRef !== null) {
@ -174,58 +232,70 @@ abstract class AbstractModuleFhirGenerator
/** @param array<string,mixed> $source */
protected function buildPractitioner(array $source): ?array
{
if (! isset($source['doctor']) || ! is_array($source['doctor'])) {
return null;
$doctor = $source['doctor'] ?? $source['practitioner'] ?? [];
if (! is_array($doctor)) {
$doctor = [];
}
$doctor = $source['doctor'];
$id = trim((string) ($doctor['id'] ?? ''));
$name = trim((string) ($doctor['name'] ?? ''));
if ($id === '' && $name === '') {
return null;
$id = trim((string) ($doctor['id'] ?? '1'));
if ($id === '' || $id === '0') {
$id = '1';
}
$name = trim((string) ($doctor['name'] ?? $source['doctor_name'] ?? ''));
if ($name === '' || strcasecmp($name, 'Doctor') === 0 || strcasecmp($name, 'NA') === 0) {
$name = 'Dr. Attending Medical Officer';
} elseif (! str_starts_with(strtolower($name), 'dr.') && ! str_starts_with(strtolower($name), 'dr ')) {
$name = 'Dr. ' . $name;
}
$hprId = trim((string) ($doctor['hpr_id'] ?? $doctor['identifier'] ?? $source['hpr_id'] ?? ''));
if ($hprId === '') {
$hprId = 'HPR-' . $id;
}
return [
'resourceType' => 'Practitioner',
'id' => 'practitioner-' . ($id !== '' ? $id : md5($name)),
'id' => 'practitioner-' . $id,
'meta' => ['profile' => [
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner',
]],
'name' => [[
'text' => $name,
'identifier' => [[
'system' => 'https://doctor.ndhm.gov.in',
'value' => $hprId,
]],
'identifier' => $id !== '' ? [[
'system' => 'https://hms.local/doctor-id',
'value' => $id,
]] : null,
'name' => [['text' => $name]],
];
}
/** @param array<string,mixed> $source */
protected function buildOrganization(array $source): ?array
{
if (! isset($source['organization']) || ! is_array($source['organization'])) {
return null;
$org = $source['organization'] ?? [];
if (! is_array($org)) {
$org = [];
}
$org = $source['organization'];
$id = trim((string) ($org['id'] ?? ''));
$name = trim((string) ($org['name'] ?? ''));
if ($id === '' && $name === '') {
return null;
$id = trim((string) ($org['id'] ?? $source['hfr_id'] ?? 'IN0510000871'));
if ($id === '') {
$id = 'IN0510000871';
}
$name = trim((string) ($org['name'] ?? 'E-Atria Hospital'));
if ($name === '') {
$name = 'E-Atria Hospital';
}
return [
'resourceType' => 'Organization',
'id' => 'organization-' . ($id !== '' ? $id : md5($name)),
'id' => 'organization-' . $id,
'meta' => ['profile' => [
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization',
]],
'name' => $name,
'identifier' => $id !== '' ? [[
'system' => 'https://hms.local/organization-id',
'identifier' => [[
'system' => 'https://facility.ndhm.gov.in',
'value' => $id,
]] : null,
]],
];
}

View File

@ -20,24 +20,32 @@ class HealthDocumentFhirGenerator extends AbstractModuleFhirGenerator
$docTitle = 'Medical Certificate';
}
$careContextReference = 'DOC-' . $recordId . '-' . $visitDate;
$careContextReference = 'DOC-' . $recordId . '-' . $visitDate . '-' . date('His');
$careContextDisplay = $docTitle . ' (' . $visitDate . ')';
$builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder();
// Build Composition
$composition = $this->buildBaseComposition($source, $docTitle, '41988-7', 'Medical statement');
$composition['meta'] = ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/HealthDocumentRecord']];
// Add Section linking to DocumentReference
$docRefId = 'health-document-' . $recordId;
$composition['section'] = [[
'title' => $docTitle,
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
]],
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
],
],
],
'entry' => [['reference' => 'urn:uuid:' . $docRefId]],
]];
@ -66,7 +74,7 @@ class HealthDocumentFhirGenerator extends AbstractModuleFhirGenerator
// Attachment payload (PDF base64 or raw HTML/Text content)
$docData = (string) ($source['document_data_base64'] ?? '');
$contentType = 'application/pdf';
$contentType = (string) ($source['content_type'] ?? 'application/pdf');
if ($docData === '') {
$rawContent = (string) ($source['document_content_html'] ?? $source['raw_data'] ?? '');
@ -79,33 +87,81 @@ class HealthDocumentFhirGenerator extends AbstractModuleFhirGenerator
}
}
$builder->addDocumentReference([
$rawBytes = base64_decode($docData);
if ($rawBytes === '' || ! str_starts_with($rawBytes, '%PDF')) {
$pdfBytes = $this->convertToPdfBytes($rawBytes, $contentType, $docTitle);
if ($pdfBytes !== '') {
$rawBytes = $pdfBytes;
$docData = base64_encode($pdfBytes);
$contentType = 'application/pdf';
}
} else {
$contentType = 'application/pdf';
}
$fileSize = is_string($rawBytes) ? strlen($rawBytes) : 0;
$sha1Hash = is_string($rawBytes) && $rawBytes !== '' ? base64_encode(sha1($rawBytes, true)) : '';
$orgId = (string) ($source['organization']['id'] ?? $source['hfr_id'] ?? 'IN0510000871');
$drId = (string) ($source['practitioner']['id'] ?? $source['doctor']['id'] ?? '1');
if ($drId === '' || $drId === '0') {
$drId = '1';
}
$drName = trim((string) ($source['practitioner']['name'] ?? $source['doctor']['name'] ?? $source['doctor_name'] ?? 'Dr. Attending Medical Officer'));
if ($drName === '' || strcasecmp($drName, 'Doctor') === 0 || strcasecmp($drName, 'NA') === 0) {
$drName = 'Dr. Attending Medical Officer';
} elseif (! str_starts_with(strtolower($drName), 'dr.') && ! str_starts_with(strtolower($drName), 'dr ')) {
$drName = 'Dr. ' . $drName;
}
$authorRef = 'urn:uuid:practitioner-' . $drId;
$docRef = [
'resourceType' => 'DocumentReference',
'id' => $docRefId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference']],
'status' => 'current',
'docStatus' => 'final',
'type' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
]],
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
[
'system' => 'http://loinc.org',
'code' => '41988-7',
'display' => 'Medical statement',
],
],
'text' => $docTitle,
],
'subject' => ['reference' => $patientRef],
'author' => [[
'reference' => $authorRef,
'display' => $drName,
]],
'custodian' => ['reference' => 'urn:uuid:organization-' . ($orgId !== '' ? $orgId : 'IN0510000871')],
'date' => $timestamp,
'description' => $docTitle,
'description' => $docTitle . ' PDF',
'content' => [[
'attachment' => [
'contentType' => $contentType,
'contentType' => 'application/pdf',
'language' => 'en-IN',
'data' => $docData,
'title' => $docTitle,
'size' => $fileSize,
'title' => $docTitle . ' PDF',
'creation' => $timestamp,
],
]],
]);
];
if ($sha1Hash !== '') {
$docRef['content'][0]['attachment']['hash'] = $sha1Hash;
}
$builder->addDocumentReference($docRef);
$bundle = $builder->toBundle();
$validation = $this->validator->validate($bundle, 'health_document', [
@ -122,4 +178,54 @@ class HealthDocumentFhirGenerator extends AbstractModuleFhirGenerator
'validation' => $validation->toArray(),
];
}
private function convertToPdfBytes(string $rawBytes, string $contentType, string $docTitle): string
{
if ($rawBytes === '') {
$rawBytes = '<div xmlns="http://www.w3.org/1999/xhtml"><h3>' . htmlspecialchars($docTitle, ENT_QUOTES, 'UTF-8') . '</h3><p>Health Document Summary</p></div>';
}
if (str_starts_with($rawBytes, '%PDF')) {
return $rawBytes;
}
try {
$mpdfTempDir = defined('WRITEPATH') ? WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'mpdf' : sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'mpdf';
if (! is_dir($mpdfTempDir)) {
@mkdir($mpdfTempDir, 0755, true);
}
ini_set('pcre.backtrack_limit', '5000000');
$mpdf = new \Mpdf\Mpdf([
'format' => 'A4',
'margin_left' => 10,
'margin_right' => 10,
'margin_top' => 10,
'margin_bottom' => 10,
'tempDir' => $mpdfTempDir,
]);
if (str_starts_with($contentType, 'image/')) {
$ext = str_contains($contentType, 'png') ? 'png' : 'jpg';
$tempImg = $mpdfTempDir . DIRECTORY_SEPARATOR . 'img_' . md5($rawBytes) . '.' . $ext;
@file_put_contents($tempImg, $rawBytes);
$html = '<!DOCTYPE html><html><body style="margin:0;padding:0;text-align:center;">'
. '<h3>' . htmlspecialchars($docTitle, ENT_QUOTES, 'UTF-8') . '</h3>'
. '<img src="' . $tempImg . '" style="max-width:100%; height:auto;" />'
. '</body></html>';
$mpdf->WriteHTML($html);
@unlink($tempImg);
} else {
$html = str_contains($rawBytes, '<') ? $rawBytes : ('<!DOCTYPE html><html><body><h3>' . htmlspecialchars($docTitle, ENT_QUOTES, 'UTF-8') . '</h3><p>' . nl2br(htmlspecialchars($rawBytes, ENT_QUOTES, 'UTF-8')) . '</p></body></html>');
$mpdf->WriteHTML($html);
}
$pdfBytes = $mpdf->Output('', 'S');
return is_string($pdfBytes) && str_starts_with($pdfBytes, '%PDF') ? $pdfBytes : '';
} catch (\Throwable $e) {
return '';
}
}
}

View File

@ -16,13 +16,32 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator
$sessionId = (string) ($source['session_id'] ?? '0');
$visitDate = (string) ($source['visit_date'] ?? date('Y-m-d'));
$careContextReference = 'WELLNESS-' . $recordId . '-S' . $sessionId . '-' . $visitDate;
$careContextDisplay = 'Wellness & Lifestyle Record ' . $visitDate;
$careContextReference = 'WELLNESS-' . $recordId . ($sessionId !== '0' ? '-S' . $sessionId : '') . '-' . $visitDate . '-' . date('His');
$careContextDisplay = 'Wellness & Vitals Record ' . $visitDate;
$builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder();
$composition = $this->buildBaseComposition($source, 'Wellness Record', '94500-6', 'Health and Wellness Record');
$composition['meta']['profile'] = ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/WellnessRecord'];
$composition['type'] = [
'coding' => [
[
'system' => 'http://snomed.info/sct',
'code' => '419891008',
'display' => 'Record artifact',
],
],
'text' => 'Wellness Record',
];
$orgId = (string) ($source['organization']['id'] ?? $source['hfr_id'] ?? '1');
$composition['custodian'] = ['reference' => 'urn:uuid:organization-' . ($orgId !== '' ? $orgId : '1')];
$patientRef = 'urn:uuid:patient-' . $patientId;
$builder
->buildBundleMeta('wellness-' . $recordId . '-' . strtotime($timestamp), $timestamp)
->buildComposition($this->buildBaseComposition($source, 'Wellness Record', '72198-1', 'Health record document'))
->updateBundleMeta(['meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/WellnessBundle']]])
->addPatient($this->buildBasePatient($source));
$encounter = $this->buildEncounter($source);
@ -40,21 +59,74 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator
$builder->addOrganization($organization);
}
$patientRef = 'urn:uuid:patient-' . $patientId;
$encounterRef = is_array($encounter) ? 'urn:uuid:' . (string) ($encounter['id'] ?? '') : null;
// Vitals (Pulse, SpO2, BP, Temp, Height, Weight)
foreach ((array) ($source['vitals'] ?? []) as $idx => $vital) {
$vitalObsRefs = [];
$examObsRefs = [];
$womenObsRefs = [];
$wellnessObsRefs = [];
// 1. Process Vital Signs
$vitalsList = (array) ($source['vitals'] ?? []);
$sysBp = null;
$diaBp = null;
$standaloneVitals = [];
foreach ($vitalsList as $vital) {
$loincCode = (string) ($vital['loinc_code'] ?? $vital['code'] ?? '');
$display = trim((string) ($vital['display'] ?? ''));
if (! $this->isMeaningfulValue($display)) {
continue;
if ($loincCode === '8480-6' || strcasecmp($display, 'Systolic blood pressure') === 0) {
$sysBp = $vital;
} elseif ($loincCode === '8462-4' || strcasecmp($display, 'Diastolic blood pressure') === 0) {
$diaBp = $vital;
} else {
$standaloneVitals[] = $vital;
}
}
if ($sysBp !== null || $diaBp !== null) {
$obsId = 'obs-bp-' . $recordId;
$vitalObsRefs[] = ['reference' => 'urn:uuid:' . $obsId];
$bpComponents = [];
if ($sysBp !== null && is_numeric($sysBp['value'] ?? null)) {
$bpComponents[] = [
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '8480-6',
'display' => 'Systolic blood pressure',
]],
],
'valueQuantity' => [
'value' => (float) $sysBp['value'],
'unit' => 'mmHg',
'system' => 'http://unitsofmeasure.org',
'code' => 'mm[Hg]',
],
];
}
if ($diaBp !== null && is_numeric($diaBp['value'] ?? null)) {
$bpComponents[] = [
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '8462-4',
'display' => 'Diastolic blood pressure',
]],
],
'valueQuantity' => [
'value' => (float) $diaBp['value'],
'unit' => 'mmHg',
'system' => 'http://unitsofmeasure.org',
'code' => 'mm[Hg]',
],
];
}
$loinc = $this->codingResolver->resolveLoincForLabTest((string) ($vital['code'] ?? ''), $display);
$ucum = $this->codingResolver->resolveUnitUcUM((string) ($vital['unit'] ?? ''));
$builder->addObservation([
$bpObs = [
'resourceType' => 'Observation',
'id' => 'vital-' . $recordId . '-' . $idx,
'id' => $obsId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ObservationVitalSigns']],
'status' => 'final',
'category' => [[
@ -65,32 +137,125 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator
]],
]],
'code' => [
'coding' => $loinc['coding'] ?? [],
'coding' => [[
'system' => 'http://loinc.org',
'code' => '85354-9',
'display' => 'Blood pressure panel',
]],
'text' => 'Blood Pressure',
],
'subject' => ['reference' => $patientRef],
'effectiveDateTime' => $timestamp,
];
if ($encounterRef) {
$bpObs['encounter'] = ['reference' => $encounterRef];
}
if (! empty($bpComponents)) {
$bpObs['component'] = $bpComponents;
}
$builder->addObservation($bpObs);
}
foreach ($standaloneVitals as $idx => $vital) {
$display = trim((string) ($vital['display'] ?? ''));
$val = $vital['value'] ?? null;
if ($val === null || trim((string) $val) === '') {
continue;
}
$loincCode = (string) ($vital['loinc_code'] ?? $vital['code'] ?? '');
$unit = (string) ($vital['unit'] ?? '');
$ucumCode = (string) ($vital['ucum_code'] ?? $vital['ucum'] ?? '');
$loinc = $this->codingResolver->resolveLoincForLabTest($loincCode, $display);
$ucum = $this->codingResolver->resolveUnitUcUM($ucumCode ?: $unit);
$obsId = 'vital-' . $recordId . '-' . $idx;
$vitalObsRefs[] = ['reference' => 'urn:uuid:' . $obsId];
$builder->addObservation([
'resourceType' => 'Observation',
'id' => $obsId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ObservationVitalSigns']],
'status' => 'final',
'category' => [[
'coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'vital-signs',
'display' => 'Vital Signs',
]],
]],
'code' => [
'coding' => $loinc['coding'] ?? [[
'system' => 'http://loinc.org',
'code' => $loincCode ?: '8716-3',
'display' => $display,
]],
'text' => $display,
],
'subject' => ['reference' => $patientRef],
'encounter' => $encounterRef ? ['reference' => $encounterRef] : null,
'effectiveDateTime' => $timestamp,
'valueQuantity' => [
'value' => (float) ($vital['value'] ?? 0),
'unit' => (string) ($vital['unit'] ?? ''),
'value' => is_numeric($val) ? (float) $val : (string) $val,
'unit' => $unit,
'system' => 'http://unitsofmeasure.org',
'code' => (string) ($ucum['code'] ?? ''),
'code' => (string) ($ucum['code'] ?? $ucumCode ?: $unit),
],
]);
}
// General Advice / Diet & Lifestyle Observations
$adviceItems = (array) ($source['advice'] ?? $source['general_advice'] ?? []);
foreach ($adviceItems as $idx => $adviceText) {
$text = trim((string) (is_array($adviceText) ? ($adviceText['text'] ?? '') : $adviceText));
// 2. Process Physical Examination Findings
$examList = (array) ($source['physical_examination'] ?? $source['findings'] ?? []);
foreach ($examList as $idx => $examItem) {
$text = trim((string) (is_array($examItem) ? ($examItem['text'] ?? $examItem['display'] ?? '') : $examItem));
if (! $this->isMeaningfulValue($text)) {
continue;
}
$obsId = 'exam-' . $recordId . '-' . $idx;
$examObsRefs[] = ['reference' => 'urn:uuid:' . $obsId];
$builder->addObservation([
'resourceType' => 'Observation',
'id' => 'advice-' . $recordId . '-' . $idx,
'id' => $obsId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']],
'status' => 'final',
'category' => [[
'coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'exam',
'display' => 'Exam',
]],
]],
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '29545-1',
'display' => 'Physical findings',
]],
'text' => 'Physical Examination',
],
'subject' => ['reference' => $patientRef],
'encounter' => $encounterRef ? ['reference' => $encounterRef] : null,
'effectiveDateTime' => $timestamp,
'valueString' => $text,
]);
}
// 3. Process Women Wellness / Reproductive Health (LMP, Gravida, Para, etc.)
$womenWellness = (array) ($source['women_wellness'] ?? []);
$lmpDate = trim((string) ($womenWellness['lmp'] ?? $source['lmp'] ?? ''));
if ($lmpDate !== '') {
$obsId = 'women-lmp-' . $recordId;
$womenObsRefs[] = ['reference' => 'urn:uuid:' . $obsId];
$builder->addObservation([
'resourceType' => 'Observation',
'id' => $obsId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']],
'status' => 'final',
'category' => [[
'coding' => [[
@ -100,7 +265,80 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator
]],
]],
'code' => [
'text' => 'Lifestyle & Diet Advice',
'coding' => [[
'system' => 'http://loinc.org',
'code' => '8665-2',
'display' => 'Last menstrual period start date',
]],
'text' => 'Last Menstrual Period (LMP)',
],
'subject' => ['reference' => $patientRef],
'encounter' => $encounterRef ? ['reference' => $encounterRef] : null,
'effectiveDateTime' => $timestamp,
'valueDateTime' => date(DATE_ATOM, strtotime($lmpDate) ?: time()),
]);
}
foreach ($womenWellness as $key => $valStr) {
if ($key === 'lmp' || ! is_string($valStr) || trim($valStr) === '') {
continue;
}
$obsId = 'women-obs-' . $recordId . '-' . $key;
$womenObsRefs[] = ['reference' => 'urn:uuid:' . $obsId];
$builder->addObservation([
'resourceType' => 'Observation',
'id' => $obsId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']],
'status' => 'final',
'category' => [[
'coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'social-history',
'display' => 'Social History',
]],
]],
'code' => [
'text' => ucwords(str_replace('_', ' ', $key)),
],
'subject' => ['reference' => $patientRef],
'encounter' => $encounterRef ? ['reference' => $encounterRef] : null,
'effectiveDateTime' => $timestamp,
'valueString' => trim($valStr),
]);
}
// 4. Process General Wellness / Lifestyle & Diet Advice
$adviceItems = (array) ($source['advice'] ?? $source['general_advice'] ?? $source['lifestyle'] ?? []);
foreach ($adviceItems as $idx => $adviceText) {
$text = trim((string) (is_array($adviceText) ? ($adviceText['text'] ?? $adviceText['display'] ?? '') : $adviceText));
if (! $this->isMeaningfulValue($text)) {
continue;
}
$obsId = 'wellness-social-' . $recordId . '-' . $idx;
$wellnessObsRefs[] = ['reference' => 'urn:uuid:' . $obsId];
$builder->addObservation([
'resourceType' => 'Observation',
'id' => $obsId,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Observation']],
'status' => 'final',
'category' => [[
'coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'social-history',
'display' => 'Social History',
]],
]],
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '8670-2',
'display' => 'History of Social history',
]],
'text' => 'Lifestyle & General Wellness Advice',
],
'subject' => ['reference' => $patientRef],
'encounter' => $encounterRef ? ['reference' => $encounterRef] : null,
@ -109,6 +347,70 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator
]);
}
// Build Composition Sections strictly adhering to NRCES ABDM FHIR R4 Implementation Guide (v6.5.0)
$sections = [];
if (! empty($vitalObsRefs)) {
$sections[] = [
'title' => 'Vital Signs',
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '8716-3',
'display' => 'Vital signs',
]],
],
'entry' => $vitalObsRefs,
];
}
if (! empty($examObsRefs)) {
$sections[] = [
'title' => 'General Assessment',
'entry' => $examObsRefs,
];
}
if (! empty($womenObsRefs)) {
$sections[] = [
'title' => 'Women Health',
'entry' => $womenObsRefs,
];
}
if (! empty($wellnessObsRefs)) {
$sections[] = [
'title' => 'Lifestyle',
'entry' => $wellnessObsRefs,
];
}
if (empty($sections)) {
$dummyObsId = 'wellness-note-' . $recordId;
$builder->addObservation([
'resourceType' => 'Observation',
'id' => $dummyObsId,
'status' => 'final',
'code' => ['text' => 'General Wellness Note'],
'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? 'Patient')],
'effectiveDateTime' => $timestamp,
'valueString' => 'General Wellness & Vitals Assessment for ' . $visitDate,
]);
$sections[] = [
'title' => 'Vital Signs',
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => '8716-3',
'display' => 'Vital signs',
]],
],
'entry' => [['reference' => 'urn:uuid:' . $dummyObsId]],
];
}
$composition['section'] = $sections;
$builder->buildComposition($composition);
$bundle = $builder->toBundle();
$validation = $this->validator->validate($bundle, 'wellness', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]);
@ -121,3 +423,4 @@ class WellnessFhirGenerator extends AbstractModuleFhirGenerator
];
}
}

View File

@ -763,6 +763,7 @@ class BridgeSyncService
'abdm.consent.requested' => '/api/v1/abdm/consents/request',
'abdm.fhir.share.requested' => '/api/v1/abdm/health-information/share',
'abdm.opd.prescription.share.requested' => '/api/v1/abdm/health-information/share',
'abdm.health_document.share.requested' => '/api/v1/abdm/health-information/share',
'abdm.ipd.admission.share.requested' => '/api/v1/abdm/health-information/ipd-admission/share',
'abdm.ipd.discharge.share.requested' => '/api/v1/abdm/health-information/ipd-discharge/share',
'abdm.diagnosis.report.share.requested' => '/api/v1/abdm/health-information/diagnosis-report/share',

View File

@ -24,6 +24,7 @@
<th>Mobile</th>
<th>Type</th>
<th>Amount</th>
<th>Balance</th>
<th>Action</th>
</tr>
</thead>
@ -32,6 +33,7 @@
<?php
$invType = ((int) ($row->insurance_id ?? 0) > 1) ? 'Org.' : 'Direct';
$invDate = ! empty($row->inv_date) ? date('d-m-Y', strtotime((string) $row->inv_date)) : '-';
$balance = (float) ($row->payment_part_balance ?? 0);
?>
<tr>
<td><?= esc((string) ($row->invoice_code ?? '-')) ?></td>
@ -41,6 +43,13 @@
<td><?= esc((string) ($row->mphone1 ?? '-')) ?></td>
<td><?= esc($invType) ?></td>
<td><?= esc(number_format((float) ($row->net_amount ?? 0), 2, '.', '')) ?></td>
<td>
<?php if ($balance > 0): ?>
<span class="badge bg-danger-subtle text-danger font-monospace fs-6"><?= esc(number_format($balance, 2, '.', '')) ?></span>
<?php else: ?>
<span class="text-muted font-monospace"><?= esc(number_format($balance, 2, '.', '')) ?></span>
<?php endif; ?>
</td>
<td>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="navigateOrLoad('<?= base_url('billing/charges/show') ?>/<?= (int) ($row->inv_id ?? 0) ?>','Charges Invoice');">Open</button>
</td>

View File

@ -31,6 +31,7 @@
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="immunization_record_publish">Immunization Record</button>
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="invoice">Invoice</button>
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="health_document_publish">Health Document</button>
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="wellness_record_publish">Wellness Record</button>
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="ipd_discharge_publish">IPD Discharge</button>
</div>
@ -124,7 +125,7 @@
$opdSessionId = (int) ($meta['opd_session_id'] ?? 0);
$showSandbox = ($type === 'opd_prescription_publish' && $opdId > 0);
$showPreview = ($type === 'opd_prescription_publish' && $opdId > 0)
|| in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish', 'immunization_record_publish', 'health_document_publish'], true);
|| in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish', 'immunization_record_publish', 'health_document_publish', 'wellness_record_publish'], true);
$bridgeSubmitted = (int) ($t['bridge_submitted'] ?? 0) === 1;
$bridgeCareContext = trim((string) ($t['bridge_care_context_reference'] ?? ''));
?>
@ -1121,6 +1122,17 @@
previewUrl = '<?= base_url('DoctorDocument/health_document_fhir_preview') ?>/' + healthDocId;
title = 'HealthDocumentRecord FHIR Preview — Document #' + healthDocId;
} else if (taskType === 'wellness_record_publish') {
var wellnessPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0;
var wellnessOpdId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0;
if (wellnessPatientId <= 0) {
setStatus('Task data missing for Wellness Record FHIR preview.', true);
return true;
}
previewUrl = '<?= base_url('DoctorDocument/wellness_record_fhir_preview') ?>/' + wellnessPatientId + (wellnessOpdId > 0 ? ('/' + wellnessOpdId) : '');
title = 'WellnessRecord FHIR Preview — Patient #' + wellnessPatientId;
} else {
return false;
}
@ -2668,19 +2680,10 @@
}
if (btnRegenerateFhirModal) {
var taskType = _fhirSubmitTaskRow ? (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() : '';
var canRebuildDiagnostic = _fhirSubmitMode === 'task'
&& (taskType === 'lab_report_publish' || taskType === 'radiology_report_publish');
btnRegenerateFhirModal.disabled = false;
btnRegenerateFhirModal.style.display = _fhirSubmitMode === 'task' && !canRebuildDiagnostic ? 'none' : '';
btnRegenerateFhirModal.innerHTML = _fhirSubmitMode === 'invoice' || canRebuildDiagnostic
? '<i class="bi bi-arrow-clockwise"></i> Rebuild Digital Record'
: '<i class="bi bi-arrow-clockwise"></i> Regenerate';
btnRegenerateFhirModal.title = canRebuildDiagnostic
? 'Rebuild FHIR and Digital Share PDF from the latest report data'
: (_fhirSubmitMode === 'invoice'
? 'Rebuild FHIR and Digital Share PDF from current billing data'
: 'Rebuild FHIR bundle from current prescription data');
btnRegenerateFhirModal.style.display = '';
btnRegenerateFhirModal.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Recreate FHIR Record';
btnRegenerateFhirModal.title = 'Re-generate FHIR bundle fresh from current clinical and patient data';
}
fhirPreviewModal.show();
@ -2690,7 +2693,7 @@
.then(function (res) {
var parsed = null;
try { parsed = JSON.parse(res.body || '{}'); } catch (e) { parsed = { raw: res.body || '' }; }
var bundle = (parsed && parsed.resourceType === 'Bundle') ? parsed : ((parsed && parsed.bundle) ? parsed.bundle : null);
var bundle = (parsed && parsed.resourceType === 'Bundle') ? parsed : ((parsed && parsed.bundle) ? parsed.bundle : ((parsed && parsed.fhir_bundle) ? parsed.fhir_bundle : null));
var hasFhir = bundle !== null;
_fhirOpdId = parseInt(parsed.opd_id || '0', 10) || 0;
_fhirSessionId = parseInt(parsed.opd_session_id || '0', 10) || 0;
@ -2714,71 +2717,21 @@
});
}
// Regenerate FHIR bundle in modal
// Regenerate / Recreate FHIR bundle in modal
btnRegenerateFhirModal.addEventListener('click', function () {
var btn = this;
var taskType = _fhirSubmitTaskRow ? (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() : '';
if (_fhirSubmitMode === 'task' && (taskType === 'lab_report_publish' || taskType === 'radiology_report_publish')) {
if (_fhirPreviewUrl === '') {
alert('Diagnostic preview URL is unavailable.');
return;
}
if (!confirm('Rebuild this FHIR record and Digital Share PDF from the latest report data?')) return;
openFhirModal(_fhirPreviewUrl, fhirModalTitle ? fhirModalTitle.textContent : 'Diagnostic FHIR Preview', {
submitMode: 'task',
taskRow: _fhirSubmitTaskRow,
abhaId: _fhirSubmitAbha
});
if (!_fhirPreviewUrl) {
alert('No FHIR preview URL available for this record.');
return;
}
if (_fhirSubmitMode === 'invoice') {
if (!_fhirInvoiceSource || _fhirInvoiceBillId <= 0 || _fhirInvoicePatientId <= 0) {
alert('Invoice source data is incomplete.');
return;
}
if (!confirm('Rebuild this FHIR InvoiceRecord from current billing data?')) return;
var invoiceUrl = '<?= base_url('AbdmGateway/invoice_fhir_preview') ?>'
+ '?source=' + encodeURIComponent(_fhirInvoiceSource)
+ '&bill_id=' + encodeURIComponent(String(_fhirInvoiceBillId))
+ '&patient_id=' + encodeURIComponent(String(_fhirInvoicePatientId));
openFhirModal(invoiceUrl, fhirModalTitle ? fhirModalTitle.textContent : 'FHIR Invoice Preview', {
submitMode: 'invoice',
taskRow: _fhirSubmitTaskRow,
invoiceSource: _fhirInvoiceSource,
invoiceBillId: _fhirInvoiceBillId,
invoicePatientId: _fhirInvoicePatientId
});
return;
}
if (_fhirOpdId <= 0) { alert('No FHIR bundle loaded.'); return; }
if (!confirm('Rebuild FHIR bundle for OPD #' + _fhirOpdId + '?\nThis overwrites the stored bundle with fresh data.')) return;
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Rebuilding…';
var body = new URLSearchParams({ opd_id: _fhirOpdId, opd_session_id: _fhirSessionId });
body.append(csrfName, csrfHash);
fetch('<?= base_url('Opd_prescription/fhir_bundle_regenerate') ?>', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.csrfName && res.csrfHash) { csrfName = res.csrfName; csrfHash = res.csrfHash; }
btn.disabled = false;
btn.innerHTML = origHtml;
if (res.ok === 1 || res.ok === '1') {
// Reload modal with fresh bundle
var reloadUrl = '<?= base_url('Opd_prescription/fhir_bundle_preview') ?>/' + _fhirOpdId + (_fhirSessionId > 0 ? '/' + _fhirSessionId : '');
openFhirModal(reloadUrl, fhirModalTitle ? fhirModalTitle.textContent : 'FHIR Preview');
} else {
alert('\u274c Regeneration failed: ' + (res.message || 'unknown error'));
}
})
.catch(function (e) {
btn.disabled = false;
btn.innerHTML = origHtml;
alert('\u274c ' + e.message);
if (!confirm('Recreate FHIR Record fresh from latest clinical and patient data?')) return;
var freshUrl = _fhirPreviewUrl + (_fhirPreviewUrl.indexOf('?') !== -1 ? '&' : '?') + 'force_rebuild=1&refresh=' + Date.now();
openFhirModal(freshUrl, fhirModalTitle ? fhirModalTitle.textContent : 'FHIR Preview', {
submitMode: _fhirSubmitMode,
taskRow: _fhirSubmitTaskRow,
abhaId: _fhirSubmitAbha,
invoiceSource: _fhirInvoiceSource,
invoiceBillId: _fhirInvoiceBillId,
invoicePatientId: _fhirInvoicePatientId
});
});
@ -2995,6 +2948,66 @@
return;
}
if (taskType === 'wellness_record_publish') {
var wellnessOpdId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0;
var wellnessPatientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0;
if (wellnessPatientId <= 0) {
alert('Task data missing (patient_id).');
return;
}
post('<?= base_url('AbdmGateway/share_wellness_bundle') ?>', {
task_id: parseInt(_fhirSubmitTaskRow.getAttribute('data-task-id') || '0', 10) || 0,
patient_id: wellnessPatientId,
opd_id: wellnessOpdId,
abha_id: _fhirSubmitAbha,
push_to_gateway: 1
}, function (res) {
if (!res || parseInt(res.ok || '0', 10) !== 1) {
alert('ABDM Bridge push failed:\n' + ((res && (res.error_text || res.error || res.message)) || 'Unknown error'));
return;
}
var badge = _fhirSubmitTaskRow.querySelector('.status-pill');
if (badge) {
badge.textContent = 'BRIDGE SUBMITTED';
badge.className = 'badge bg-info status-pill';
}
setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-'));
fhirPreviewModal.hide();
});
return;
}
if (taskType === 'health_document_publish') {
var healthDocId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0;
var healthDocPatientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0;
if (healthDocPatientId <= 0 || healthDocId <= 0) {
alert('Task data missing (record_id/patient_id).');
return;
}
post('<?= base_url('AbdmGateway/share_health_document_bundle') ?>', {
task_id: parseInt(_fhirSubmitTaskRow.getAttribute('data-task-id') || '0', 10) || 0,
patient_id: healthDocPatientId,
record_id: healthDocId,
abha_id: _fhirSubmitAbha,
push_to_gateway: 1
}, function (res) {
if (!res || parseInt(res.ok || '0', 10) !== 1) {
alert('ABDM Bridge push failed:\n' + ((res && (res.error_text || res.error || res.message)) || 'Unknown error'));
return;
}
var badge = _fhirSubmitTaskRow.querySelector('.status-pill');
if (badge) {
badge.textContent = 'BRIDGE SUBMITTED';
badge.className = 'badge bg-info status-pill';
}
setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-'));
fhirPreviewModal.hide();
});
return;
}
runAction(_fhirSubmitTaskRow, 'submit', _fhirSubmitAbha);
fhirPreviewModal.hide();
return;

View File

@ -229,13 +229,19 @@ if ($patientPhotoPath === '') {
</div>
</div>
<div class="mt-2 d-flex flex-wrap gap-2">
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<?php if ((int) ($group['rx_session_id'] ?? 0) > 0) { ?>
<a class="btn btn-primary btn-sm" target="_blank"
href="<?= base_url('Opd_prescription/opd_prescription_print/' . (int) $group['opd_id'] . '/' . (int) $group['rx_session_id']) ?>">
Prescription Print
</a>
<?php } ?>
<button type="button" class="btn btn-outline-info btn-sm" onclick="previewWellnessFhir(<?= (int) ($patient->id ?? 0) ?>, <?= (int) ($group['opd_id'] ?? 0) ?>)">
<i class="bi bi-activity me-1"></i>Wellness Record FHIR Preview
</button>
<button type="button" class="btn btn-success btn-sm text-white" onclick="generateWellnessFhir(<?= (int) ($patient->id ?? 0) ?>, <?= (int) ($group['opd_id'] ?? 0) ?>)">
<i class="bi bi-lightning-charge me-1"></i>Push ABDM Wellness
</button>
</div>
<?php if (!empty($group['bp']) || !empty($group['diastolic']) || !empty($group['pulse']) || !empty($group['temp']) || !empty($group['spo2'])) { ?>
@ -389,14 +395,19 @@ if ($patientPhotoPath === '') {
<?php endif; ?>
<div class="tab-pane fade pt-3" id="opd-documents-tab" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h5 class="card-title mb-0 py-0">Patient Scanned & Uploaded Documents</h5>
<div class="text-muted small">All documents attached to this patient ordered by date (Latest First)</div>
<h5 class="card-title mb-0 py-0"><i class="bi bi-file-earmark-medical text-primary me-1"></i>Patient Scanned & Uploaded Documents</h5>
<div class="text-muted small">Scanned copies, webcam captures, and uploaded reports formatted for ABDM HealthDocumentRecord</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-success text-white shadow-sm fw-semibold" onclick="generateAllFhirDocuments(<?= (int) ($patient->id ?? 0) ?>)" id="btnGenerateAllFhirDocs">
<i class="bi bi-diagram-3-fill me-1"></i>Generate ABDM FHIR Bundle (All)
</button>
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
Total Documents: <?= count($patientDocuments ?? []) ?>
</span>
</div>
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
Total Documents: <?= count($patientDocuments ?? []) ?>
</span>
</div>
<?php if (empty($patientDocuments)) : ?>
@ -406,15 +417,28 @@ if ($patientPhotoPath === '') {
<?php else : ?>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
<?php foreach ($patientDocuments as $doc) : ?>
<div class="col">
<div class="col" id="doc-card-col-<?= (int) $doc['id'] ?>">
<div class="card h-100 border shadow-sm">
<div class="card-header py-2 d-flex justify-content-between align-items-center bg-light">
<span class="fw-bold text-truncate" style="max-width: 180px;" title="<?= esc($doc['title']) ?>"><?= esc($doc['title']) ?></span>
<?php if ($doc['isPdf']) : ?>
<span class="badge bg-danger-subtle text-danger border border-danger-subtle"><i class="bi bi-file-earmark-pdf me-1"></i>PDF</span>
<?php else : ?>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle"><i class="bi bi-file-earmark-image me-1"></i><?= esc(strtoupper($doc['ext'] ?: 'IMAGE')) ?></span>
<?php endif; ?>
<span class="fw-bold text-truncate" style="max-width: 170px;" title="<?= esc($doc['title']) ?>">
<i class="bi bi-folder2-open me-1 text-primary"></i><?= esc($doc['title']) ?>
</span>
<div class="d-flex align-items-center gap-1">
<?php if (! empty($doc['hasSync']) || ! empty($doc['hasTask'])) : ?>
<span class="badge bg-success-subtle text-success border border-success-subtle" title="HealthDocumentRecord FHIR bundle created & enqueued for ABDM sync">
<i class="bi bi-check-circle-fill me-1"></i>FHIR Ready
</span>
<?php else : ?>
<span class="badge bg-info-subtle text-info border border-info-subtle" title="Document uploaded and ready for FHIR bundling">
<i class="bi bi-file-earmark-code me-1"></i>FHIR Available
</span>
<?php endif; ?>
<?php if ($doc['isPdf']) : ?>
<span class="badge bg-danger-subtle text-danger border border-danger-subtle"><i class="bi bi-file-earmark-pdf me-1"></i>PDF</span>
<?php else : ?>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle"><i class="bi bi-file-earmark-image me-1"></i><?= esc(strtoupper($doc['ext'] ?: 'IMAGE')) ?></span>
<?php endif; ?>
</div>
</div>
<div class="card-body p-2 text-center bg-light-subtle d-flex align-items-center justify-content-center" style="height: 180px;">
<?php if ($doc['isPdf']) : ?>
@ -428,9 +452,19 @@ if ($patientPhotoPath === '') {
</div>
<div class="card-footer py-2 d-flex justify-content-between align-items-center bg-white text-muted small">
<span><i class="bi bi-clock me-1"></i><?= esc($doc['insertDate'] ?: '-') ?></span>
<a href="<?= base_url(ltrim($doc['path'], '/')) ?>" target="_blank" class="btn btn-sm btn-outline-primary py-0" style="font-size: 12px;">
<i class="bi bi-box-arrow-up-right me-1"></i>Open
</a>
<div class="btn-group btn-group-sm" role="group">
<a href="<?= base_url(ltrim($doc['path'], '/')) ?>" target="_blank" class="btn btn-outline-secondary py-0" style="font-size: 11px;">
<i class="bi bi-box-arrow-up-right me-1"></i>Open
</a>
<button type="button" class="btn btn-outline-info py-0" style="font-size: 11px;" onclick="previewDocumentFhir(<?= (int) $doc['id'] ?>, '<?= esc($doc['title'], 'js') ?>')">
<i class="bi bi-eye me-1"></i>FHIR Preview
</button>
<?php if (empty($doc['hasSync']) && empty($doc['hasTask'])) : ?>
<button type="button" class="btn btn-outline-success py-0" style="font-size: 11px;" onclick="generateSingleDocumentFhir(<?= (int) $doc['id'] ?>)" id="btn-fhir-single-<?= (int) $doc['id'] ?>">
<i class="bi bi-lightning-charge me-1"></i>Push ABDM
</button>
<?php endif; ?>
</div>
</div>
</div>
</div>
@ -1860,3 +1894,196 @@ $(function() {
});
</script>
<!-- ABDM Scanned Document FHIR Preview Modal -->
<div class="modal fade" id="patientDocFhirModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white py-2">
<h6 class="modal-title mb-0 fw-bold" id="patientDocFhirTitle">
<i class="bi bi-file-earmark-code me-2"></i>HealthDocumentRecord FHIR Bundle Preview
</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div class="bg-light border-bottom px-3 py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success" id="patientDocFhirBadge">HAS FHIR</span>
<span class="badge bg-info text-dark" id="patientDocProfileBadge">HealthDocumentRecord</span>
<small class="text-muted" id="patientDocFhirStatusText">Loaded (200)</small>
</div>
<ul class="nav nav-pills nav-sm" id="patientDocFhirTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-3 fs-7" id="patientDocFormTabBtn" data-bs-toggle="pill" data-bs-target="#patientDocFormTab" type="button" role="tab">Summary View</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-3 fs-7" id="patientDocJsonTabBtn" data-bs-toggle="pill" data-bs-target="#patientDocJsonTab" type="button" role="tab">JSON View</button>
</li>
</ul>
</div>
<div class="tab-content p-3" id="patientDocFhirTabContent">
<div class="tab-pane fade show active" id="patientDocFormTab" role="tabpanel">
<div id="patientDocFormView" class="small">
<div class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm me-2"></div>Loading FHIR HealthDocumentRecord...</div>
</div>
</div>
<div class="tab-pane fade" id="patientDocJsonTab" role="tabpanel">
<div class="d-flex justify-content-end mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyDocFhirJson()">
<i class="bi bi-clipboard me-1"></i>Copy JSON
</button>
</div>
<pre id="patientDocJsonView" class="bg-dark text-success p-3 rounded fs-7 mb-0" style="max-height: 500px; overflow-y: auto;"></pre>
</div>
</div>
</div>
<div class="modal-footer py-2 bg-light">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
function copyDocFhirJson() {
var jsonText = document.getElementById('patientDocJsonView').textContent;
if (jsonText && navigator.clipboard) {
navigator.clipboard.writeText(jsonText).then(function() {
alert('FHIR JSON copied to clipboard!');
});
}
}
function previewDocumentFhir(docId, docTitle) {
var modalEl = document.getElementById('patientDocFhirModal');
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
document.getElementById('patientDocFhirTitle').innerHTML = '<i class="bi bi-file-earmark-code me-2"></i>FHIR Preview - ' + (docTitle || ('Document #' + docId));
document.getElementById('patientDocFormView').innerHTML = '<div class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm text-primary me-2"></div>Building ABDM HealthDocumentRecord FHIR bundle...</div>';
document.getElementById('patientDocJsonView').textContent = 'Loading...';
modal.show();
fetch('<?= base_url('DoctorDocument/health_document_fhir_preview') ?>/' + docId, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
var bundle = data.fhir_bundle || data.bundle || (data.resourceType === 'Bundle' ? data : null);
document.getElementById('patientDocJsonView').textContent = JSON.stringify(data, null, 2);
if (bundle) {
document.getElementById('patientDocFhirBadge').className = 'badge bg-success';
document.getElementById('patientDocFhirBadge').textContent = 'HAS FHIR';
var html = '<div class="card mb-3 border-primary-subtle"><div class="card-header bg-primary text-white py-1 font-monospace fw-bold">' + (data.source?.document_title || docTitle || 'Health Document') + '</div>';
html += '<div class="card-body py-2 small">';
html += '<p class="mb-1"><strong>Patient:</strong> ' + (data.source?.patient?.name || '-') + ' (ABHA: ' + (data.source?.patient?.abha_id || '-') + ')</p>';
html += '<p class="mb-1"><strong>Content Type:</strong> <code>' + (data.source?.content_type || 'application/pdf') + '</code></p>';
html += '<p class="mb-1"><strong>Facility HFR:</strong> ' + (data.source?.hfr_id || '-') + ' (' + (data.source?.organization?.name || '-') + ')</p>';
html += '<p class="mb-0"><strong>Care Context:</strong> DOC-' + docId + ' (' + (data.source?.visit_date || '') + ')</p>';
html += '</div></div>';
document.getElementById('patientDocFormView').innerHTML = html;
} else {
document.getElementById('patientDocFhirBadge').className = 'badge bg-warning';
document.getElementById('patientDocFhirBadge').textContent = 'NOT READY';
document.getElementById('patientDocFormView').innerHTML = '<div class="alert alert-warning">Unable to build FHIR bundle for this document. Check file upload details.</div>';
}
})
.catch(function(err) {
document.getElementById('patientDocFhirBadge').className = 'badge bg-danger';
document.getElementById('patientDocFhirBadge').textContent = 'ERROR';
document.getElementById('patientDocFormView').innerHTML = '<div class="alert alert-danger">Failed to load FHIR preview: ' + err.message + '</div>';
});
}
function generateSingleDocumentFhir(docId) {
var btn = document.getElementById('btn-fhir-single-' + docId);
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Pushing...'; }
$.post('<?= base_url('patient/generate_fhir_for_file') ?>/' + docId, function(res) {
if (res && res.ok === 1) {
alert(res.message || 'FHIR HealthDocumentRecord bundle generated and enqueued for ABDM sync!');
location.reload();
} else {
alert((res && res.error_text) ? res.error_text : 'Unable to generate FHIR for document.');
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-lightning-charge me-1"></i>Push ABDM'; }
}
}, 'json').fail(function() {
alert('Network request failed while pushing to ABDM.');
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-lightning-charge me-1"></i>Push ABDM'; }
});
}
function generateAllFhirDocuments(patientId) {
var btn = document.getElementById('btnGenerateAllFhirDocs');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Bundling FHIR...'; }
$.post('<?= base_url('patient/generate_fhir_bundle_for_all_files') ?>/' + patientId, function(res) {
if (res && res.ok === 1) {
alert(res.message || 'Scanned documents bundled into HealthDocumentRecord FHIR tasks!');
location.reload();
} else {
alert((res && res.error_text) ? res.error_text : 'Failed to bundle documents.');
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-diagram-3-fill me-1"></i>Generate ABDM FHIR Bundle (All)'; }
}
}, 'json').fail(function() {
alert('Network request failed.');
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-diagram-3-fill me-1"></i>Generate ABDM FHIR Bundle (All)'; }
});
}
function previewWellnessFhir(patientId, opdId) {
var modalEl = document.getElementById('patientDocFhirModal');
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
document.getElementById('patientDocFhirTitle').innerHTML = '<i class="bi bi-activity me-2"></i>WellnessRecord FHIR Bundle Preview';
document.getElementById('patientDocFormView').innerHTML = '<div class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm text-primary me-2"></div>Building ABDM WellnessRecord FHIR bundle...</div>';
document.getElementById('patientDocJsonView').textContent = 'Loading...';
modal.show();
var url = '<?= base_url('DoctorDocument/wellness_record_fhir_preview') ?>/' + patientId + (opdId ? ('/' + opdId) : '');
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
var bundle = data.fhir_bundle || data.bundle || (data.resourceType === 'Bundle' ? data : null);
document.getElementById('patientDocJsonView').textContent = JSON.stringify(data, null, 2);
if (bundle) {
document.getElementById('patientDocFhirBadge').className = 'badge bg-success';
document.getElementById('patientDocFhirBadge').textContent = 'HAS FHIR';
document.getElementById('patientDocProfileBadge').textContent = 'WellnessRecord';
var html = '<div class="card mb-3 border-success-subtle"><div class="card-header bg-success text-white py-1 font-monospace fw-bold"><i class="bi bi-activity me-1"></i>Wellness & Vitals Assessment</div>';
html += '<div class="card-body py-2 small">';
html += '<p class="mb-1"><strong>Patient:</strong> ' + (data.source?.patient?.name || '-') + ' (ABHA: ' + (data.source?.patient?.abha_id || '-') + ')</p>';
html += '<p class="mb-1"><strong>Doctor:</strong> ' + (data.source?.doctor_name || '-') + '</p>';
html += '<p class="mb-1"><strong>Vitals Count:</strong> ' + (data.source?.vitals?.length || 0) + ' recorded observations</p>';
if (data.source?.women_wellness && Object.keys(data.source.women_wellness).length > 0) {
html += '<p class="mb-1"><strong>Women Wellness:</strong> ' + JSON.stringify(data.source.women_wellness) + '</p>';
}
html += '<p class="mb-0"><strong>Care Context:</strong> ' + (data.care_context_reference || 'WELLNESS-' + patientId) + '</p>';
html += '</div></div>';
document.getElementById('patientDocFormView').innerHTML = html;
} else {
document.getElementById('patientDocFhirBadge').className = 'badge bg-warning';
document.getElementById('patientDocFhirBadge').textContent = 'NOT READY';
document.getElementById('patientDocFormView').innerHTML = '<div class="alert alert-warning">No vitals or wellness observations found to build WellnessRecord FHIR bundle.</div>';
}
})
.catch(function(err) {
document.getElementById('patientDocFhirBadge').className = 'badge bg-danger';
document.getElementById('patientDocFhirBadge').textContent = 'ERROR';
document.getElementById('patientDocFormView').innerHTML = '<div class="alert alert-danger">Failed to load FHIR preview: ' + err.message + '</div>';
});
}
function generateWellnessFhir(patientId, opdId) {
var url = '<?= base_url('patient/generate_wellness_fhir') ?>/' + patientId + (opdId ? ('/' + opdId) : '');
$.post(url, function(res) {
if (res && res.ok === 1) {
alert(res.message || 'WellnessRecord FHIR bundle enqueued for ABDM sync!');
location.reload();
} else {
alert((res && res.error_text) ? res.error_text : 'Unable to generate WellnessRecord FHIR.');
}
}, 'json').fail(function() {
alert('Network request failed.');
});
}
</script>

View File

@ -1,100 +1,355 @@
<div class="pagetitle">
<h1>Doctor Documents Workspace</h1>
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="<?= base_url('dashboard') ?>">Home</a></li>
<li class="breadcrumb-item active">Doctor Documents</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h1><i class="bi bi-folder-check text-primary me-2"></i>MRD & IPD Medical Record Documents Workspace</h1>
<nav>
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="<?= base_url('dashboard') ?>">Home</a></li>
<li class="breadcrumb-item active">MRD & IPD Document Scanner</li>
</ol>
</nav>
</div>
<div>
<span class="badge bg-success px-3 py-2 fs-6"><i class="bi bi-shield-check me-1"></i>NABH Guidelines Compliant</span>
<span class="badge bg-primary px-3 py-2 fs-6 ms-1"><i class="bi bi-cloud-arrow-up me-1"></i>ABDM FHIR R4 Ready</span>
</div>
</div>
</div>
<section class="section">
<div class="row g-3">
<div class="col-lg-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Template Master</h5>
<p class="text-muted mb-3">Create and edit certificate templates with custom fields.</p>
<button type="button" class="btn btn-primary" onclick="load_form('<?= base_url('Doc_Admin/doc_list') ?>')">
Open Template List
<section class="section mt-3">
<!-- MAIN NABH IPD MEDICAL RECORD SCANNER & ABDM HEALTH DOCUMENT PORTAL -->
<div class="card shadow border-0 mb-4">
<div class="card-header bg-gradient-primary text-white d-flex justify-content-between align-items-center py-3" style="background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);">
<div>
<h5 class="m-0 text-white fw-bold"><i class="bi bi-scanner me-2"></i>NABH IPD Medical Record Scanner & ABDM Health Document Upload</h5>
<small class="text-white-50">Type IPD No. to view required paper scan checklist as per NABH guidelines</small>
</div>
<span class="badge bg-warning text-dark"><i class="bi bi-star-fill me-1"></i>NABH IPD Checklist</span>
</div>
<div class="card-body p-4">
<!-- IPD Search Box -->
<div class="row g-3 align-items-end mb-4">
<div class="col-md-7 col-lg-8">
<label class="form-label fw-bold" for="nabh_ipd_search_input">
<i class="bi bi-search text-primary me-1"></i>Enter IPD No. / Admission No. / UHID / Patient Name
</label>
<div class="input-group">
<span class="input-group-text bg-light"><i class="bi bi-hospital"></i></span>
<input type="text" class="form-control form-control-lg" id="nabh_ipd_search_input" placeholder="e.g. IPD-2026-001, 17, 5, DEVENDER..." onkeypress="if(event.key==='Enter'){ searchIpdPatientForNabh(event); return false; }">
<button type="button" class="btn btn-lg btn-primary px-4" id="btn_search_ipd_nabh" onclick="searchIpdPatientForNabh(event)">
<i class="bi bi-search me-1"></i> Search IPD Admission
</button>
</div>
</div>
<div class="col-md-5 col-lg-4 text-end">
<small class="text-muted d-block">Need help locating IPD admission?</small>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="load_form('<?= base_url('DoctorDocument/workspace') ?>')">
<i class="bi bi-arrow-clockwise me-1"></i> Refresh Workspace
</button>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Patient Document Issue</h5>
<div class="mb-3">
<label class="form-label" for="doc_workspace_patient_id">Patient ID / UHID</label>
<input type="text" class="form-control" id="doc_workspace_patient_id" placeholder="Enter UHID / Patient ID / last digits">
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-success" onclick="openPatientDoc()">Open Patient Documents</button>
<button type="button" class="btn btn-outline-success" onclick="openPatientImmunization()">Open Immunization</button>
<button type="button" class="btn btn-outline-secondary" onclick="load_form('<?= base_url('doctor_work/patient_search') ?>','Doctor Patient Search')">Search Patient with History</button>
<button type="button" class="btn btn-outline-primary" onclick="load_form('<?= base_url('Report/document_list') ?>', 'Document Issue Report')">Document Issue Report</button>
<!-- Patient Info Banner (Hidden until searched) -->
<div id="nabh_ipd_patient_card" class="card bg-light border border-info mb-4" style="display: none;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h5 class="fw-bold mb-1 text-primary" id="nabh_ipd_patient_name">DEVENDER SINGH</h5>
<div class="d-flex flex-wrap gap-3 text-dark small">
<span><strong>IPD No:</strong> <span class="badge bg-primary fs-6" id="nabh_ipd_no">IPD-17</span></span>
<span><strong>UHID:</strong> <span class="badge bg-secondary fs-6" id="nabh_ipd_uhid">11</span></span>
<span><strong>Gender/Age:</strong> <span id="nabh_ipd_gender_age">Male / 47</span></span>
<span><strong>Admission Date:</strong> <span id="nabh_ipd_admission_date">30-07-2026</span></span>
<span><strong>Attending Doctor:</strong> <span id="nabh_ipd_doctor">Dr. Nidhi Pandey</span></span>
</div>
</div>
<div>
<span class="badge bg-info text-dark p-2 me-1" id="nabh_ipd_abha_badge"><i class="bi bi-qr-code me-1"></i>ABHA Address</span>
<small class="d-block text-muted text-end fw-bold" id="nabh_ipd_abha_address">singhdevender0328@sbx</small>
</div>
</div>
</div>
</div>
<!-- NABH Guideline Required Documents Grid (Hidden until patient selected) -->
<div id="nabh_ipd_checklist_section" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold text-dark m-0"><i class="bi bi-check2-square text-success me-2"></i>NABH Guideline Mandatory IPD Paper Documents Checklist</h6>
<small class="text-muted">Upload scanned paper records (JPG, PNG, PDF) & push as FHIR HealthDocumentRecord to ABDM</small>
</div>
<div class="row g-3" id="nabh_doc_categories_container">
<!-- Populated dynamically via JavaScript -->
</div>
</div>
<!-- Initial Placeholder -->
<div id="nabh_ipd_placeholder" class="text-center py-5 border rounded bg-light">
<i class="bi bi-folder-symlink display-4 text-muted"></i>
<h5 class="mt-3 text-muted">Search an IPD Admission to View NABH Document Upload List</h5>
<p class="text-muted small">Enter an IPD No. (e.g. 17 or 5) above to load the NABH mandatory scanned document checklist.</p>
</div>
</div>
</div>
</section>
<script>
function openPatientDoc() {
var patientKey = (document.getElementById('doc_workspace_patient_id').value || '').trim();
if (!patientKey) {
alert('Enter patient id');
return;
var _activeIpdRecord = null;
var _nabhCategories = [
{ code: 'NABH-IPD-01', title: 'Admission Request & Initial Assessment Form', icon: 'bi-file-earmark-text', desc: 'Patient admission order, initial clinical assessment, emergency notes & vital baseline.' },
{ code: 'NABH-IPD-02', title: 'Signed Informed Consent Forms', icon: 'bi-pen-fill', desc: 'Signed consent forms for general admission, surgery, anesthesia, high risk procedures & blood transfusion.' },
{ code: 'NABH-IPD-03', title: 'Doctor Clinical Notes & Daily Orders', icon: 'bi-journal-medical', desc: 'Daily clinical progress notes, doctor rounds, medication orders & treatment plans.' },
{ code: 'NABH-IPD-04', title: 'Nursing Flowsheet & Vitals Chart', icon: 'bi-activity', desc: '24-hour nursing assessment, vital signs monitoring chart, fluid intake/output & drug administration record.' },
{ code: 'NABH-IPD-05', title: 'OT Surgery Notes & Anesthesia Record', icon: 'bi-scissors', desc: 'Pre-operative evaluation, intra-operative surgical notes, anesthesia record & post-op recovery note.' },
{ code: 'NABH-IPD-06', title: 'Laboratory & Radiology Diagnostic Reports', icon: 'bi-file-earmark-pdf', desc: 'Scanned paper lab investigation reports, ECG charts, X-ray & CT/MRI imaging reports.' },
{ code: 'NABH-IPD-07', title: 'Discharge Summary & Advice Note', icon: 'bi-file-earmark-check', desc: 'NABH discharge summary, discharge instructions, prescription & follow-up advice.' },
{ code: 'NABH-IPD-08', title: 'Billing Summary & TPA/Insurance Claim Form', icon: 'bi-credit-card-2-front', desc: 'IPD final bill summary, government scheme approval, insurance claim forms & payment receipts.' },
{ code: 'NABH-IPD-09', title: 'Other Scanned Paper Medical Records', icon: 'bi-folder-plus', desc: 'Any other paper medical records, external referral slips, or patient consent attachments.' }
];
function searchIpdPatientForNabh(ev) {
if (ev && ev.preventDefault) ev.preventDefault();
if (ev && ev.stopPropagation) ev.stopPropagation();
var query = (document.getElementById('nabh_ipd_search_input').value || '').trim();
if (!query) {
alert('Please enter an IPD No., UHID, or Patient Name.');
return false;
}
var resolveUrl = '<?= base_url('Document_Patient/open_by_key') ?>?patient_key=' + encodeURIComponent(patientKey);
fetch(resolveUrl, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function(response) {
return response.json();
})
var btn = document.getElementById('btn_search_ipd_nabh');
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Searching...';
var searchUrl = '<?= base_url('DoctorDocument/search_ipd_patient') ?>?ipd_key=' + encodeURIComponent(query);
fetch(searchUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && Number(data.status) === 1 && Number(data.patient_id) > 0) {
load_form('<?= base_url('Document_Patient/p_doc_record') ?>/' + Number(data.patient_id));
return;
btn.disabled = false;
btn.innerHTML = origHtml;
if (data.status === 'success' && Array.isArray(data.records) && data.records.length > 0) {
_activeIpdRecord = data.records[0];
renderActiveIpdRecord(_activeIpdRecord);
} else {
alert((data && data.message) ? data.message : 'No IPD admission found matching "' + query + '"');
}
alert((data && data.message) ? data.message : 'Patient not found');
})
.catch(function() {
alert('Unable to resolve patient. Try again.');
.catch(function(e) {
btn.disabled = false;
btn.innerHTML = origHtml;
alert('Unable to search IPD patient: ' + e.message);
});
return false;
}
function openPatientImmunization() {
var patientKey = (document.getElementById('doc_workspace_patient_id').value || '').trim();
if (!patientKey) {
alert('Enter patient id');
return;
function renderActiveIpdRecord(rec) {
document.getElementById('nabh_ipd_placeholder').style.display = 'none';
document.getElementById('nabh_ipd_patient_card').style.display = 'block';
document.getElementById('nabh_ipd_checklist_section').style.display = 'block';
document.getElementById('nabh_ipd_patient_name').textContent = rec.patient_name || 'Patient';
document.getElementById('nabh_ipd_no').textContent = rec.ipd_no || ('IPD-' + rec.ipd_id);
document.getElementById('nabh_ipd_uhid').textContent = rec.uhid || rec.patient_id;
document.getElementById('nabh_ipd_gender_age').textContent = (rec.gender || 'N/A') + ' / ' + (rec.age || 'N/A');
document.getElementById('nabh_ipd_admission_date').textContent = rec.admission_date || 'N/A';
document.getElementById('nabh_ipd_doctor').textContent = rec.doctor_name || 'Attending Doctor';
document.getElementById('nabh_ipd_abha_address').textContent = rec.abha_address || rec.abha_id || 'Not linked';
var container = document.getElementById('nabh_doc_categories_container');
container.innerHTML = '';
_nabhCategories.forEach(function(cat) {
var existing = (rec.scans || {})[cat.code] || null;
var col = document.createElement('div');
col.className = 'col-md-6 col-lg-4';
var statusBadge = '<span class="badge bg-secondary mb-2" id="badge_status_' + cat.code + '"><i class="bi bi-clock me-1"></i>Pending Scan</span>';
if (existing) {
if (existing.abdm_status === 'pushed') {
statusBadge = '<span class="badge bg-success mb-2" id="badge_status_' + cat.code + '"><i class="bi bi-check-circle-fill me-1"></i>Pushed to ABDM (201)</span>';
} else {
statusBadge = '<span class="badge bg-info text-dark mb-2" id="badge_status_' + cat.code + '"><i class="bi bi-file-earmark-check me-1"></i>Scanned & Saved</span>';
}
}
var fileInfo = existing ? '<div class="small text-truncate text-muted mt-1" id="file_info_' + cat.code + '"><i class="bi bi-paperclip me-1"></i><a href="' + existing.file_url + '" target="_blank" class="fw-bold text-decoration-none">' + hesc(existing.file_name) + '</a></div>' : '<div class="small text-muted mt-1" id="file_info_' + cat.code + '">No scan uploaded yet.</div>';
var pushBtnDisabled = existing ? '' : 'disabled';
var pushBtnClass = (existing && existing.abdm_status === 'pushed') ? 'btn-outline-success' : 'btn-primary';
var pushBtnText = (existing && existing.abdm_status === 'pushed') ? '<i class="bi bi-check2-circle"></i> Pushed to ABDM' : '<i class="bi bi-cloud-arrow-up"></i> Push to ABDM';
col.innerHTML = `
<div class="card h-100 shadow-sm border border-light-subtle">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<div class="d-flex justify-content-between align-items-start gap-1">
<h6 class="fw-bold text-dark mb-1">
<i class="bi ${cat.icon} text-primary me-2"></i>${hesc(cat.title)}
</h6>
</div>
${statusBadge}
<p class="text-muted small mb-2" style="font-size:0.8rem; line-height:1.2;">${hesc(cat.desc)}</p>
${fileInfo}
</div>
<div class="mt-3 pt-2 border-top">
<div class="mb-2">
<input type="file" class="form-control form-control-sm" id="file_input_${cat.code}" accept=".jpg,.jpeg,.png,.webp,.pdf">
</div>
<div class="d-flex gap-1">
<button type="button" class="btn btn-sm btn-outline-primary flex-grow-1" onclick="uploadNabhScan(event, '${cat.code}', '${hesc(cat.title)}')">
<i class="bi bi-upload"></i> Upload
</button>
<button type="button" class="btn btn-sm ${pushBtnClass} flex-grow-1" id="btn_push_${cat.code}" ${pushBtnDisabled} onclick="pushNabhToAbdm(event, '${cat.code}', ${existing ? existing.patient_doc_id : 0})">
${pushBtnText}
</button>
</div>
</div>
</div>
</div>
`;
container.appendChild(col);
});
}
function uploadNabhScan(ev, catCode, catTitle) {
if (ev && ev.preventDefault) ev.preventDefault();
if (ev && ev.stopPropagation) ev.stopPropagation();
if (!_activeIpdRecord) {
alert('Please search and select an IPD admission first.');
return false;
}
var resolveUrl = '<?= base_url('Document_Patient/open_by_key') ?>?patient_key=' + encodeURIComponent(patientKey);
fetch(resolveUrl, {
headers: {
'X-Requested-With': 'XMLHttpRequest'
var fileInput = document.getElementById('file_input_' + catCode);
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
alert('Please choose a scanned paper file (JPG, PNG, PDF) to upload for ' + catTitle);
return false;
}
var btn = ev ? (ev.currentTarget || ev.target) : null;
var origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
}
var formData = new FormData();
formData.append('ipd_id', _activeIpdRecord.ipd_id);
formData.append('patient_id', _activeIpdRecord.patient_id);
formData.append('doctor_id', _activeIpdRecord.doctor_id || 1);
formData.append('nabh_category', catCode);
formData.append('category_title', catTitle);
formData.append('scanned_file', fileInput.files[0]);
var uploadUrl = '<?= base_url('DoctorDocument/upload_ipd_nabh_document') ?>';
fetch(uploadUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
}
if (res.status === 'success') {
alert('✅ ' + res.message);
var badge = document.getElementById('badge_status_' + catCode);
if (badge) {
badge.className = 'badge bg-info text-dark mb-2';
badge.innerHTML = '<i class="bi bi-file-earmark-check me-1"></i>Scanned & Saved';
}
var info = document.getElementById('file_info_' + catCode);
if (info) {
info.innerHTML = '<i class="bi bi-paperclip me-1"></i><a href="' + res.file_url + '" target="_blank" class="fw-bold text-decoration-none">' + hesc(res.file_name) + '</a>';
}
var pushBtn = document.getElementById('btn_push_' + catCode);
if (pushBtn) {
pushBtn.disabled = false;
pushBtn.setAttribute('onclick', "pushNabhToAbdm(event, '" + catCode + "', " + res.patient_doc_id + ")");
}
} else {
alert('❌ Upload failed: ' + (res.message || 'Unknown error'));
}
})
.then(function(response) {
return response.json();
})
.then(function(data) {
if (data && Number(data.status) === 1 && Number(data.patient_id) > 0) {
load_form('<?= base_url('Immunization') ?>?patient_id=' + Number(data.patient_id), 'Immunization Record');
return;
.catch(function(e) {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
}
alert('❌ Error uploading scan: ' + e.message);
});
return false;
}
function pushNabhToAbdm(ev, catCode, patientDocId) {
if (ev && ev.preventDefault) ev.preventDefault();
if (ev && ev.stopPropagation) ev.stopPropagation();
if (patientDocId <= 0) {
alert('Upload a scanned file first before pushing to ABDM.');
return false;
}
var btn = document.getElementById('btn_push_' + catCode);
var origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Pushing...';
}
var formData = new URLSearchParams();
formData.append('patient_doc_id', patientDocId);
formData.append('ipd_id', _activeIpdRecord ? _activeIpdRecord.ipd_id : 0);
formData.append('nabh_category', catCode);
fetch('<?= base_url('DoctorDocument/push_ipd_nabh_to_abdm') ?>', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
}
if (res.ok === 1 || res.status === 'queued') {
alert('🚀 Successfully pushed HealthDocumentRecord to ABDM Gateway Bridge!\nCare Context: ' + (res.care_context_reference || 'DOC-' + patientDocId));
var badge = document.getElementById('badge_status_' + catCode);
if (badge) {
badge.className = 'badge bg-success mb-2';
badge.innerHTML = '<i class="bi bi-check-circle-fill me-1"></i>Pushed to ABDM (201)';
}
alert((data && data.message) ? data.message : 'Patient not found');
})
.catch(function() {
alert('Unable to resolve patient. Try again.');
});
if (btn) {
btn.className = 'btn btn-sm btn-outline-success flex-grow-1';
btn.innerHTML = '<i class="bi bi-check2-circle"></i> Pushed to ABDM';
}
} else {
alert('❌ ABDM Push Failed: ' + (res.error || res.error_text || 'Bridge rejected request'));
}
})
.catch(function(e) {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
}
alert('❌ Error pushing to ABDM: ' + e.message);
});
return false;
}
function hesc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>

View File

@ -487,6 +487,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
<span>Collection Report</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('Report/old_payment_received_report') ?>','Old Payment Received Report')">
<i class="bi bi-clock-history"></i>
<span>Old Payment Received</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('Report/report_opd_total') ?>','OPD Total Report')">
<i class="bi bi-bar-chart"></i>

View File

@ -24,6 +24,7 @@ $can = static fn (string $panel): bool => (bool) ($panels[$panel] ?? false);
</div>
<ul class="report-links">
<li><a href="javascript:load_form('<?= base_url('Report/collection_report') ?>','Collection Report');"><i class="bi bi-arrow-right-short"></i> Total Payment (Cash and Bank)</a></li>
<li><a href="javascript:load_form('<?= base_url('Report/old_payment_received_report') ?>','Old Payment Received Report');"><i class="bi bi-arrow-right-short"></i> Old Payment Received Report (Late Collections)</a></li>
<li><a href="javascript:load_form('<?= base_url('Report/report_opd_total') ?>','OPD Total Report');"><i class="bi bi-arrow-right-short"></i> OPD Total Report</a></li>
<li><a href="javascript:load_form('<?= base_url('Report/report_opd_patient_list') ?>','OPD Patient List');"><i class="bi bi-arrow-right-short"></i> OPD Patient List</a></li>
</ul>

View File

@ -0,0 +1,89 @@
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<h3 class="card-title mb-0"><i class="bi bi-clock-history text-primary me-2"></i>Old Payment Received Report</h3>
<span class="badge bg-info text-dark"><i class="bi bi-calendar-range me-1"></i>Invoice Date &lt; Payment Date</span>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
This report lists payments received during the selected date range where the original invoice was generated on an <strong>earlier date</strong> (Invoice Date &lt; Payment Date).
</p>
<form class="row g-2 align-items-end mb-3" onsubmit="event.preventDefault(); loadOldPaymentData(0);">
<div class="col-md-5">
<label class="form-label fw-bold">Payment Collection Date Range</label>
<div class="input-group">
<input type="text" class="form-control" id="old_pay_min" placeholder="Start Date" value="<?= date('Y-m-d 00:00:00') ?>">
<span class="input-group-text">to</span>
<input type="text" class="form-control" id="old_pay_max" placeholder="End Date" value="<?= date('Y-m-d 23:59:00') ?>">
</div>
</div>
<div class="col-md-7 d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-primary" onclick="loadOldPaymentData(0)">
<i class="bi bi-search me-1"></i> Show Details
</button>
<button type="button" class="btn btn-success" onclick="loadOldPaymentData(1)">
<i class="bi bi-file-earmark-excel me-1"></i> Export Excel
</button>
<button type="button" class="btn btn-danger" onclick="loadOldPaymentData(2)">
<i class="bi bi-file-earmark-pdf me-1"></i> Export PDF
</button>
<button type="button" class="btn btn-light" onclick="load_form('<?= base_url('Report/old_payment_received_report') ?>','Old Payment Received Report')">
<i class="bi bi-arrow-clockwise me-1"></i> Reset
</button>
</div>
</form>
<div id="old_payment_results_container" class="mt-3">
<div class="text-center py-5 border rounded bg-light">
<i class="bi bi-search display-6 text-muted"></i>
<p class="text-muted mt-2">Click <strong>Show Details</strong> to load old payment collections for the selected date range.</p>
</div>
</div>
</div>
</div>
<script>
function buildDateRangeStr() {
var minVal = (document.getElementById('old_pay_min').value || '').trim();
var maxVal = (document.getElementById('old_pay_max').value || '').trim();
if (!minVal) minVal = '<?= date('Y-m-d 00:00:00') ?>';
if (!maxVal) maxVal = '<?= date('Y-m-d 23:59:00') ?>';
return encodeURIComponent(minVal) + 'S' + encodeURIComponent(maxVal);
}
function loadOldPaymentData(outputType) {
var dateRange = buildDateRangeStr();
var url = '<?= base_url('Report/old_payment_received_report_data') ?>/' + dateRange + '/' + (outputType || 0);
if (outputType === 1 || outputType === 2) {
window.open(url, '_blank');
return;
}
var container = document.getElementById('old_payment_results_container');
container.innerHTML = '<div class="text-center py-5"><span class="spinner-border text-primary"></span><p class="mt-2 text-muted">Loading Old Payment Received Data...</p></div>';
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.text(); })
.then(function(html) {
container.innerHTML = html;
if (window.jQuery && $.fn && $.fn.DataTable) {
var $t = $('#oldPaymentTable');
if ($t.length > 0 && $t.find('tbody tr').length > 0) {
if ($.fn.DataTable.isDataTable($t)) {
$t.DataTable().destroy();
}
$t.DataTable({ pageLength: 25, order: [[0, 'desc']] });
}
}
})
.catch(function(e) {
container.innerHTML = '<div class="alert alert-danger">Unable to load report: ' + e.message + '</div>';
});
}
(function() {
loadOldPaymentData(0);
})();
</script>

View File

@ -0,0 +1,64 @@
<div class="alert alert-info d-flex justify-content-between align-items-center mb-3">
<div>
<i class="bi bi-info-circle-fill me-2"></i>
Payment Date Range: <strong><?= esc($min_range ?? '') ?></strong> to <strong><?= esc($max_range ?? '') ?></strong>
</div>
<div>
Total Records: <span class="badge bg-primary fs-6"><?= count($rows ?? []) ?></span> |
Total Amount Collected: <span class="badge bg-success fs-6">₹<?= number_format((float) ($total_amount ?? 0), 2, '.', ',') ?></span>
</div>
</div>
<?php if (empty($rows)): ?>
<div class="text-center py-4 border rounded bg-light text-muted">
<i class="bi bi-check-circle-fill text-success fs-4 d-block mb-2"></i>
<h5>No Old Payment Receipts Found</h5>
<p class="mb-0 small">No transactions found where Invoice Date &lt; Payment Date in the selected date range.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-hover align-middle border" id="oldPaymentTable">
<thead class="table-light">
<tr>
<th>#</th>
<th>Payment Date</th>
<th>Invoice Date</th>
<th>Delay (Days)</th>
<th>Invoice Code</th>
<th>Patient Name</th>
<th>Patient Code</th>
<th class="text-end">Invoice Net Amt</th>
<th class="text-end">Amount Received</th>
<th>Pay Mode</th>
<th>Collected By</th>
</tr>
</thead>
<tbody>
<?php $i = 1; foreach ($rows as $row): ?>
<?php
$payDate = ! empty($row['payment_date']) ? date('d-m-Y H:i', strtotime((string) $row['payment_date'])) : '-';
$invDate = ! empty($row['inv_date']) ? date('d-m-Y', strtotime((string) $row['inv_date'])) : '-';
$delay = (int) ($row['delay_days'] ?? 0);
?>
<tr>
<td><?= $i++ ?></td>
<td><span class="badge bg-secondary"><?= esc($payDate) ?></span></td>
<td><span class="badge bg-warning text-dark"><?= esc($invDate) ?></span></td>
<td>
<span class="badge bg-danger-subtle text-danger font-monospace">
<i class="bi bi-clock-history me-1"></i><?= $delay ?> day<?= $delay > 1 ? 's' : '' ?> ago
</span>
</td>
<td class="fw-bold"><?= esc((string) ($row['invoice_code'] ?? '-')) ?></td>
<td><?= esc((string) ($row['patient_name'] ?? '-')) ?></td>
<td><?= esc((string) ($row['patient_code'] ?? '-')) ?></td>
<td class="text-end fw-bold"><?= number_format((float) ($row['net_amount'] ?? 0), 2, '.', ',') ?></td>
<td class="text-end fw-bold text-success">₹<?= number_format((float) ($row['paid_amount'] ?? 0), 2, '.', ',') ?></td>
<td><span class="badge bg-info text-dark"><?= esc((string) ($row['pay_mode'] ?? 'Cash')) ?></span></td>
<td><?= esc((string) ($row['update_by'] ?? 'Staff')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>

View File

@ -492,7 +492,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase
]);
$this->assertSame('HealthDocumentRecord', $output['hi_type']);
$this->assertSame('DOC-4-2026-08-26', $output['care_context_reference']);
$this->assertStringStartsWith('DOC-4-2026-08-26', $output['care_context_reference']);
$this->assertTrue($output['validation']['valid']);
$this->assertSame(100, $output['validation']['score']);
@ -502,4 +502,86 @@ final class FhirGeneratorsTest extends CIUnitTestCase
$this->assertContains('Patient', $resourceTypes);
$this->assertContains('DocumentReference', $resourceTypes);
}
public function testHealthDocumentGeneratorSupportsCustomContentType(): void
{
$generator = $this->factory->healthDocument();
$output = $generator->generate([
'record_id' => '5',
'visit_date' => '2026-08-31',
'document_title' => 'Scanned X-Ray',
'document_data_base64' => base64_encode('fake image data'),
'content_type' => 'image/png',
'patient' => [
'id' => '11',
'name' => 'DEVENDER SINGH',
'abha_id' => '91510165305101',
],
]);
$this->assertSame('HealthDocumentRecord', $output['hi_type']);
$bundle = $output['fhir_bundle'];
$this->assertSame('Bundle', $bundle['resourceType']);
$docRef = null;
foreach ($bundle['entry'] as $entry) {
if (($entry['resource']['resourceType'] ?? '') === 'DocumentReference') {
$docRef = $entry['resource'];
break;
}
}
$this->assertNotNull($docRef);
$this->assertSame('application/pdf', $docRef['content'][0]['attachment']['contentType']);
}
public function testWellnessRecordGeneratorCreatesFullSections(): void
{
$generator = $this->factory->wellness();
$output = $generator->generate([
'record_id' => '101',
'session_id' => '2',
'visit_date' => '2026-09-01',
'completed_at' => date(DATE_ATOM),
'vitals' => [
['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' => '59408-5', 'display' => 'Oxygen saturation', 'value' => 98, 'unit' => '%', 'ucum_code' => '%'],
],
'physical_examination' => [
'Complaints: Mild headache for 2 days',
'Diagnosis: Essential hypertension (mild)',
],
'women_wellness' => [
'lmp' => '2026-08-15',
'gravida' => '2',
'para' => '1',
],
'advice' => [
'Smoking status: No',
'Diet & Lifestyle Advice: Low salt diet, 30 min daily walk',
],
'patient' => [
'id' => '11',
'name' => 'SUNITA DEVI',
'abha_id' => '91510165305101',
'gender' => 'female',
],
]);
$this->assertSame('WellnessRecord', $output['hi_type']);
$bundle = $output['fhir_bundle'];
$this->assertSame('Bundle', $bundle['resourceType']);
$resourceTypes = array_column(array_column($bundle['entry'], 'resource'), 'resourceType');
$this->assertContains('Composition', $resourceTypes);
$this->assertContains('Patient', $resourceTypes);
$this->assertContains('Observation', $resourceTypes);
$composition = $bundle['entry'][0]['resource'];
$this->assertSame('https://nrces.in/ndhm/fhir/r4/StructureDefinition/WellnessRecord', $composition['meta']['profile'][0]);
$this->assertGreaterThanOrEqual(1, count($composition['section']));
$this->assertNotEmpty($composition['section'][0]['entry']);
}
}