diff --git a/app/Config/Routes.php b/app/Config/Routes.php index e0e9884..a4ea818 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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'); /* * -------------------------------------------------------------------- diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index d244899..a657a68 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -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('

' . esc($title) . '

Patient Health Document #' . ($targetRecordId > 0 ? $targetRecordId : $patientId) . '

'); + $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 = '' + . '

' . esc($title) . '

' + . '' + . ''; + $mpdf->WriteHTML($html); + @unlink($tempImg); + } else { + $htmlStr = (is_string($rawBytes) && str_contains($rawBytes, '<')) ? $rawBytes : ('

' . esc($title) . '

' . nl2br(esc((string) $rawBytes)) . '

'); + $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 ''; } diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index d3505aa..72cddc7 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -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', + ] + ); + } } } diff --git a/app/Controllers/DoctorDocument.php b/app/Controllers/DoctorDocument.php index d35bd3a..9b24c2a 100644 --- a/app/Controllers/DoctorDocument.php +++ b/app/Controllers/DoctorDocument.php @@ -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('

' . htmlspecialchars($categoryTitle) . '

'); + $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('
' . $rawHtml . '
'); + $contentType = 'text/html'; + } else { + $docDataBase64 = base64_encode('

' . esc($docTitle) . '

Patient Document #' . $patientDocId . '

'); + $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 = '
' + . $headerRef . 'Date : ' . $issueDate . '
'; + + $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 = '

' . esc($docTitle) . '

Uploaded Document ID: ' . $fileUploadId . '

'; + $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 ''; + } } diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 6323527..2baaa89 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -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(), + ]); + } } diff --git a/app/Controllers/Report.php b/app/Controllers/Report.php index 4b2a749..7ee85af 100644 --- a/app/Controllers/Report.php +++ b/app/Controllers/Report.php @@ -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 = '' + . '

Old Payment Received Report

' + . $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() { diff --git a/app/Helpers/common_helper.php b/app/Helpers/common_helper.php index 91956ca..af1df9e 100644 --- a/app/Helpers/common_helper.php +++ b/app/Helpers/common_helper.php @@ -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) { diff --git a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php index fa76e81..8353a91 100644 --- a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php @@ -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 $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 $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, + ]], ]; } diff --git a/app/Libraries/Abdm/Fhir/Generators/HealthDocumentFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/HealthDocumentFhirGenerator.php index e3bc5e5..d704fba 100644 --- a/app/Libraries/Abdm/Fhir/Generators/HealthDocumentFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/HealthDocumentFhirGenerator.php @@ -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 = '

' . htmlspecialchars($docTitle, ENT_QUOTES, 'UTF-8') . '

Health Document Summary

'; + } + + 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 = '' + . '

' . htmlspecialchars($docTitle, ENT_QUOTES, 'UTF-8') . '

' + . '' + . ''; + $mpdf->WriteHTML($html); + @unlink($tempImg); + } else { + $html = str_contains($rawBytes, '<') ? $rawBytes : ('

' . htmlspecialchars($docTitle, ENT_QUOTES, 'UTF-8') . '

' . nl2br(htmlspecialchars($rawBytes, ENT_QUOTES, 'UTF-8')) . '

'); + $mpdf->WriteHTML($html); + } + + $pdfBytes = $mpdf->Output('', 'S'); + return is_string($pdfBytes) && str_starts_with($pdfBytes, '%PDF') ? $pdfBytes : ''; + } catch (\Throwable $e) { + return ''; + } + } } diff --git a/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php index 268e04b..e2bdd01 100644 --- a/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/WellnessFhirGenerator.php @@ -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 ]; } } + diff --git a/app/Libraries/BridgeSyncService.php b/app/Libraries/BridgeSyncService.php index 04f86cf..1861de5 100644 --- a/app/Libraries/BridgeSyncService.php +++ b/app/Libraries/BridgeSyncService.php @@ -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', diff --git a/app/Views/Invoice/charges_invoice_list.php b/app/Views/Invoice/charges_invoice_list.php index 83439b8..3fac786 100644 --- a/app/Views/Invoice/charges_invoice_list.php +++ b/app/Views/Invoice/charges_invoice_list.php @@ -24,6 +24,7 @@ Mobile Type Amount + Balance Action @@ -32,6 +33,7 @@ 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); ?> invoice_code ?? '-')) ?> @@ -41,6 +43,13 @@ mphone1 ?? '-')) ?> net_amount ?? 0), 2, '.', '')) ?> + + 0): ?> + + + + + diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index 90fe9c2..6a7c421 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -31,6 +31,7 @@ + @@ -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 = '/' + 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 = '/' + 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 - ? ' Rebuild Digital Record' - : ' 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 = ' 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 = '' - + '?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 = 'Rebuilding…'; - var body = new URLSearchParams({ opd_id: _fhirOpdId, opd_session_id: _fhirSessionId }); - body.append(csrfName, csrfHash); - fetch('', { - 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 = '/' + _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('', { + 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('', { + 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; diff --git a/app/Views/billing/Patient_Profile_Opd_V.php b/app/Views/billing/Patient_Profile_Opd_V.php index 3fd49e2..5c803dc 100644 --- a/app/Views/billing/Patient_Profile_Opd_V.php +++ b/app/Views/billing/Patient_Profile_Opd_V.php @@ -229,13 +229,19 @@ if ($patientPhotoPath === '') { -
+
0) { ?> Prescription Print + +
@@ -389,14 +395,19 @@ if ($patientPhotoPath === '') {
-
+
-
Patient Scanned & Uploaded Documents
-
All documents attached to this patient ordered by date (Latest First)
+
Patient Scanned & Uploaded Documents
+
Scanned copies, webcam captures, and uploaded reports formatted for ABDM HealthDocumentRecord
+
+
+ + + Total Documents: +
- - Total Documents: -
@@ -406,15 +417,28 @@ if ($patientPhotoPath === '') {
-
+
- - - PDF - - - + + + +
+ + + FHIR Ready + + + + FHIR Available + + + + PDF + + + +
@@ -428,9 +452,19 @@ if ($patientPhotoPath === '') {
@@ -1860,3 +1894,196 @@ $(function() { }); + + + + + diff --git a/app/Views/doctor_document/workspace.php b/app/Views/doctor_document/workspace.php index 19ac7ff..6937479 100644 --- a/app/Views/doctor_document/workspace.php +++ b/app/Views/doctor_document/workspace.php @@ -1,100 +1,355 @@
-

Doctor Documents Workspace

- +
+
+

MRD & IPD Medical Record Documents Workspace

+ +
+
+ NABH Guidelines Compliant + ABDM FHIR R4 Ready +
+
-
-
-
-
-
-
Template Master
-

Create and edit certificate templates with custom fields.

- +
+
+
+ Need help locating IPD admission? +
-
-
-
-
-
Patient Document Issue
-
- - -
-
- - - - + + + + + + + + +
+ +
Search an IPD Admission to View NABH Document Upload List
+

Enter an IPD No. (e.g. 17 or 5) above to load the NABH mandatory scanned document checklist.

+
diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index 2217ea3..2ad6337 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -487,6 +487,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) { Collection Report +
diff --git a/app/Views/report/old_payment_received_report.php b/app/Views/report/old_payment_received_report.php new file mode 100644 index 0000000..575cffe --- /dev/null +++ b/app/Views/report/old_payment_received_report.php @@ -0,0 +1,89 @@ +
+
+

Old Payment Received Report

+ Invoice Date < Payment Date +
+
+

+ This report lists payments received during the selected date range where the original invoice was generated on an earlier date (Invoice Date < Payment Date). +

+ +
+
+ +
+ + to + +
+
+
+ + + + +
+
+ +
+
+ +

Click Show Details to load old payment collections for the selected date range.

+
+
+
+
+ + diff --git a/app/Views/report/old_payment_received_report_table.php b/app/Views/report/old_payment_received_report_table.php new file mode 100644 index 0000000..985f258 --- /dev/null +++ b/app/Views/report/old_payment_received_report_table.php @@ -0,0 +1,64 @@ +
+
+ + Payment Date Range: to +
+
+ Total Records: | + Total Amount Collected: ₹ +
+
+ + +
+ +
No Old Payment Receipts Found
+

No transactions found where Invoice Date < Payment Date in the selected date range.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Payment DateInvoice DateDelay (Days)Invoice CodePatient NamePatient CodeInvoice Net AmtAmount ReceivedPay ModeCollected By
+ + day 1 ? 's' : '' ?> ago + + ₹
+
+ diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index 02237ad..82ebd3d 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -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']); + } }