diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 9bc07b2..2b0222a 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -19,7 +19,7 @@ $routes->get('Medical', 'Medical::index');
$routes->get('Medical/search_customer', 'Medical::search_customer');
$routes->get('Medical/CounterSaleForm', 'Medical::counter_sale_form');
$routes->post('Medical/CounterSaleCreate', 'Medical::counter_sale_create');
-$routes->match(['get', 'post'], 'Medical/search', 'Medical::search');
+$routes->match(['GET', 'POST'], 'Medical/search', 'Medical::search');
$routes->get('Medical/get_drug', 'Medical::get_drug');
$routes->get('Medical/get_batch/(:num)', 'Medical::get_batch/$1');
$routes->get('Medical/check_invoice/(:num)/(:num)/(:num)', 'Medical::check_invoice/$1/$2/$3');
@@ -28,7 +28,7 @@ $routes->get('Medical/invoice_new/(:num)', 'Medical::invoice_new/$1');
$routes->get('Medical/invoice_edit/(:num)', 'Medical::invoice_edit/$1');
$routes->get('Medical/final_invoice/(:num)', 'Medical::final_invoice/$1');
$routes->get('Medical/open_invoice_edit/(:num)', 'Medical::open_invoice_edit/$1');
-$routes->match(['get', 'post'], 'Medical/edit_invoice_edit/(:num)', 'Medical::edit_invoice_edit/$1');
+$routes->match(['GET', 'POST'], 'Medical/edit_invoice_edit/(:num)', 'Medical::edit_invoice_edit/$1');
$routes->post('Medical/patient_info', 'Medical::patient_info');
$routes->post('Medical/ipd_info', 'Medical::ipd_info');
$routes->post('Medical/org_info', 'Medical::org_info');
@@ -46,7 +46,7 @@ $routes->post('Medical/remove_item', 'Medical::remove_item');
$routes->post('Medical/update_item_qty', 'Medical::update_item_qty');
$routes->post('Medical/add_remove_item', 'Medical::add_remove_item');
$routes->post('Medical/go_final', 'Medical::go_final');
-$routes->match(['get', 'post'], 'Medical/getInvoiceTable', 'Medical::getInvoiceTable');
+$routes->match(['GET', 'POST'], 'Medical/getInvoiceTable', 'Medical::getInvoiceTable');
$routes->get('Medical/invoice_print/(:num)', 'Medical::invoice_print/$1');
$routes->get('Medical/invoice_print/(:num)/(:num)', 'Medical::invoice_print/$1/$2');
$routes->get('Medical/Invoice_Med_Draft', 'Medical::Invoice_Med_Draft');
@@ -148,7 +148,7 @@ $routes->post('Medical/CreatePurchaseReturn', 'Medical::create_purchase_return')
$routes->get('Medical/PurchaseReturnInvoiceEdit/(:num)', 'Medical::purchase_return_invoice_edit/$1');
$routes->get('Medical/PurchaseReturn_invoice_item_list/(:num)', 'Medical::purchase_return_invoice_item_list/$1');
$routes->get('Medical/Purchase_Invoice_product/(:num)', 'Medical::purchase_invoice_product/$1');
-$routes->match(['get', 'post'], 'Medical/Purchase_Invoice_old/(:num)', 'Medical::purchase_invoice_old/$1');
+$routes->match(['GET', 'POST'], 'Medical/Purchase_Invoice_old/(:num)', 'Medical::purchase_invoice_old/$1');
$routes->post('Medical/remove_item_invoice/(:num)', 'Medical::purchase_return_remove_item_invoice/$1');
$routes->get('Medical/print_purchase_return/(:num)', 'Medical::print_purchase_return/$1');
$routes->get('Medical/store_stock', 'Medical::store_stock');
@@ -168,7 +168,7 @@ $routes->post('Medical_backpanel/CreatePurchaseReturn', 'Medical::create_purchas
$routes->get('Medical_backpanel/PurchaseReturnInvoiceEdit/(:num)', 'Medical::purchase_return_invoice_edit/$1');
$routes->get('Medical_backpanel/PurchaseReturn_invoice_item_list/(:num)', 'Medical::purchase_return_invoice_item_list/$1');
$routes->get('Medical_backpanel/Purchase_Invoice_product/(:num)', 'Medical::purchase_invoice_product/$1');
-$routes->match(['get', 'post'], 'Medical_backpanel/Purchase_Invoice_old/(:num)', 'Medical::purchase_invoice_old/$1');
+$routes->match(['GET', 'POST'], 'Medical_backpanel/Purchase_Invoice_old/(:num)', 'Medical::purchase_invoice_old/$1');
$routes->post('Medical_backpanel/add_remove_item', 'Medical::purchase_return_add_remove_item');
$routes->post('Medical_backpanel/remove_item_invoice/(:num)', 'Medical::purchase_return_remove_item_invoice/$1');
$routes->get('Medical_backpanel/get_drug', 'Medical::get_drug');
@@ -180,7 +180,7 @@ $routes->get('Medical_backpanel/Print_bill_on_uhid', 'Medical::print_bill_on_uhi
$routes->get('Medical_backpanel/uhid_report/(:segment)/(:segment)', 'Medical::uhid_report/$1/$2');
$routes->get('Medical_backpanel/SupplierAccount', 'Medical::supplier_account');
$routes->get('Medical_backpanel/invoice_item_log', 'Medical::invoice_item_log');
-$routes->match(['get', 'post'], 'Medical_backpanel/invoice_item_log_data', 'Medical::invoice_item_log_data');
+$routes->match(['GET', 'POST'], 'Medical_backpanel/invoice_item_log_data', 'Medical::invoice_item_log_data');
$routes->get('Medical_backpanel/stock_details', 'Medical::stock_details');
$routes->get('Medical_backpanel/Expire_stock', 'Medical::expire_stock');
$routes->get('Medical_backpanel/Expire_stock_pdf', 'Medical::expire_stock_pdf');
@@ -273,7 +273,7 @@ $routes->post('Medical/supplier_account_add_entry/(:num)', 'Medical::supplier_ac
$routes->get('Medical/supplier_account_edit_entry/(:num)', 'Medical::supplier_account_edit_entry/$1');
$routes->post('Medical/supplier_account_edit_entry/(:num)', 'Medical::supplier_account_edit_entry/$1');
$routes->get('Medical/invoice_item_log', 'Medical::invoice_item_log');
-$routes->match(['get', 'post'], 'Medical/invoice_item_log_data', 'Medical::invoice_item_log_data');
+$routes->match(['GET', 'POST'], 'Medical/invoice_item_log_data', 'Medical::invoice_item_log_data');
$routes->get('Medical/master', 'Medical::master');
$routes->get('Medical/master_report/med-payment-edit', 'Payment_Medical::index');
$routes->get('Medical/master_report/(:segment)', 'Medical::master_report/$1');
@@ -281,7 +281,7 @@ $routes->get('Medical/master_link/(:segment)', 'Medical::master_link/$1');
$routes->get('Payment_Medical', 'Payment_Medical::index');
$routes->get('Payment_Medical/payment_log', 'Payment_Medical::payment_log');
-$routes->match(['get', 'post'], 'Payment_Medical/payment_log_data', 'Payment_Medical::payment_log_data');
+$routes->match(['GET', 'POST'], 'Payment_Medical/payment_log_data', 'Payment_Medical::payment_log_data');
$routes->post('Payment_Medical/payment_record', 'Payment_Medical::payment_record');
$routes->post('Payment_Medical/change_to_bank', 'Payment_Medical::change_to_bank');
$routes->post('Payment_Medical/change_to_cash', 'Payment_Medical::change_to_cash');
@@ -337,7 +337,7 @@ service('auth')->routes($routes);
// Hospital Stock Management (Storestock)
$routes->get('Storestock', 'Storestock::index');
$routes->get('Storestock/Indent_List', 'Storestock::indent_list');
-$routes->match(['get', 'post'], 'Storestock/getIndentTable', 'Storestock::getIndentTable');
+$routes->match(['GET', 'POST'], 'Storestock/getIndentTable', 'Storestock::getIndentTable');
$routes->get('Storestock/new_indent', 'Storestock::new_indent');
$routes->post('Storestock/Indent_create/(:num)', 'Storestock::indent_create/$1');
$routes->get('Storestock/Indent_show/(:num)', 'Storestock::indent_show/$1');
@@ -348,11 +348,11 @@ $routes->get('Storestock/final_invoice/(:num)', 'Storestock::final_invoice/$1');
$routes->get('Storestock/print_single_indent/(:num)', 'Storestock::print_single_indent/$1');
$routes->get('Storestock/store_stock', 'Storestock::store_stock');
$routes->post('Storestock/store_Stock_result', 'Storestock::store_stock_result');
-$routes->match(['get', 'post'], 'Storestock/get_product_stock/(:num)', 'Storestock::get_product_stock/$1');
+$routes->match(['GET', 'POST'], 'Storestock/get_product_stock/(:num)', 'Storestock::get_product_stock/$1');
$routes->get('Storestock/Report_2', 'Storestock::report_2');
$routes->get('Storestock/main_store', 'Storestock::main_store');
$routes->get('Storestock/Purchase', 'Storestock::Purchase');
-$routes->match(['get', 'post'], 'Storestock/PurchaseInvoice', 'Storestock::PurchaseInvoice');
+$routes->match(['GET', 'POST'], 'Storestock/PurchaseInvoice', 'Storestock::PurchaseInvoice');
$routes->get('Storestock/PurchaseNew', 'Storestock::PurchaseNew');
$routes->post('Storestock/CreatePurchase', 'Storestock::CreatePurchase');
$routes->get('Storestock/PurchaseMasterEdit/(:num)', 'Storestock::PurchaseMasterEdit/$1');
@@ -363,7 +363,7 @@ $routes->post('Storestock/purchase_invoice_delete/(:num)', 'Storestock::purchase
$routes->get('Storestock/print_purchase/(:num)', 'Storestock::print_purchase/$1');
$routes->get('Storestock/Purchase_return', 'Storestock::Purchase_return');
$routes->get('Storestock/PurchaseReturnNew', 'Storestock::PurchaseReturnNew');
-$routes->match(['get', 'post'], 'Storestock/PurchaseReturnInvoice', 'Storestock::PurchaseReturnInvoice');
+$routes->match(['GET', 'POST'], 'Storestock/PurchaseReturnInvoice', 'Storestock::PurchaseReturnInvoice');
$routes->get('Storestock/PurchaseReturnInvoiceEdit/(:num)', 'Storestock::PurchaseReturnInvoiceEdit/$1');
$routes->get('Storestock/PurchaseReturn_invoice_item_list/(:num)', 'Storestock::PurchaseReturn_invoice_item_list/$1');
$routes->post('Storestock/create_purchase_return', 'Storestock::create_purchase_return');
@@ -391,8 +391,8 @@ $routes->get('Ipd_discharge/debug_ipd_fields/(:num)', 'Ipd_discharge::debug_ipd_
$routes->get('Ipd_discharge/debug_discharge_html/(:num)', 'Ipd_discharge::debug_discharge_html/$1');
$routes->get('Ipd_discharge/placeholder_preview', 'Ipd_discharge::placeholder_preview');
$routes->get('Ipd_discharge/placeholder_preview/(:num)', 'Ipd_discharge::placeholder_preview/$1');
-$routes->match(['get', 'post'], 'Ipd_discharge/ipd_select/(:num)', 'Ipd_discharge::ipd_select/$1');
-$routes->match(['get', 'post'], 'Ipd_discharge/ipd_select/(:num)/(:num)', 'Ipd_discharge::ipd_select/$1/$2');
+$routes->match(['GET', 'POST'], 'Ipd_discharge/ipd_select/(:num)', 'Ipd_discharge::ipd_select/$1');
+$routes->match(['GET', 'POST'], 'Ipd_discharge/ipd_select/(:num)/(:num)', 'Ipd_discharge::ipd_select/$1/$2');
$routes->get('Ipd_discharge/section_past_data', 'Ipd_discharge::section_past_data');
$routes->get('Ipd_discharge/section_template_list', 'Ipd_discharge::section_template_list');
$routes->post('Ipd_discharge/section_template_save', 'Ipd_discharge::section_template_save');
@@ -410,7 +410,7 @@ $routes->post('Ipd_discharge/update_complaint_field', 'Ipd_discharge::update_com
$routes->get('Ipd_discharge/dietary_master_list', 'Ipd_discharge::dietary_master_list');
$routes->post('Ipd_discharge/dietary_master_save', 'Ipd_discharge::dietary_master_save');
$routes->post('Ipd_discharge/dietary_master_delete', 'Ipd_discharge::dietary_master_delete');
-$routes->match(['get', 'post'], 'Ipd_discharge/preview_discharge_report/(:num)', 'Ipd_discharge::preview_discharge_report/$1');
+$routes->match(['GET', 'POST'], 'Ipd_discharge/preview_discharge_report/(:num)', 'Ipd_discharge::preview_discharge_report/$1');
$routes->get('Ipd_discharge/show_discharge/(:num)', 'Ipd_discharge::show_discharge/$1');
$routes->get('Ipd_discharge/show_discharge/(:num)/(:num)', 'Ipd_discharge::show_discharge/$1/$2');
$routes->get('Ipd_discharge/show_file3/(:num)', 'Ipd_discharge::show_file3/$1');
@@ -502,7 +502,7 @@ $routes->group('billing', function($routes) {
$routes->get('patient/abdm_content_auto_flow/(:num)', 'Patient::abdm_content_auto_flow/$1');
$routes->get('patient/abdm_timeline/(:num)', 'Patient::abdm_timeline/$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->match(['GET', 'POST'], 'patient/patient_file_upload/(:num)', 'Patient::patient_file_upload/$1');
$routes->get('patient/city', 'Patient::city');
$routes->get('patient/district_list', 'Patient::district_list');
$routes->get('patient/get_name', 'Patient::get_name');
@@ -580,24 +580,24 @@ $routes->get('Opd/get_appointment_digest/(:num)', 'Opd::get_appointment_digest/$
$routes->get('Opd/get_appointment_digest/(:num)/(:segment)', 'Opd::get_appointment_digest/$1/$2');
$routes->get('Opd/get_appointment_live_rows/(:num)', 'Opd::get_appointment_live_rows/$1');
$routes->get('Opd/get_appointment_live_rows/(:num)/(:segment)', 'Opd::get_appointment_live_rows/$1/$2');
-$routes->match(['get', 'post'], 'Opd/opd_status/(:num)/(:num)', 'Opd::opd_status/$1/$2');
+$routes->match(['GET', 'POST'], 'Opd/opd_status/(:num)/(:num)', 'Opd::opd_status/$1/$2');
$routes->post('Opd/showfee', 'Opd::showfee');
$routes->post('Opd/confirm_opd', 'Opd::confirm_opd');
$routes->post('Opd/confirm_payment', 'Opd::confirm_payment');
$routes->post('Opd/update_doc_date/(:num)', 'Opd::update_doc_date/$1');
$routes->post('Opd/opd_discount_update/(:num)', 'Opd::opd_discount_update/$1');
$routes->post('Opd/opd_cancel/(:num)', 'Opd::opd_cancel/$1');
-$routes->match(['get', 'post'], 'Opd/opd_load_doc/(:num)', 'Opd::opd_load_doc/$1');
-$routes->match(['get', 'post'], 'Opd/opd_file_list/(:num)', 'Opd::opd_file_list/$1');
-$routes->match(['get', 'post'], 'Opd/opd_file_last_list', 'Opd::opd_file_last_list');
-$routes->match(['get', 'post'], 'Opd/opd_file_last_list/(:num)', 'Opd::opd_file_last_list/$1');
-$routes->match(['get', 'post'], 'Opd/save_image/(:num)', 'Opd::save_image/$1');
+$routes->match(['GET', 'POST'], 'Opd/opd_load_doc/(:num)', 'Opd::opd_load_doc/$1');
+$routes->match(['GET', 'POST'], 'Opd/opd_file_list/(:num)', 'Opd::opd_file_list/$1');
+$routes->match(['GET', 'POST'], 'Opd/opd_file_last_list', 'Opd::opd_file_last_list');
+$routes->match(['GET', 'POST'], 'Opd/opd_file_last_list/(:num)', 'Opd::opd_file_last_list/$1');
+$routes->match(['GET', 'POST'], 'Opd/save_image/(:num)', 'Opd::save_image/$1');
$routes->post('Opd/scan_ai_process_file', 'Opd::scan_ai_process_file');
// Legacy CI3 uploads fallback: serves files from project-root uploads/ when missing from public/uploads/
$routes->get('uploads/(:any)', 'Opd::serve_legacy_upload/$1');
$routes->post('Opd/scan_ai_assist', 'Opd::scan_ai_assist');
-$routes->match(['get', 'post'], 'Opd/opd_file_hide/(:num)', 'Opd::opd_file_hide/$1');
-$routes->match(['get', 'post'], 'Opd/opd_file_delete/(:num)', 'Opd::opd_file_delete/$1');
+$routes->match(['GET', 'POST'], 'Opd/opd_file_hide/(:num)', 'Opd::opd_file_hide/$1');
+$routes->match(['GET', 'POST'], 'Opd/opd_file_delete/(:num)', 'Opd::opd_file_delete/$1');
$routes->get('Opd/invoice/(:num)', 'Opd::invoice/$1');
$routes->get('Opd/invoice_print/(:num)', 'Opd::invoice_print/$1');
$routes->get('Opd/opd_lettre_print/(:num)', 'Opd::opd_lettre_print/$1');
@@ -608,7 +608,7 @@ $routes->get('Opd/print_template_builder', 'Opd::print_template_builder');
$routes->post('Opd/print_template_save', 'Opd::print_template_save');
$routes->post('Opd/print_template_rename', 'Opd::print_template_rename');
$routes->post('Opd/print_template_delete', 'Opd::print_template_delete');
-$routes->match(['get', 'post'], 'Opd/paper_print_settings', 'Opd::paper_print_settings');
+$routes->match(['GET', 'POST'], 'Opd/paper_print_settings', 'Opd::paper_print_settings');
$routes->get('opd_print/invoice_print_pdf/(:num)', 'Opd::invoice_print_pdf/$1');
$routes->get('opd_print/opd_PDF_print/(:num)', 'Opd::opd_pdf_print/$1');
@@ -616,7 +616,7 @@ $routes->get('opd_print/opd_Cont_print/(:num)', 'Opd::opd_cont_print/$1');
$routes->get('opd_print/opd_blank_print/(:num)', 'Opd::opd_blank_print/$1');
$routes->get('Opd_prescription/Prescription/(:num)', 'Opd_prescription::Prescription/$1');
-$routes->match(['get', 'post'], 'Opd_prescription/create_opd_queue/(:num)', 'Opd_prescription::create_opd_queue/$1');
+$routes->match(['GET', 'POST'], 'Opd_prescription/create_opd_queue/(:num)', 'Opd_prescription::create_opd_queue/$1');
$routes->get('Opd_prescription/opd_prescription_print/(:num)/(:num)', 'Opd_prescription::opd_prescription_print/$1/$2');
$routes->get('Opd_prescription/opd_prescription_print/(:num)/(:num)/(:num)', 'Opd_prescription::opd_prescription_print/$1/$2/$3');
$routes->post('Opd_prescription/opd_prescription_save', 'Opd_prescription::opd_prescription_save');
@@ -685,6 +685,9 @@ $routes->get('AbdmGateway/diagnosis_report_fhir_preview', 'AbdmGateway::diagnosi
$routes->get('AbdmGateway/ipd_discharge_fhir_preview', 'AbdmGateway::ipdDischargeFhirPreview', ['filter' => $abdmPermFilter]);
$routes->get('AbdmGateway/immunization_fhir_preview', 'AbdmGateway::immunizationFhirPreview', ['filter' => $abdmPermFilter]);
$routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmunizationBundle', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmGateway/share_wellness_bundle', 'AbdmGateway::shareWellnessBundle', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmGateway/share_health_document_bundle', 'AbdmGateway::shareHealthDocumentBundle', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmGateway/share_invoice_bundle', 'AbdmGateway::shareInvoiceBundle', ['filter' => $abdmPermFilter]);
$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
$routes->post('Immunization/sync_uip_master', 'Immunization::syncUipMaster', ['filter' => 'permission:doctor_work.access,doctor_work.template_workspace.access,abdm.access,abdm.taskboard.access,abdm.gateway.use']);
@@ -723,6 +726,7 @@ $routes->post('abha/create/verify_comm_otp', 'Abha::verifyCommOtp');
$routes->post('abha/create/address', 'Abha::address');
$routes->get('abha/card/(:segment)', 'Abha::card/$1');
$routes->post('abha/register/validate', 'Abha::validateAbha');
+$routes->get('abha/register/facility_qr', 'Abha::facilityQr');
$routes->get('AbdmTaskBoard', 'AbdmTaskBoard::index', ['filter' => $abdmPermFilter]);
$routes->get('AbdmTaskBoard/list', 'AbdmTaskBoard::list', ['filter' => $abdmPermFilter]);
$routes->post('AbdmTaskBoard/perform_action', 'AbdmTaskBoard::performAction', ['filter' => $abdmPermFilter]);
@@ -959,15 +963,15 @@ $routes->post('Lab_Admin/pathology_master_apply_panel', 'Setting\Template::patho
$routes->post('Lab_Admin/pathology_master_add_component', 'Setting\Template::pathologyMasterAddComponent');
$routes->get('Lab_Admin/pathology_bridge_debug', 'Setting\Template::pathologyBridgeDebug');
-$routes->match(['get', 'post'], 'setting/template/discharge_templates', 'Setting\Template::discharge_templates');
+$routes->match(['GET', 'POST'], 'setting/template/discharge_templates', 'Setting\Template::discharge_templates');
$routes->post('setting/template/discharge_templates_seed', 'Setting\Template::discharge_templates_seed');
$routes->get('setting/template/discharge_template_get/(:num)', 'Setting\Template::discharge_template_get/$1');
-$routes->match(['get', 'post'], 'setting/template/discharge_templates/delete/(:num)', 'Setting\Template::discharge_template_delete/$1');
-$routes->match(['get', 'post'], 'setting/template/ipd_document_templates', 'Setting\Template::ipd_document_templates');
+$routes->match(['GET', 'POST'], 'setting/template/discharge_templates/delete/(:num)', 'Setting\Template::discharge_template_delete/$1');
+$routes->match(['GET', 'POST'], 'setting/template/ipd_document_templates', 'Setting\Template::ipd_document_templates');
$routes->get('setting/template/ipd_document_templates/delete/(:num)', 'Setting\Template::ipd_document_template_delete/$1');
-$routes->match(['get', 'post'], 'setting/template/diagnosis_print_settings', 'Setting\Template::diagnosis_print_settings');
-$routes->match(['get', 'post'], 'setting/template/diagnosis_print_settings/(:num)', 'Setting\Template::diagnosis_print_settings/$1');
-$routes->match(['get', 'post'], 'setting/template/document_print_settings', 'Setting\Template::document_print_settings');
+$routes->match(['GET', 'POST'], 'setting/template/diagnosis_print_settings', 'Setting\Template::diagnosis_print_settings');
+$routes->match(['GET', 'POST'], 'setting/template/diagnosis_print_settings/(:num)', 'Setting\Template::diagnosis_print_settings/$1');
+$routes->match(['GET', 'POST'], 'setting/template/document_print_settings', 'Setting\Template::document_print_settings');
$routes->get('Opdcase/addopd/(:num)', 'Opdcase::addopd/$1');
$routes->post('Opdcase/showfee', 'Opdcase::showfee');
@@ -1087,7 +1091,7 @@ $routes->post('diagnosis/confirm-report-xray/(:num)', 'Diagnosis::confirmReportX
// Editor JS compatibility aliases
$routes->post('diagnosis/update-report/(:num)', 'Diagnosis::finalUpdateXray/$1');
$routes->post('diagnosis/report-verify/(:num)', 'Diagnosis::confirmReportXray/$1');
-$routes->match(['get', 'post'], 'diagnosis/get-template-xray/(:num)', 'Diagnosis::getTemplateXray/$1');
+$routes->match(['GET', 'POST'], 'diagnosis/get-template-xray/(:num)', 'Diagnosis::getTemplateXray/$1');
$routes->post('diagnosis/remove-test/(:num)', 'Diagnosis::removeTest/$1');
$routes->post('diagnosis/update-combine-report', 'Diagnosis::updateCombineReport');
$routes->post('diagnosis/report-compile/(:num)/(:num)', 'Diagnosis::reportCompile/$1/$2');
@@ -1124,11 +1128,11 @@ $routes->get('Lab_Report/report_file_list/(:num)/(:num)/(:num)', 'Diagnosis::rep
$routes->get('Lab_Report/imaging_upload_gallery/(:num)/(:num)/(:num)', 'Diagnosis::imagingUploadGallery/$1/$2/$3');
$routes->post('Lab_Report/imaging_ai_diagnosis/(:num)', 'Diagnosis::imagingAiDiagnosis/$1');
$routes->get('Lab_Report/select_lab_radiology/(:num)/(:num)', 'Diagnosis::selectLabRadiology/$1/$2');
-$routes->match(['get', 'post'], 'Lab_Report/create_report_xray/(:num)', 'Diagnosis::createReportXray/$1');
+$routes->match(['GET', 'POST'], 'Lab_Report/create_report_xray/(:num)', 'Diagnosis::createReportXray/$1');
$routes->get('Lab_Report/show_report_final/(:num)', 'Diagnosis::showReportFinalXray/$1');
-$routes->match(['get', 'post'], 'Lab_Report/get_template_xray/(:num)', 'Diagnosis::getTemplateXray/$1');
-$routes->match(['get', 'post'], 'Lab_Report/Final_Update_xray/(:num)', 'Diagnosis::finalUpdateXray/$1');
-$routes->match(['get', 'post'], 'Lab_Report/confirm_report_xray/(:num)', 'Diagnosis::confirmReportXray/$1');
+$routes->match(['GET', 'POST'], 'Lab_Report/get_template_xray/(:num)', 'Diagnosis::getTemplateXray/$1');
+$routes->match(['GET', 'POST'], 'Lab_Report/Final_Update_xray/(:num)', 'Diagnosis::finalUpdateXray/$1');
+$routes->match(['GET', 'POST'], 'Lab_Report/confirm_report_xray/(:num)', 'Diagnosis::confirmReportXray/$1');
// Legacy URL compatibility
$routes->get('Ocasemaster/newcase/(:num)/(:num)', 'Billing\\CaseMaster::newcase/$1/$2');
@@ -1310,7 +1314,7 @@ $routes->group('item', static function($routes) {
$routes->get('search-adv/(:num)', 'Item::searchAdv/$1');
$routes->get('search-print/(:num)', 'Item::searchPrint/$1');
$routes->get('export-excel/(:num)', 'Item::exportExcel/$1');
- $routes->match(['get', 'post'], 'item-record/(:num)', 'Item::itemRecord/$1');
+ $routes->match(['GET', 'POST'], 'item-record/(:num)', 'Item::itemRecord/$1');
$routes->get('itemtype-record/(:num)', 'Item::itemTypeRecord/$1');
$routes->get('add', 'Item::addRecord');
$routes->get('add-type', 'Item::addItemTypeRecord');
@@ -1328,7 +1332,7 @@ $routes->group('item-ipd', static function($routes) {
$routes->get('search-adv/(:num)', 'ItemIpd::searchAdv/$1');
$routes->get('search-print/(:num)', 'ItemIpd::searchPrint/$1');
$routes->get('export-excel/(:num)', 'ItemIpd::exportExcel/$1');
- $routes->match(['get', 'post'], 'item-record/(:num)', 'ItemIpd::itemRecord/$1');
+ $routes->match(['GET', 'POST'], 'item-record/(:num)', 'ItemIpd::itemRecord/$1');
$routes->get('itemtype-record/(:num)', 'ItemIpd::itemTypeRecord/$1');
$routes->get('add', 'ItemIpd::addRecord');
$routes->get('add-type', 'ItemIpd::addItemTypeRecord');
@@ -1348,7 +1352,7 @@ $routes->group('package', static function($routes) {
$routes->get('search-adv/(:num)', 'Package::searchAdv/$1');
$routes->get('search-print/(:num)', 'Package::searchPrint/$1');
$routes->get('export-excel/(:num)', 'Package::exportExcel/$1');
- $routes->match(['get', 'post'], 'item-record/(:num)', 'Package::itemRecord/$1');
+ $routes->match(['GET', 'POST'], 'item-record/(:num)', 'Package::itemRecord/$1');
$routes->get('itemtype-record/(:num)', 'Package::itemTypeRecord/$1');
$routes->get('add', 'Package::addRecord');
$routes->get('add-type', 'Package::addItemTypeRecord');
@@ -1366,7 +1370,7 @@ $routes->group('Package', static function($routes) {
$routes->get('search_adv/(:num)', 'Package::searchAdv/$1');
$routes->get('search_print/(:num)', 'Package::searchPrint/$1');
$routes->get('export-excel/(:num)', 'Package::exportExcel/$1');
- $routes->match(['get', 'post'], 'item_record/(:num)', 'Package::itemRecord/$1');
+ $routes->match(['GET', 'POST'], 'item_record/(:num)', 'Package::itemRecord/$1');
$routes->get('itemtype_record/(:num)', 'Package::itemTypeRecord/$1');
$routes->get('AddRecord', 'Package::addRecord');
$routes->get('AddItemTypeRecord', 'Package::addItemTypeRecord');
@@ -1384,7 +1388,7 @@ $routes->group('IPD_Package', static function($routes) {
$routes->get('search_adv/(:num)', 'Package::searchAdv/$1');
$routes->get('search_print/(:num)', 'Package::searchPrint/$1');
$routes->get('export-excel/(:num)', 'Package::exportExcel/$1');
- $routes->match(['get', 'post'], 'item_record/(:num)', 'Package::itemRecord/$1');
+ $routes->match(['GET', 'POST'], 'item_record/(:num)', 'Package::itemRecord/$1');
$routes->get('itemtype_record/(:num)', 'Package::itemTypeRecord/$1');
$routes->get('AddRecord', 'Package::addRecord');
$routes->get('AddItemTypeRecord', 'Package::addItemTypeRecord');
@@ -1402,7 +1406,7 @@ $routes->group('Item_IPD', static function($routes) {
$routes->get('search_adv/(:num)', 'ItemIpd::searchAdv/$1');
$routes->get('search_print/(:num)', 'ItemIpd::searchPrint/$1');
$routes->get('export-excel/(:num)', 'ItemIpd::exportExcel/$1');
- $routes->match(['get', 'post'], 'item_record/(:num)', 'ItemIpd::itemRecord/$1');
+ $routes->match(['GET', 'POST'], 'item_record/(:num)', 'ItemIpd::itemRecord/$1');
$routes->get('itemtype_record/(:num)', 'ItemIpd::itemTypeRecord/$1');
$routes->get('AddRecord', 'ItemIpd::addRecord');
$routes->get('AddItemTypeRecord', 'ItemIpd::addItemTypeRecord');
diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php
index e6157d5..05bb7e4 100644
--- a/app/Controllers/AbdmGateway.php
+++ b/app/Controllers/AbdmGateway.php
@@ -999,6 +999,8 @@ class AbdmGateway extends BaseController
'phone' => trim((string) ($this->request->getPost('phone') ?? '')),
'gender' => strtoupper(trim((string) ($this->request->getPost('gender') ?? ''))),
'dob' => trim((string) ($this->request->getPost('dob') ?? '')),
+ 'age' => trim((string) ($this->request->getPost('age') ?? $this->request->getPost('patient_age') ?? '')),
+ 'birth_year' => trim((string) ($this->request->getPost('birth_year') ?? $this->request->getPost('birthYear') ?? $this->request->getPost('year_of_birth') ?? '')),
];
$db = $this->db;
@@ -1542,15 +1544,15 @@ class AbdmGateway extends BaseController
$hasAbha = $abhaId !== '';
$consent = null;
+ $consentWarning = '';
if ($hasAbha) {
$consent = $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle);
if ($consent === null) {
- return $this->response->setJSON([
- 'ok' => 0,
- 'error_text' => 'No active consent found. Share blocked due to expiry/not-approved consent.',
- ]);
+ $consentWarning = 'No active consent found. Proceeding with care-context push only.';
+ $this->logMissingConsentShareWarning('ipd', $ipdId, $patientId, $abhaId, $consentHandle, 'abdm.ipd.discharge.share.consent_missing', $consentWarning);
}
}
+ $effectiveConsent = (string) ($consent['consent_handle'] ?? $consentHandle);
$fhirPayload = $this->buildIpdDischargeGatewayPayload($ipdId, $patientId, $abhaId);
if ($fhirPayload === null) {
@@ -1575,7 +1577,7 @@ class AbdmGateway extends BaseController
'entity_id' => (string) $ipdId,
'fhir_bundle' => $bundleJson,
'care_context_reference' => $ccRef,
- 'consent_handle' => (string) ($consent['consent_handle'] ?? ''),
+ 'consent_handle' => $effectiveConsent,
]);
if (! $hasAbha) {
@@ -1642,8 +1644,8 @@ class AbdmGateway extends BaseController
'entity_id' => (string) $ipdId,
'abha_id' => $abhaId,
'patient_id' => $patientId,
- 'request' => ['ipd_id' => $ipdId, 'hi_type' => 'DischargeSummaryRecord', 'consent_handle' => (string) ($consent['consent_handle'] ?? '')],
- 'response' => ['queue_id' => $queueId],
+ 'request' => ['ipd_id' => $ipdId, 'hi_type' => 'DischargeSummaryRecord', 'consent_handle' => $effectiveConsent],
+ 'response' => ['queue_id' => $queueId, 'warning' => $consentWarning],
'outcome' => $connectorError === null ? 'success' : 'failure',
'error_message' => (string) ($connectorError ?? ''),
]);
@@ -1651,8 +1653,10 @@ class AbdmGateway extends BaseController
return $this->response->setJSON([
'ok' => $connectorError === null ? 1 : 0,
'queue_id' => $queueId,
- 'consent_handle' => (string) ($consent['consent_handle'] ?? ''),
+ 'consent_handle' => $effectiveConsent,
'status' => $connectorError === null ? 'queued' : 'failed',
+ 'message' => $connectorError === null && $consentWarning !== '' ? 'Record pushed to gateway without active consent. Recheck link status in M2 ABDM Gateway.' : null,
+ 'warning' => $consentWarning !== '' ? $consentWarning : null,
'error' => $connectorError,
]);
}
@@ -1685,13 +1689,12 @@ class AbdmGateway extends BaseController
$hasAbha = $abhaId !== '';
$consentRecord = null;
+ $consentWarning = '';
if ($hasAbha) {
$consentRecord = $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle);
if ($consentRecord === null) {
- return $this->response->setJSON([
- 'ok' => 0,
- 'error' => 'No active consent found. Share blocked due to expiry/not-approved consent.',
- ]);
+ $consentWarning = 'No active consent found. Proceeding with care-context push only.';
+ $this->logMissingConsentShareWarning('lab', $labReqId, $patientId, $abhaId, $consentHandle, 'abdm.diagnosis.report.share.consent_missing', $consentWarning);
}
}
$effectiveConsent = (string) ($consentRecord['consent_handle'] ?? $consentHandle);
@@ -1875,7 +1878,7 @@ class AbdmGateway extends BaseController
'abha_id' => $abhaId,
'patient_id' => $patientId,
'request' => ['lab_req_id' => $labReqId, 'hi_type' => 'DiagnosticReportRecord', 'consent_handle' => $effectiveConsent],
- 'response' => ['queue_id' => $queueId],
+ 'response' => ['queue_id' => $queueId, 'warning' => $consentWarning],
'outcome' => $connectorError === null ? 'success' : 'failure',
'error_message' => (string) ($connectorError ?? ''),
]);
@@ -1885,6 +1888,8 @@ class AbdmGateway extends BaseController
'queue_id' => $queueId,
'consent_handle' => $effectiveConsent,
'status' => $connectorError === null ? 'queued' : 'failed',
+ 'message' => $connectorError === null && $consentWarning !== '' ? 'Record pushed to gateway without active consent. Recheck link status in M2 ABDM Gateway.' : null,
+ 'warning' => $consentWarning !== '' ? $consentWarning : null,
'error' => $connectorError,
]);
}
@@ -2218,6 +2223,74 @@ class AbdmGateway extends BaseController
]);
}
+ public function shareWellnessBundle()
+ {
+ if (! $this->request->isAJAX()) {
+ return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $patientId = (int) $this->request->getPost('patient_id');
+ $opdId = (int) $this->request->getPost('opd_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) {
+ return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
+ }
+
+ $payload = $this->buildWellnessRecordPayload($patientId, $opdId, $abhaId);
+ if ($payload === null) {
+ return $this->response->setJSON(['ok' => 0, 'error_text' => 'No wellness/vital data found for this patient']);
+ }
+
+ return $this->pushAdditionalHiRecord($payload, $patientId, $abhaId, $consentHandle);
+ }
+
+ public function shareHealthDocumentBundle()
+ {
+ if (! $this->request->isAJAX()) {
+ return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $patientId = (int) $this->request->getPost('patient_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) {
+ return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
+ }
+
+ $payload = $this->buildHealthDocumentRecordPayload($patientId, $abhaId);
+ if ($payload === null) {
+ return $this->response->setJSON(['ok' => 0, 'error_text' => 'document_title with document_text/document_base64/file_path is required']);
+ }
+
+ return $this->pushAdditionalHiRecord($payload, $patientId, $abhaId, $consentHandle);
+ }
+
+ public function shareInvoiceBundle()
+ {
+ if (! $this->request->isAJAX()) {
+ return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $invoiceId = (int) $this->request->getPost('invoice_id');
+ $patientId = (int) $this->request->getPost('patient_id');
+ $abhaId = trim((string) ($this->request->getPost('abha_id') ?? $this->request->getPost('abha_address') ?? ''));
+ $consentHandle = trim((string) $this->request->getPost('consent_handle'));
+
+ if ($invoiceId <= 0) {
+ return $this->response->setJSON(['ok' => 0, 'error_text' => 'invoice_id is required']);
+ }
+
+ $payload = $this->buildInvoiceRecordPayload($invoiceId, $patientId, $abhaId);
+ if ($payload === null) {
+ return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invoice not found or patient could not be resolved']);
+ }
+
+ return $this->pushAdditionalHiRecord($payload, (int) $payload['patient_id'], $abhaId, $consentHandle);
+ }
+
public function ipdDischargeFhirPreview()
{
if (! $this->request->isAJAX()) {
@@ -3911,6 +3984,16 @@ class AbdmGateway extends BaseController
$identity['dob'] = $dob;
}
+ $age = preg_replace('/\D/', '', (string) ($this->findFirstByKeys($payload, ['age', 'patient_age', 'patientAge']) ?? ''));
+ if ($age !== '' && preg_match('/^\d{1,3}$/', $age)) {
+ $identity['age'] = $age;
+ }
+
+ $birthYear = preg_replace('/\D/', '', (string) ($this->findFirstByKeys($payload, ['birth_year', 'birthYear', 'year_of_birth', 'yearOfBirth']) ?? ''));
+ if ($birthYear !== '' && preg_match('/^(19|20)\d{2}$/', $birthYear)) {
+ $identity['birth_year'] = $birthYear;
+ }
+
return $identity;
}
@@ -3982,7 +4065,10 @@ class AbdmGateway extends BaseController
$genderDb = ($rawGender === 'F' || $rawGender === '2') ? 2 : ($rawGender !== '' ? 1 : null);
$dob = trim((string) ($identity['dob'] ?? ''));
$dobValid = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob) ? $dob : '';
- $yearOnly = $dobValid !== '' ? (int) substr($dobValid, 0, 4) : 0;
+ $birthYear = trim((string) ($identity['birth_year'] ?? ''));
+ $yearOnly = $dobValid !== '' ? (int) substr($dobValid, 0, 4) : (preg_match('/^(19|20)\d{2}$/', $birthYear) ? (int) $birthYear : 0);
+ $age = trim((string) ($identity['age'] ?? ''));
+ $ageYears = preg_match('/^\d{1,3}$/', $age) ? (int) $age : null;
if ($nameUpper !== '' && $dobValid !== '') {
// Name + exact DOB [+ gender] — high confidence (score 3)
@@ -4014,6 +4100,13 @@ class AbdmGateway extends BaseController
}
}
+ foreach ($this->findFuzzyDemographicMatches($this->db, $select, $nameUpper, $genderDb, $dobValid, $yearOnly, $ageYears) as $row) {
+ $reason = (string) ($row['_match_reason'] ?? 'Similar Name + Demographics');
+ $score = (int) ($row['_match_score'] ?? 2);
+ unset($row['_match_reason'], $row['_match_score']);
+ $append([$row], $reason, $score);
+ }
+
// Phone match — supplementary only (score 1), ambiguous for families
if (($identity['phone'] ?? '') !== '') {
$rows = $this->db->table('patient_master')->select($select)->where('mphone1', $identity['phone'])->limit(10)->get()->getResultArray();
@@ -4041,6 +4134,96 @@ class AbdmGateway extends BaseController
return $result;
}
+ private function findFuzzyDemographicMatches(
+ \CodeIgniter\Database\BaseConnection $db,
+ string $select,
+ string $nameUpper,
+ ?int $genderDb,
+ string $dobValid,
+ int $yearOnly,
+ ?int $ageYears
+ ): array {
+ if ($nameUpper === '' || strlen($this->normalizeMatchName($nameUpper)) < 4) {
+ return [];
+ }
+
+ $fields = $db->getFieldNames('patient_master') ?? [];
+ $queries = [];
+
+ if ($dobValid !== '') {
+ $queries[] = ['reason' => $genderDb !== null ? 'Similar Name + DOB + Gender' : 'Similar Name + DOB', 'score' => 3, 'dob' => $dobValid];
+ }
+ if ($yearOnly > 0) {
+ $queries[] = ['reason' => $genderDb !== null ? 'Similar Name + Year of Birth + Gender' : 'Similar Name + Year of Birth', 'score' => 2, 'year' => $yearOnly];
+ }
+ if ($ageYears !== null && in_array('age', $fields, true)) {
+ $queries[] = ['reason' => $genderDb !== null ? 'Similar Name + Age + Gender' : 'Similar Name + Age', 'score' => 2, 'age' => $ageYears];
+ }
+
+ $matches = [];
+ foreach ($queries as $query) {
+ $builder = $db->table('patient_master')->select($select);
+ if (isset($query['dob'])) {
+ $builder->where('dob', $query['dob']);
+ }
+ if (isset($query['year'])) {
+ $builder->where('YEAR(dob)', (int) $query['year']);
+ }
+ if (isset($query['age'])) {
+ $builder->where('age', (int) $query['age']);
+ }
+ if ($genderDb !== null) {
+ $builder->where('gender', $genderDb);
+ }
+
+ foreach ($builder->limit(100)->get()->getResultArray() as $row) {
+ if (! $this->isSimilarPatientName($nameUpper, (string) ($row['p_fname'] ?? ''))) {
+ continue;
+ }
+ $id = (int) ($row['id'] ?? 0);
+ if ($id <= 0 || isset($matches[$id])) {
+ continue;
+ }
+ $row['_match_reason'] = (string) $query['reason'];
+ $row['_match_score'] = (int) $query['score'];
+ $matches[$id] = $row;
+ }
+ }
+
+ return array_values($matches);
+ }
+
+ private function normalizeMatchName(string $name): string
+ {
+ return preg_replace('/[^A-Z0-9]+/', '', strtoupper($name)) ?? '';
+ }
+
+ private function isSimilarPatientName(string $incomingName, string $storedName): bool
+ {
+ $incoming = $this->normalizeMatchName($incomingName);
+ $stored = $this->normalizeMatchName($storedName);
+ if ($incoming === '' || $stored === '' || $incoming === $stored) {
+ return false;
+ }
+
+ $maxDistance = strlen($incoming) <= 8 ? 1 : (strlen($incoming) <= 14 ? 2 : 3);
+ similar_text($incoming, $stored, $percent);
+ if (levenshtein($incoming, $stored) <= $maxDistance || $percent >= 86.0) {
+ return true;
+ }
+
+ $incomingFirst = $this->normalizeMatchName(strtok($incomingName, ' ') ?: $incomingName);
+ $storedFirst = $this->normalizeMatchName(strtok($storedName, ' ') ?: $storedName);
+ if (strlen($incomingFirst) < 4 || strlen($storedFirst) < 4) {
+ return false;
+ }
+
+ $firstDistance = strlen($incomingFirst) <= 8 ? 1 : 2;
+ similar_text($incomingFirst, $storedFirst, $firstPercent);
+
+ return levenshtein($incomingFirst, $storedFirst) <= $firstDistance || $firstPercent >= 84.0;
+ }
+
private function resolveFirstExistingColumn(array $fields, array $candidates): ?string
{
foreach ($candidates as $candidate) {
@@ -5115,6 +5298,431 @@ class AbdmGateway extends BaseController
};
}
+ private function pushAdditionalHiRecord(array $payload, int $patientId, string $abhaId, string $consentHandle)
+ {
+ $hiType = (string) ($payload['hi_type'] ?? 'HealthDocumentRecord');
+ $entityType = (string) ($payload['entity_type'] ?? strtolower($hiType));
+ $entityId = (string) ($payload['entity_id'] ?? $patientId);
+ $ccRef = (string) ($payload['care_context_reference'] ?? ($hiType . '-' . $patientId . '-' . date('Y-m-d')));
+ $ccDisplay = (string) ($payload['care_context_display'] ?? $hiType);
+ $bundle = (array) ($payload['bundle'] ?? []);
+ $visitDate = (string) ($payload['visit_date'] ?? date('Y-m-d'));
+ $patientName = (string) ($payload['patient_name'] ?? ('PATIENT-' . $patientId));
+ $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ $consent = $abhaId !== '' ? $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle) : null;
+ $consentWarning = '';
+ if ($abhaId !== '' && $consent === null) {
+ $consentWarning = 'No active consent found. Proceeding with care-context push only.';
+ $this->logMissingConsentShareWarning($entityType, (int) $entityId, $patientId, $abhaId, $consentHandle, 'abdm.' . $entityType . '.share.consent_missing', $consentWarning);
+ }
+ $effectiveConsent = (string) ($consent['consent_handle'] ?? $consentHandle);
+
+ $healthRecordId = $this->storeHealthRecord([
+ 'patient_id' => $patientId,
+ 'abha_id' => $abhaId,
+ 'hi_type' => $hiType,
+ 'entity_type' => $entityType,
+ 'entity_id' => $entityId,
+ 'fhir_bundle' => $bundleJson,
+ 'care_context_reference' => $ccRef,
+ 'consent_handle' => $effectiveConsent,
+ ]);
+
+ if ($abhaId === '') {
+ if ($healthRecordId > 0 && $this->db->tableExists('health_records')) {
+ $this->db->table('health_records')->where('id', $healthRecordId)->update([
+ 'push_status' => 'local_only',
+ 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
+ ]);
+ }
+
+ return $this->response->setJSON([
+ 'ok' => 1,
+ 'status' => 'local_stored',
+ 'health_record_id' => $healthRecordId,
+ 'care_context_reference' => $ccRef,
+ 'message' => 'ABHA not available. Record stored locally for later discovery.',
+ ]);
+ }
+
+ $queueId = null;
+ $bridgeRecordId = 0;
+ $connectorError = null;
+ try {
+ $result = $this->connector->pushRecord([
+ 'patient_id' => (string) $patientId,
+ 'patient_name' => $patientName,
+ 'abha_id' => preg_match('/^\d{14}$/', $abhaId) === 1 ? $abhaId : '',
+ 'abha_address' => str_contains($abhaId, '@') ? $abhaId : '',
+ 'hi_type' => $hiType,
+ 'record_type' => $hiType,
+ 'visit_date' => $visitDate,
+ 'care_context_reference' => $ccRef,
+ 'care_context_display' => $ccDisplay,
+ 'notes' => $ccDisplay,
+ 'queue_id' => $ccRef,
+ 'record_data' => $bundle,
+ ]);
+ $queueId = (string) ($result['queue_id'] ?? $result['existing_queue_id'] ?? '');
+ $bridgeRecordId = (int) ($result['record_id'] ?? $result['id'] ?? $result['existing_record_id'] ?? 0);
+ $httpCode = (int) ($result['http_code'] ?? 0);
+ $errorCode = strtoupper(trim((string) ($result['error_code'] ?? '')));
+ if ((int) ($result['ok'] ?? 0) !== 1 && $httpCode !== 409 && $errorCode !== 'DUPLICATE_RECORD') {
+ $connectorError = trim((string) ($result['message'] ?? $result['error_text'] ?? $errorCode));
+ if ($connectorError === '') {
+ $connectorError = 'Bridge push failed';
+ }
+ }
+ } catch (\Throwable $e) {
+ $connectorError = $e->getMessage();
+ }
+
+ if ($healthRecordId > 0) {
+ $this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError, $bridgeRecordId);
+ }
+
+ $this->getAuditService()->log([
+ 'action' => 'push_record',
+ 'entity_type' => $entityType,
+ 'entity_id' => $entityId,
+ 'abha_id' => $abhaId,
+ 'patient_id' => $patientId,
+ 'request' => ['hi_type' => $hiType, 'care_context_reference' => $ccRef, 'consent_handle' => $effectiveConsent],
+ 'response' => ['queue_id' => $queueId, 'bridge_record_id' => $bridgeRecordId > 0 ? $bridgeRecordId : null, 'warning' => $consentWarning],
+ 'outcome' => $connectorError === null ? 'success' : 'failure',
+ 'error_message' => (string) ($connectorError ?? ''),
+ ]);
+
+ return $this->response->setJSON([
+ 'ok' => $connectorError === null ? 1 : 0,
+ 'queue_id' => $queueId,
+ 'bridge_record_id' => $bridgeRecordId > 0 ? $bridgeRecordId : null,
+ 'health_record_id' => $healthRecordId,
+ 'care_context_reference' => $ccRef,
+ 'consent_handle' => $effectiveConsent,
+ 'status' => $connectorError === null ? 'queued' : 'failed',
+ 'message' => $connectorError === null && $consentWarning !== '' ? 'Record pushed to gateway without active consent. Recheck link status in M2 ABDM Gateway.' : null,
+ 'warning' => $consentWarning !== '' ? $consentWarning : null,
+ 'error' => $connectorError,
+ ]);
+ }
+
+ private function buildWellnessRecordPayload(int $patientId, int $opdId, string $abhaId): ?array
+ {
+ $patientRow = $this->loadPatientRow($patientId);
+ if (empty($patientRow)) {
+ return null;
+ }
+
+ $row = [];
+ 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("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() ?? [];
+ }
+
+ $vitals = $this->extractWellnessVitals($row);
+ $lifestyle = $this->extractPatientLifestyleObservations($patientRow);
+ if (empty($vitals) && empty($lifestyle)) {
+ return null;
+ }
+
+ $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));
+
+ return [
+ 'hi_type' => 'WellnessRecord',
+ 'entity_type' => 'wellness',
+ '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)),
+ 'bundle' => $bundle,
+ ];
+ }
+
+ private function buildHealthDocumentRecordPayload(int $patientId, string $abhaId): ?array
+ {
+ $patientRow = $this->loadPatientRow($patientId);
+ if (empty($patientRow)) {
+ return null;
+ }
+
+ $title = trim((string) ($this->request->getPost('document_title') ?? $this->request->getPost('title') ?? ''));
+ $documentText = trim((string) ($this->request->getPost('document_text') ?? $this->request->getPost('notes') ?? ''));
+ $contentType = trim((string) ($this->request->getPost('content_type') ?? ''));
+ $base64 = trim((string) ($this->request->getPost('document_base64') ?? ''));
+ $filePath = trim((string) ($this->request->getPost('file_path') ?? $this->request->getPost('attachment_path') ?? ''));
+ if ($title === '') {
+ $title = 'Health Document';
+ }
+ if ($base64 === '' && $documentText !== '') {
+ $contentType = 'text/html';
+ $base64 = base64_encode('
' . esc($documentText) . '
');
+ }
+ if ($base64 === '' && $filePath !== '') {
+ $file = $this->resolveReadableAttachmentPath($filePath);
+ if ($file !== '') {
+ $base64 = base64_encode((string) file_get_contents($file));
+ $contentType = $contentType !== '' ? $contentType : ((string) (mime_content_type($file) ?: 'application/octet-stream'));
+ }
+ }
+ if ($base64 === '') {
+ return null;
+ }
+ $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;
+ $patient = $this->buildAbdmPatientResource($patientRow, $patientId, $abhaId);
+ $bundle = $this->buildSimpleHealthDocumentBundle($patient, $title, $contentType, $base64);
+
+ return [
+ 'hi_type' => 'HealthDocumentRecord',
+ 'entity_type' => 'health_document',
+ 'entity_id' => $entityId,
+ 'patient_id' => $patientId,
+ 'patient_name' => $this->patientDisplayName($patientRow),
+ 'visit_date' => $visitDate,
+ 'care_context_reference' => 'HDOC-' . $patientId . '-' . $entityId . '-' . $visitDate,
+ 'care_context_display' => $title,
+ 'bundle' => $bundle,
+ ];
+ }
+
+ private function buildInvoiceRecordPayload(int $invoiceId, int $patientId, string $abhaId): ?array
+ {
+ if (! $this->db->tableExists('invoice_master')) {
+ return null;
+ }
+ $invoice = $this->db->table('invoice_master')->where('id', $invoiceId)->get(1)->getRowArray() ?? [];
+ if (empty($invoice)) {
+ return null;
+ }
+ if ($patientId <= 0) {
+ $patientId = (int) ($invoice['attach_id'] ?? 0);
+ }
+ $patientRow = $this->loadPatientRow($patientId);
+ if ($patientId <= 0 || empty($patientRow)) {
+ return null;
+ }
+ $items = $this->db->tableExists('invoice_item')
+ ? $this->db->table('invoice_item')->where('inv_master_id', $invoiceId)->orderBy('id', 'ASC')->get()->getResultArray()
+ : [];
+
+ $visitDate = (string) ($invoice['inv_date'] ?? date('Y-m-d'));
+ $patient = $this->buildAbdmPatientResource($patientRow, $patientId, $abhaId);
+ $bundle = $this->buildSimpleInvoiceBundle($patient, $invoice, $items);
+
+ return [
+ 'hi_type' => 'InvoiceRecord',
+ 'entity_type' => 'invoice',
+ 'entity_id' => (string) $invoiceId,
+ 'patient_id' => $patientId,
+ 'patient_name' => $this->patientDisplayName($patientRow),
+ 'visit_date' => date('Y-m-d', strtotime($visitDate)),
+ 'care_context_reference' => 'INV-' . $invoiceId . '-' . date('Y-m-d', strtotime($visitDate)),
+ 'care_context_display' => 'Invoice ' . (string) ($invoice['invoice_code'] ?? $invoiceId),
+ 'bundle' => $bundle,
+ ];
+ }
+
+ private function loadPatientRow(int $patientId): array
+ {
+ if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) {
+ return [];
+ }
+ return $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? [];
+ }
+
+ private function patientDisplayName(array $patientRow): string
+ {
+ return trim(trim((string) ($patientRow['p_fname'] ?? '')) . ' ' . trim((string) ($patientRow['p_lname'] ?? ''))) ?: 'Patient';
+ }
+
+ private function buildAbdmPatientResource(array $patientRow, int $patientId, string $abhaId): array
+ {
+ $genderRaw = trim((string) ($patientRow['gender'] ?? ''));
+ $gender = match ($genderRaw) {
+ '1', 'M', 'm', 'male', 'Male' => 'male',
+ '2', 'F', 'f', 'female', 'Female' => 'female',
+ default => 'unknown',
+ };
+ $patient = [
+ 'resourceType' => 'Patient',
+ 'id' => 'patient-' . $patientId,
+ 'name' => [['text' => $this->patientDisplayName($patientRow)]],
+ 'gender' => $gender,
+ ];
+ if (! empty($patientRow['dob'])) {
+ $patient['birthDate'] = date('Y-m-d', strtotime((string) $patientRow['dob']));
+ }
+ if ($abhaId !== '') {
+ $patient['identifier'] = [[
+ 'system' => str_contains($abhaId, '@') ? 'https://healthid.ndhm.gov.in/abha-address' : 'https://healthid.ndhm.gov.in',
+ 'value' => $abhaId,
+ ]];
+ }
+ return $patient;
+ }
+
+ private function extractWellnessVitals(array $row): array
+ {
+ if (empty($row)) {
+ return [];
+ }
+ $vitals = [];
+ $add = static function (string $code, string $display, mixed $value, string $unit, string $ucum) use (&$vitals): void {
+ if ($value === null || trim((string) $value) === '' || ! is_numeric($value)) {
+ return;
+ }
+ $vitals[] = ['loinc_code' => $code, 'display' => $display, 'value' => (float) $value, 'unit' => $unit, 'ucum_code' => $ucum];
+ };
+ $add('8480-6', 'Systolic blood pressure', $row['bp'] ?? null, 'mmHg', 'mm[Hg]');
+ $add('8462-4', 'Diastolic blood pressure', $row['diastolic'] ?? null, 'mmHg', 'mm[Hg]');
+ $add('8867-4', 'Heart rate', $row['pulse'] ?? null, '/min', '/min');
+ $add('8302-2', 'Body height', $row['height'] ?? null, 'cm', 'cm');
+ $add('29463-7', 'Body weight', $row['weight'] ?? null, 'kg', 'kg');
+ $temp = $row['temp'] ?? null;
+ if ($temp !== null && is_numeric($temp) && (float) $temp > 45) {
+ $temp = (((float) $temp - 32) * 5) / 9;
+ }
+ $add('8310-5', 'Body temperature', $temp, 'Cel', 'Cel');
+ $add('9279-1', 'Respiratory rate', $row['rr_min'] ?? null, '/min', '/min');
+ $add('59408-5', 'Oxygen saturation in Arterial blood by Pulse oximetry', $row['spo2'] ?? null, '%', '%');
+ $add('2339-0', 'Glucose', $row['glucose'] ?? null, 'mg/dL', 'mg/dL');
+ return $vitals;
+ }
+
+ private function extractPatientLifestyleObservations(array $patientRow): array
+ {
+ $map = [
+ 'is_smoking' => 'Smoking status',
+ 'is_alcohol' => 'Alcohol use',
+ 'is_drug_abuse' => 'Drug abuse status',
+ 'is_tobacoo' => 'Tobacco use',
+ 'is_hypertesion' => 'Hypertension history',
+ 'is_niddm' => 'Diabetes history',
+ ];
+ $items = [];
+ foreach ($map as $field => $label) {
+ $value = trim((string) ($patientRow[$field] ?? ''));
+ if ($value !== '' && $value !== '0') {
+ $items[] = ['display' => $label, 'value' => 'Yes'];
+ }
+ }
+ if (trim((string) ($patientRow['Others'] ?? '')) !== '') {
+ $items[] = ['display' => 'Other wellness notes', 'value' => trim((string) $patientRow['Others'])];
+ }
+ return $items;
+ }
+
+ private function buildSimpleWellnessBundle(array $patient, array $vitals, array $lifestyle): array
+ {
+ $issuedAt = Time::now('Asia/Kolkata')->format(DATE_ATOM);
+ $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'patient-unknown');
+ $entries = [];
+ $sectionEntries = [];
+ $index = 1;
+ foreach ($vitals as $vital) {
+ $id = 'wellness-obs-' . $index++;
+ $sectionEntries[] = ['reference' => 'Observation/' . $id];
+ $entries[] = ['resource' => [
+ 'resourceType' => 'Observation',
+ 'id' => $id,
+ 'status' => 'final',
+ 'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/observation-category', 'code' => 'vital-signs', 'display' => 'Vital Signs']]]],
+ 'code' => ['coding' => [['system' => 'http://loinc.org', 'code' => (string) $vital['loinc_code'], 'display' => (string) $vital['display']]], 'text' => (string) $vital['display']],
+ 'subject' => ['reference' => $patientRef],
+ 'effectiveDateTime' => $issuedAt,
+ 'valueQuantity' => ['value' => (float) $vital['value'], 'unit' => (string) $vital['unit'], 'system' => 'http://unitsofmeasure.org', 'code' => (string) $vital['ucum_code']],
+ ]];
+ }
+ foreach ($lifestyle as $item) {
+ $id = 'wellness-social-' . $index++;
+ $sectionEntries[] = ['reference' => 'Observation/' . $id];
+ $entries[] = ['resource' => [
+ 'resourceType' => 'Observation',
+ 'id' => $id,
+ 'status' => 'final',
+ 'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/observation-category', 'code' => 'social-history', 'display' => 'Social History']]]],
+ 'code' => ['text' => (string) ($item['display'] ?? 'Lifestyle')],
+ 'subject' => ['reference' => $patientRef],
+ 'effectiveDateTime' => $issuedAt,
+ 'valueString' => (string) ($item['value'] ?? ''),
+ ]];
+ }
+ $composition = ['resource' => [
+ 'resourceType' => 'Composition',
+ 'id' => 'composition-wellness-' . date('YmdHis'),
+ 'status' => 'final',
+ 'type' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '371529009', 'display' => 'Health maintenance report']]],
+ 'subject' => ['reference' => $patientRef],
+ 'date' => $issuedAt,
+ 'title' => 'Wellness Record',
+ 'section' => [['title' => 'Vital Signs and Wellness', 'entry' => $sectionEntries]],
+ ]];
+ return ['resourceType' => 'Bundle', 'type' => 'document', 'timestamp' => $issuedAt, 'entry' => array_merge([$composition, ['resource' => $patient]], $entries)];
+ }
+
+ private function buildSimpleHealthDocumentBundle(array $patient, string $title, string $contentType, string $base64): array
+ {
+ $issuedAt = Time::now('Asia/Kolkata')->format(DATE_ATOM);
+ $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'patient-unknown');
+ $binaryId = 'binary-health-doc-' . date('YmdHis');
+ $docId = 'doc-health-' . date('YmdHis');
+ $composition = ['resource' => ['resourceType' => 'Composition', 'id' => 'composition-health-doc-' . date('YmdHis'), 'status' => 'final', 'type' => ['text' => 'Health Document Record'], 'subject' => ['reference' => $patientRef], 'date' => $issuedAt, 'title' => $title, 'section' => [['title' => $title, 'entry' => [['reference' => 'DocumentReference/' . $docId]]]]]];
+ $binary = ['resource' => ['resourceType' => 'Binary', 'id' => $binaryId, 'contentType' => $contentType, 'data' => $base64]];
+ $docRef = ['resource' => ['resourceType' => 'DocumentReference', 'id' => $docId, 'status' => 'current', 'type' => ['text' => $title], 'subject' => ['reference' => $patientRef], 'date' => $issuedAt, 'content' => [['attachment' => ['contentType' => $contentType, 'url' => 'Binary/' . $binaryId, 'title' => $title]]]]];
+ return ['resourceType' => 'Bundle', 'type' => 'document', 'timestamp' => $issuedAt, 'entry' => [$composition, ['resource' => $patient], $binary, $docRef]];
+ }
+
+ private function buildSimpleInvoiceBundle(array $patient, array $invoice, array $items): array
+ {
+ $issuedAt = Time::now('Asia/Kolkata')->format(DATE_ATOM);
+ $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'patient-unknown');
+ $invoiceId = 'invoice-' . (int) ($invoice['id'] ?? 0);
+ $lineItems = [];
+ foreach ($items as $idx => $item) {
+ $lineItems[] = ['sequence' => $idx + 1, 'chargeItemCodeableConcept' => ['text' => (string) ($item['item_name'] ?? 'Item')], 'priceComponent' => [['type' => 'base', 'amount' => ['value' => (float) ($item['item_amount'] ?? 0), 'currency' => 'INR']]]];
+ }
+ $invoiceResource = ['resourceType' => 'Invoice', 'id' => $invoiceId, 'status' => 'issued', 'subject' => ['reference' => $patientRef], 'date' => ! empty($invoice['inv_date']) ? date('Y-m-d', strtotime((string) $invoice['inv_date'])) : date('Y-m-d'), 'totalNet' => ['value' => (float) ($invoice['net_amount'] ?? $invoice['total_amount'] ?? 0), 'currency' => 'INR'], 'totalGross' => ['value' => (float) ($invoice['total_amount'] ?? $invoice['net_amount'] ?? 0), 'currency' => 'INR']];
+ if (! empty($lineItems)) {
+ $invoiceResource['lineItem'] = $lineItems;
+ }
+ $composition = ['resource' => ['resourceType' => 'Composition', 'id' => 'composition-invoice-' . (int) ($invoice['id'] ?? 0), 'status' => 'final', 'type' => ['text' => 'Invoice Record'], 'subject' => ['reference' => $patientRef], 'date' => $issuedAt, 'title' => 'Invoice ' . (string) ($invoice['invoice_code'] ?? $invoice['id'] ?? ''), 'section' => [['title' => 'Invoice details', 'entry' => [['reference' => 'Invoice/' . $invoiceId]]]]]];
+ return ['resourceType' => 'Bundle', 'type' => 'document', 'timestamp' => $issuedAt, 'entry' => [$composition, ['resource' => $patient], ['resource' => $invoiceResource]]];
+ }
+
+ 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)) {
+ return '';
+ }
+ $allowed = array_filter([realpath(FCPATH), realpath(WRITEPATH)]);
+ foreach ($allowed as $root) {
+ if (str_starts_with($real, (string) $root)) {
+ return $real;
+ }
+ }
+ return '';
+ }
+
private function getActiveConsentRecord(int $patientId, string $abhaId, string $consentHandle = ''): ?array
{
$hasAbdmConsentTable = $this->db->tableExists('abdm_consent_records');
@@ -5185,6 +5793,68 @@ class AbdmGateway extends BaseController
return null;
}
+ private function logMissingConsentShareBlock(string $entityType, int $entityId, int $patientId, string $abhaId, string $consentHandle, string $eventType): void
+ {
+ if (! $this->db->tableExists('abdm_api_logs')) {
+ return;
+ }
+
+ $message = 'No active consent found. Share blocked due to expiry/not-approved consent.';
+ $this->db->table('abdm_api_logs')->insert([
+ 'channel' => 'bridge',
+ 'event_type' => $eventType,
+ 'endpoint' => '/AbdmGateway/share_' . $entityType,
+ 'http_method' => 'POST',
+ 'entity_type' => $entityType,
+ 'entity_id' => (string) $entityId,
+ 'request_json' => (string) json_encode([
+ 'entity_type' => $entityType,
+ 'entity_id' => $entityId,
+ 'patient_id' => $patientId,
+ 'abha_id' => $abhaId,
+ 'consent_handle' => $consentHandle,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'response_code' => 200,
+ 'response_json' => (string) json_encode([
+ 'ok' => 0,
+ 'error_text' => $message,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'status' => 'error',
+ 'error_message' => $message,
+ 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
+ ]);
+ }
+
+ private function logMissingConsentShareWarning(string $entityType, int $entityId, int $patientId, string $abhaId, string $consentHandle, string $eventType, string $message): void
+ {
+ if (! $this->db->tableExists('abdm_api_logs')) {
+ return;
+ }
+
+ $this->db->table('abdm_api_logs')->insert([
+ 'channel' => 'bridge',
+ 'event_type' => $eventType,
+ 'endpoint' => '/AbdmGateway/share_' . $entityType,
+ 'http_method' => 'POST',
+ 'entity_type' => $entityType,
+ 'entity_id' => (string) $entityId,
+ 'request_json' => json_encode([
+ 'patient_id' => $patientId,
+ 'abha_id' => $abhaId,
+ 'consent_handle' => $consentHandle,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'response_json' => json_encode([
+ 'ok' => 1,
+ 'note' => $message,
+ 'care_context_push_only' => 1,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'response_code' => 200,
+ 'status' => 'warning',
+ 'error_message' => $message,
+ 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
+ ]);
+ }
+
/**
* Pick the consent id expected by gateway: consent_id/gateway_consent_id fallback to consent_handle.
*
diff --git a/app/Controllers/AbdmOpdQueue.php b/app/Controllers/AbdmOpdQueue.php
index 0b6555d..a7c6859 100644
--- a/app/Controllers/AbdmOpdQueue.php
+++ b/app/Controllers/AbdmOpdQueue.php
@@ -220,6 +220,23 @@ class AbdmOpdQueue extends BaseController
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
+ if (empty($result['ok']) || (int) $result['ok'] !== 1) {
+ $message = (string) (
+ $result['error_text']
+ ?? $result['message']
+ ?? $result['error']
+ ?? 'Bridge rejected OPD token status update'
+ );
+
+ return $this->response
+ ->setStatusCode((int) ($result['http_code'] ?? 502) ?: 502)
+ ->setJSON([
+ 'ok' => 0,
+ 'error_text' => $message,
+ 'bridge_http_code' => (int) ($result['http_code'] ?? 0),
+ ]);
+ }
+
// Mirror status to local DB so list() stays consistent
try {
$db->table('abdm_opd_tokens')
@@ -285,6 +302,8 @@ class AbdmOpdQueue extends BaseController
$phone = trim((string) ($this->request->getPost('phone') ?? ''));
$gender = strtoupper(trim((string) ($this->request->getPost('gender') ?? '')));
$dob = trim((string) ($this->request->getPost('dob') ?? '')); // YYYY-MM-DD from gateway
+ $age = trim((string) ($this->request->getPost('age') ?? $this->request->getPost('patient_age') ?? ''));
+ $birthYear = trim((string) ($this->request->getPost('birth_year') ?? $this->request->getPost('birthYear') ?? $this->request->getPost('year_of_birth') ?? ''));
$relationText = trim((string) ($this->request->getPost('relation_text') ?? ''));
$relationType = trim((string) ($this->request->getPost('relation_type') ?? ''));
$relationName = trim((string) ($this->request->getPost('relative_name') ?? ''));
@@ -307,7 +326,7 @@ class AbdmOpdQueue extends BaseController
$abhaField = $this->resolveFirstExistingColumn($fields, ['abha_id', 'abha_no', 'abha', 'abha_address']);
$aadhaarField = $this->resolveFirstExistingColumn($fields, ['udai', 'aadhar_no', 'aadhaar_no', 'aadhaar', 'adhar_no']);
- $matches = $this->findPatientMatches($db, $abhaField, $aadhaarField, $abhaRaw, $abhaAddr, $aadhaarRaw, $phone, $name, $gender, $dob);
+ $matches = $this->findPatientMatches($db, $abhaField, $aadhaarField, $abhaRaw, $abhaAddr, $aadhaarRaw, $phone, $name, $gender, $dob, $age, $birthYear);
if ($action === 'check') {
return $this->response->setJSON([
@@ -323,6 +342,8 @@ class AbdmOpdQueue extends BaseController
'phone' => $phone,
'gender' => $gender,
'dob' => $dob,
+ 'age' => $age,
+ 'birth_year' => $birthYear,
'relation_text' => $relationText,
'relation_type' => $relationType,
'relative_name' => $relationName,
@@ -350,6 +371,8 @@ class AbdmOpdQueue extends BaseController
'phone' => $phone,
'gender' => $gender,
'dob' => $dob,
+ 'age' => $age,
+ 'birth_year' => $birthYear,
'relation_text' => $relationText,
'relation_type' => $relationType,
'relative_name' => $relationName,
@@ -396,7 +419,7 @@ class AbdmOpdQueue extends BaseController
if (! empty($backfill)) {
$db->table('patient_master')->where('id', $patientId)->update($backfill);
}
- } elseif (($action === '' || $action === 'auto') && count($matches) === 1) {
+ } elseif (($action === '' || $action === 'auto') && count($matches) === 1 && (int) ($matches[0]['match_score'] ?? 0) > 1) {
$existing = $matches[0];
$patientId = (int) ($existing['id'] ?? 0);
$pCode = (string) ($existing['p_code'] ?? '');
@@ -441,21 +464,14 @@ class AbdmOpdQueue extends BaseController
$this->attachPatientToToken($db, $tokenId, $patientId);
- // Mark token as CALLED on gateway (non-blocking)
- try {
- $this->gw->opdTokenUpdateStatus($tokenId, 'CALLED');
- } catch (\Throwable $e) {
- // non-critical
- }
-
$oldStatus = strtoupper(trim((string) ($tokenBefore['status'] ?? '')));
- if ($oldStatus !== 'CALLED') {
+ if ($oldStatus !== '') {
$this->logTokenAudit(
'abdm_opd_queue',
(string) $tokenId,
- 'status',
- $oldStatus,
- 'CALLED',
+ 'patient_id',
+ (string) ($tokenBefore['patient_id'] ?? ''),
+ (string) $patientId,
[
'source' => 'register_opd',
'action' => $action,
@@ -497,7 +513,9 @@ class AbdmOpdQueue extends BaseController
string $phone,
string $name = '',
string $gender = '',
- string $dob = ''
+ string $dob = '',
+ string $age = '',
+ string $birthYear = ''
): array {
$select = 'id,p_code,p_fname,mphone1,dob,gender';
if ($abhaField) {
@@ -548,7 +566,8 @@ class AbdmOpdQueue extends BaseController
$nameUpper = strtoupper(trim($name));
$genderDb = ($gender === 'F' || $gender === '2') ? 2 : ($gender !== '' ? 1 : null);
$dobValid = preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob) ? $dob : '';
- $yearOnly = $dobValid !== '' ? (int) substr($dobValid, 0, 4) : 0;
+ $yearOnly = $dobValid !== '' ? (int) substr($dobValid, 0, 4) : (preg_match('/^(19|20)\d{2}$/', $birthYear) ? (int) $birthYear : 0);
+ $ageYears = preg_match('/^\d{1,3}$/', $age) ? (int) $age : null;
if ($nameUpper !== '' && $dobValid !== '') {
// Name + exact DOB [+ gender] — high confidence (score 3)
@@ -580,6 +599,13 @@ class AbdmOpdQueue extends BaseController
}
}
+ foreach ($this->findFuzzyDemographicMatches($db, $select, $nameUpper, $genderDb, $dobValid, $yearOnly, $ageYears) as $row) {
+ $reason = (string) ($row['_match_reason'] ?? 'Similar Name + Demographics');
+ $score = (int) ($row['_match_score'] ?? 2);
+ unset($row['_match_reason'], $row['_match_score']);
+ $append([$row], $reason, $score);
+ }
+
// Phone match — supplementary only (score 1), ambiguous for families
if ($phone !== '') {
$rows = $db->table('patient_master')->select($select)->where('mphone1', $phone)->limit(10)->get()->getResultArray();
@@ -607,6 +633,96 @@ class AbdmOpdQueue extends BaseController
return $result;
}
+ private function findFuzzyDemographicMatches(
+ \CodeIgniter\Database\BaseConnection $db,
+ string $select,
+ string $nameUpper,
+ ?int $genderDb,
+ string $dobValid,
+ int $yearOnly,
+ ?int $ageYears
+ ): array {
+ if ($nameUpper === '' || strlen($this->normalizeMatchName($nameUpper)) < 4) {
+ return [];
+ }
+
+ $fields = $db->getFieldNames('patient_master') ?? [];
+ $queries = [];
+
+ if ($dobValid !== '') {
+ $queries[] = ['reason' => $genderDb !== null ? 'Similar Name + DOB + Gender' : 'Similar Name + DOB', 'score' => 3, 'dob' => $dobValid];
+ }
+ if ($yearOnly > 0) {
+ $queries[] = ['reason' => $genderDb !== null ? 'Similar Name + Year of Birth + Gender' : 'Similar Name + Year of Birth', 'score' => 2, 'year' => $yearOnly];
+ }
+ if ($ageYears !== null && in_array('age', $fields, true)) {
+ $queries[] = ['reason' => $genderDb !== null ? 'Similar Name + Age + Gender' : 'Similar Name + Age', 'score' => 2, 'age' => $ageYears];
+ }
+
+ $matches = [];
+ foreach ($queries as $query) {
+ $builder = $db->table('patient_master')->select($select);
+ if (isset($query['dob'])) {
+ $builder->where('dob', $query['dob']);
+ }
+ if (isset($query['year'])) {
+ $builder->where('YEAR(dob)', (int) $query['year']);
+ }
+ if (isset($query['age'])) {
+ $builder->where('age', (int) $query['age']);
+ }
+ if ($genderDb !== null) {
+ $builder->where('gender', $genderDb);
+ }
+
+ foreach ($builder->limit(100)->get()->getResultArray() as $row) {
+ if (! $this->isSimilarPatientName($nameUpper, (string) ($row['p_fname'] ?? ''))) {
+ continue;
+ }
+ $id = (int) ($row['id'] ?? 0);
+ if ($id <= 0 || isset($matches[$id])) {
+ continue;
+ }
+ $row['_match_reason'] = (string) $query['reason'];
+ $row['_match_score'] = (int) $query['score'];
+ $matches[$id] = $row;
+ }
+ }
+
+ return array_values($matches);
+ }
+
+ private function normalizeMatchName(string $name): string
+ {
+ return preg_replace('/[^A-Z0-9]+/', '', strtoupper($name)) ?? '';
+ }
+
+ private function isSimilarPatientName(string $incomingName, string $storedName): bool
+ {
+ $incoming = $this->normalizeMatchName($incomingName);
+ $stored = $this->normalizeMatchName($storedName);
+ if ($incoming === '' || $stored === '' || $incoming === $stored) {
+ return false;
+ }
+
+ $maxDistance = strlen($incoming) <= 8 ? 1 : (strlen($incoming) <= 14 ? 2 : 3);
+ similar_text($incoming, $stored, $percent);
+ if (levenshtein($incoming, $stored) <= $maxDistance || $percent >= 86.0) {
+ return true;
+ }
+
+ $incomingFirst = $this->normalizeMatchName(strtok($incomingName, ' ') ?: $incomingName);
+ $storedFirst = $this->normalizeMatchName(strtok($storedName, ' ') ?: $storedName);
+ if (strlen($incomingFirst) < 4 || strlen($storedFirst) < 4) {
+ return false;
+ }
+
+ $firstDistance = strlen($incomingFirst) <= 8 ? 1 : 2;
+ similar_text($incomingFirst, $storedFirst, $firstPercent);
+
+ return levenshtein($incomingFirst, $storedFirst) <= $firstDistance || $firstPercent >= 84.0;
+ }
+
private function createPatientFromToken(
\CodeIgniter\Database\BaseConnection $db,
?string $abhaField,
@@ -711,7 +827,6 @@ class AbdmOpdQueue extends BaseController
->where('gateway_token_id', $tokenId)
->update([
'patient_id' => $patientId,
- 'status' => 'CALLED',
'processed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
diff --git a/app/Controllers/Abha.php b/app/Controllers/Abha.php
index 5c9ad1a..ea09f60 100644
--- a/app/Controllers/Abha.php
+++ b/app/Controllers/Abha.php
@@ -95,7 +95,7 @@ class Abha extends BaseController
?: ($payload['abha_address'] ?? '')
?: ''
);
- $name = (string) ($profile['name'] ?? $profile['fullName'] ?? $profile['full_name'] ?? $payload['name'] ?? $payload['full_name'] ?? '');
+ $name = $this->extractAbhaProfileName($profile, $payload);
$photo = (string) ($profile['profilePhoto'] ?? $profile['profile_photo'] ?? $payload['profilePhoto'] ?? $payload['profile_photo'] ?? '');
$mobile = (string) ($profile['mobile'] ?? $payload['mobile'] ?? $payload['mobileNumber'] ?? '');
$profileGender = (string) ($profile['gender'] ?? $payload['gender'] ?? '');
@@ -225,7 +225,7 @@ class Abha extends BaseController
?: ($payload['abha_address'] ?? '')
?: ''
);
- $name = (string) ($profile['name'] ?? $profile['fullName'] ?? $profile['full_name'] ?? $payload['name'] ?? $payload['full_name'] ?? '');
+ $name = $this->extractAbhaProfileName($profile, $payload);
$photo = (string) ($profile['profilePhoto'] ?? $profile['profile_photo'] ?? $payload['profilePhoto'] ?? $payload['profile_photo'] ?? '');
$gender = (string) ($profile['gender'] ?? $payload['gender'] ?? '');
$dob = (string) ($profile['dob'] ?? $profile['date_of_birth'] ?? $payload['dob'] ?? $payload['date_of_birth'] ?? '');
@@ -289,6 +289,41 @@ class Abha extends BaseController
return $this->response->setJSON(['ok' => 1, 'message' => 'ABHA created successfully.']);
}
+ // -------------------------------------------------------------------------
+ // Facility Scan & Share QR — GET abha/register/facility_qr
+ // -------------------------------------------------------------------------
+ public function facilityQr()
+ {
+ if (! $this->request->isAJAX()) {
+ return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ try {
+ /** @var \App\Libraries\Abdm\AbdmConnectorInterface $connector */
+ $connector = AbdmConnectorFactory::make();
+ $result = $connector->facilityQrCode();
+ } catch (\Throwable $e) {
+ return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
+ }
+
+ if (empty($result['ok']) || (int) $result['ok'] !== 1) {
+ return $this->response->setStatusCode((int) ($result['http_code'] ?? 502) ?: 502)->setJSON([
+ 'ok' => 0,
+ 'error_text' => $result['error_text'] ?? $result['message'] ?? $result['error'] ?? 'Unable to fetch facility QR code',
+ 'bridge_http_code' => (int) ($result['http_code'] ?? 0),
+ ]);
+ }
+
+ return $this->response->setJSON([
+ 'ok' => 1,
+ 'hospital_id' => $result['hospital_id'] ?? $result['data']['hospital_id'] ?? '',
+ 'hospital_name' => $result['hospital_name'] ?? $result['data']['hospital_name'] ?? '',
+ 'hfr_id' => $result['hfr_id'] ?? $result['data']['hfr_id'] ?? '',
+ 'qr_data' => $result['qr_data'] ?? $result['data']['qr_data'] ?? '',
+ 'request_id' => $result['request_id'] ?? '',
+ ]);
+ }
+
// -------------------------------------------------------------------------
// ABHA Card view — GET abha/card/{abha_number}
// Renders a printable card page for a patient whose ABHA is stored in HMS.
@@ -391,11 +426,20 @@ class Abha extends BaseController
if ($existing) {
$patientId = (int) ($existing['id'] ?? 0);
$pCode = (string) ($existing['p_code'] ?? '');
+ $existingUpdates = [];
+
+ $existingName = strtoupper(trim((string) ($existing['p_fname'] ?? '')));
+ if ($name !== '' && ($existingName === '' || $existingName === 'ABHA PATIENT')) {
+ $existingUpdates['p_fname'] = strtoupper($name);
+ }
+
// Backfill ABHA field if it was empty
if ($abhaField && empty($existing[$abhaField]) && $abhaNumClean !== '') {
- $db->table('patient_master')
- ->where('id', $patientId)
- ->update([$abhaField => $abhaNumClean]);
+ $existingUpdates[$abhaField] = $abhaNumClean;
+ }
+
+ if ($existingUpdates !== []) {
+ $db->table('patient_master')->where('id', $patientId)->update($existingUpdates);
}
$this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields);
@@ -687,12 +731,46 @@ class Abha extends BaseController
return [];
}
+ /**
+ * @param array $profile
+ * @param array $payload
+ */
+ private function extractAbhaProfileName(array $profile, array $payload): string
+ {
+ foreach ([$profile, $payload, $payload['gateway_abha_profile'] ?? [], $payload['gateway_patient'] ?? []] as $source) {
+ if (! is_array($source)) {
+ continue;
+ }
+
+ foreach (['name', 'fullName', 'full_name', 'patient_name'] as $key) {
+ $name = trim((string) ($source[$key] ?? ''));
+ if ($name !== '') {
+ return $name;
+ }
+ }
+
+ $parts = [];
+ foreach (['firstName', 'first_name', 'middleName', 'middle_name', 'lastName', 'last_name'] as $key) {
+ $part = trim((string) ($source[$key] ?? ''));
+ if ($part !== '') {
+ $parts[] = $part;
+ }
+ }
+
+ if ($parts !== []) {
+ return implode(' ', $parts);
+ }
+ }
+
+ return '';
+ }
+
/**
* @param array $profile
*/
private function looksLikeGatewayAbhaProfile(array $profile): bool
{
- foreach (['ABHANumber', 'abha_id', 'preferredAddress', 'preferredAbhaAddress', 'abha_address', 'name', 'fullName', 'full_name', 'gender', 'dob', 'date_of_birth', 'profilePhoto', 'profile_photo'] as $key) {
+ foreach (['ABHANumber', 'abha_id', 'preferredAddress', 'preferredAbhaAddress', 'abha_address', 'name', 'fullName', 'full_name', 'patient_name', 'firstName', 'first_name', 'lastName', 'last_name', 'gender', 'dob', 'date_of_birth', 'profilePhoto', 'profile_photo'] as $key) {
if (array_key_exists($key, $profile) && trim((string) $profile[$key]) !== '') {
return true;
}
diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php
index c6e1a93..23f32a7 100644
--- a/app/Controllers/Opd_prescription.php
+++ b/app/Controllers/Opd_prescription.php
@@ -6193,6 +6193,7 @@ class Opd_prescription extends BaseController
$out = [];
$seen = [];
$needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
+ $hasLocalSnomedRows = false;
$csnotkRows = (new CsnotkTerminologyService())->searchDiagnosis($q, 20);
foreach ($csnotkRows as $row) {
@@ -6206,6 +6207,9 @@ class Opd_prescription extends BaseController
$conceptId = trim((string) ($row['concept_id'] ?? ''));
$source = trim((string) ($row['source'] ?? 'csnotk'));
+ if (in_array($source, ['local', 'snomed'], true)) {
+ $hasLocalSnomedRows = true;
+ }
$k = mb_strtoupper($name) . '|' . $conceptId;
if (isset($seen[$k])) {
continue;
@@ -6224,7 +6228,7 @@ class Opd_prescription extends BaseController
}
}
- $snomedRows = $this->fetchSnomedDiagnosisRows($q, 20);
+ $snomedRows = ($hasLocalSnomedRows || mb_strlen($needle) < 3) ? [] : $this->fetchSnomedDiagnosisRows($q, 20);
foreach ($snomedRows as $row) {
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['term'] ?? ''));
if ($name === '') {
diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php
index d846091..4025f97 100644
--- a/app/Controllers/Patient.php
+++ b/app/Controllers/Patient.php
@@ -2669,7 +2669,7 @@ class Patient extends BaseController
$kycVerified = (int) ($row['abha_kyc_verified'] ?? 0) === 1;
$mobileVerified = (int) ($row['abha_mobile_verified'] ?? 0) === 1;
$hasStatusColumn = array_key_exists('abha_verified_status', $row);
- $isVerified = $verifiedStatus === 'VERIFIED' || ($kycVerified && $mobileVerified);
+ $isVerified = in_array($verifiedStatus, ['1', 'VERIFIED', 'YES', 'Y', 'TRUE'], true) || ($kycVerified && $mobileVerified);
if (! $hasStatusColumn && $abhaAddress !== '') {
$isVerified = true;
}
diff --git a/app/Libraries/Abdm/AbdmConnectorInterface.php b/app/Libraries/Abdm/AbdmConnectorInterface.php
index 9dc82e1..89a6089 100644
--- a/app/Libraries/Abdm/AbdmConnectorInterface.php
+++ b/app/Libraries/Abdm/AbdmConnectorInterface.php
@@ -92,6 +92,12 @@ interface AbdmConnectorInterface
*/
public function abhaMobileVerifyOtp(array $payload): array;
+ /**
+ * Fetch hospital facility details and official ABDM Scan & Share QR image.
+ * Gateway response includes hospital_name, hfr_id, and qr_data data URI/base64.
+ */
+ public function facilityQrCode(): array;
+
// -------------------------------------------------------------------------
// Health Record Sharing
// -------------------------------------------------------------------------
diff --git a/app/Libraries/Abdm/DirectAbdmConnector.php b/app/Libraries/Abdm/DirectAbdmConnector.php
index fbf228c..dd67b50 100644
--- a/app/Libraries/Abdm/DirectAbdmConnector.php
+++ b/app/Libraries/Abdm/DirectAbdmConnector.php
@@ -142,6 +142,14 @@ class DirectAbdmConnector implements AbdmConnectorInterface
);
}
+ public function facilityQrCode(): array
+ {
+ return [
+ 'ok' => 0,
+ 'error_text' => 'Facility QR API is not supported by DirectAbdmConnector. Use eatria_bridge connector.',
+ ];
+ }
+
// -------------------------------------------------------------------------
// Scan & Share
// -------------------------------------------------------------------------
diff --git a/app/Libraries/Abdm/DreamsoftConnector.php b/app/Libraries/Abdm/DreamsoftConnector.php
index 4a97169..03057c9 100644
--- a/app/Libraries/Abdm/DreamsoftConnector.php
+++ b/app/Libraries/Abdm/DreamsoftConnector.php
@@ -322,6 +322,14 @@ class DreamsoftConnector implements AbdmConnectorInterface
];
}
+ public function facilityQrCode(): array
+ {
+ return [
+ 'ok' => 0,
+ 'error_text' => 'Facility QR API is not supported by DreamsoftConnector. Use eatria_bridge connector.',
+ ];
+ }
+
public function opdTokenCreate(array $payload): array
{
return [
diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php
index e521b3f..2f5da16 100644
--- a/app/Libraries/Abdm/EAtriaBridgeConnector.php
+++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php
@@ -847,6 +847,11 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
return $this->get('/v3/opd/queue', $params);
}
+ public function facilityQrCode(): array
+ {
+ return $this->get('/v3/facility/qr-code');
+ }
+
public function opdTokenCreate(array $payload): array
{
if (array_key_exists('gender', $payload)) {
@@ -858,12 +863,21 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
}
}
+ if ($this->hfrId !== '' && empty($payload['hfr_id'])) {
+ $payload['hfr_id'] = $this->hfrId;
+ }
+
return $this->post('/v3/opd/token', $payload);
}
public function opdTokenUpdateStatus(int $tokenId, string $status): array
{
- return $this->patch('/v3/opd/token/' . $tokenId, ['status' => $status]);
+ $body = ['status' => $status];
+ if ($this->hfrId !== '') {
+ $body['hfr_id'] = $this->hfrId;
+ }
+
+ return $this->patch('/v3/opd/token/' . $tokenId, $body);
}
public function opdRunningTokenStatus(): array
diff --git a/app/Libraries/CsnotkTerminologyService.php b/app/Libraries/CsnotkTerminologyService.php
index 977fca6..f9134db 100644
--- a/app/Libraries/CsnotkTerminologyService.php
+++ b/app/Libraries/CsnotkTerminologyService.php
@@ -67,7 +67,7 @@ class CsnotkTerminologyService
// Local DB is primary — ~17ms vs ~330ms for external API from server
$rows = $this->localSearch($q, $limit, ['finding', 'disorder', 'morphologic abnormality', 'disease']);
- if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
+ if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
$rows = $this->mergeFallback($rows, $this->apiSearchDiagnosis($q, $limit), $limit);
}
@@ -93,7 +93,7 @@ class CsnotkTerminologyService
// Local DB is primary — ~17ms vs ~330ms for external API from server
$rows = $this->localSearch($q, $limit, ['finding', 'disorder']);
- if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
+ if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
$rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['finding', 'disorder']), $limit);
}
@@ -121,7 +121,7 @@ class CsnotkTerminologyService
$rows = $this->localSearch($q, $limit, $substanceTags);
- if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
+ if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
// CSNOtk: search substance tag first, then product tag as fallback
$apiRows = $this->apiSearchBySemTag($q, $limit, ['substance', 'product']);
$rows = $this->mergeFallback($rows, $apiRows, $limit);
@@ -160,7 +160,7 @@ class CsnotkTerminologyService
// Local DB is primary — ~17ms vs ~330ms for external API from server
$rows = $this->localSearch($q, $limit, ['procedure', 'observable entity', 'regime/therapy']);
- if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
+ if (count($rows) < $limit && mb_strlen($q) >= 3 && $this->apiEnabled && $this->baseUrl !== '') {
$rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['procedure', 'observable entity']), $limit);
}
diff --git a/app/Views/abdm/opd_queue.php b/app/Views/abdm/opd_queue.php
index 1123e5f..9531b99 100644
--- a/app/Views/abdm/opd_queue.php
+++ b/app/Views/abdm/opd_queue.php
@@ -408,6 +408,7 @@
aadhaar_number: t.aadhaar_number ?? t.aadhar_number ?? t.udai ?? '',
patient_name: t.patient_name ?? '', phone: t.phone ?? '',
gender: t.gender ?? '', dob: t.dob ?? '',
+ age: t.age ?? t.patient_age ?? '', birth_year: t.birth_year ?? t.birthYear ?? t.year_of_birth ?? '',
relation_text: t.relation_text ?? '', relation_type: t.relation_type ?? '', relative_name: t.relative_name ?? '',
email: t.email ?? t.email1 ?? '',
address: t.address ?? t.add1 ?? '',
@@ -488,6 +489,7 @@
aadhaar_number: t.aadhaar_number ?? '',
patient_name: t.patient_name ?? '', phone: t.phone ?? '',
gender: t.gender ?? '', dob: t.dob ?? '',
+ age: t.age ?? t.patient_age ?? '', birth_year: t.birth_year ?? t.birthYear ?? t.year_of_birth ?? '',
}).then(r => {
if (!r.ok) {
body.innerHTML = `${esc(r.error_text ?? 'Failed to process token')}
@@ -554,6 +556,7 @@
aadhaar_number: t.aadhaar_number ?? '',
patient_name: t.patient_name ?? '', phone: t.phone ?? '',
gender: t.gender ?? '', dob: t.dob ?? '',
+ age: t.age ?? t.patient_age ?? '', birth_year: t.birth_year ?? t.birthYear ?? t.year_of_birth ?? '',
}).then(handleProcessResult).catch(err => {
body.innerHTML = `Request error: ${esc(err.message)}
`;
});
diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php
index bf3935f..e344b5b 100644
--- a/app/Views/abdm/task_board.php
+++ b/app/Views/abdm/task_board.php
@@ -2315,13 +2315,60 @@
}
var queueText = res.queue_id ? (' Queue ID: ' + res.queue_id) : '';
- setStatus('Submitted to ABDM gateway.' + queueText);
+ var warningText = res.warning ? (' Warning: ' + res.warning) : '';
+ setStatus('Submitted to ABDM gateway.' + queueText + warningText);
var badge = _fhirSubmitTaskRow.querySelector('.status-pill');
if (badge) {
badge.textContent = 'IN_PROGRESS';
badge.className = 'badge bg-info status-pill';
}
- alert('Submitted to ABDM gateway.' + queueText);
+ alert('Submitted to ABDM gateway.' + queueText + warningText);
+ if (res.queue_id) {
+ var logUrl = '= base_url('AbdmBridgeLog') ?>' + '?search=' + encodeURIComponent(res.queue_id);
+ window.open(logUrl, '_blank');
+ }
+ fhirPreviewModal.hide();
+ });
+ return;
+ }
+
+ if (taskType === 'ipd_discharge_publish') {
+ var ipdId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0;
+ var ipdPatientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0;
+ if (ipdId <= 0 || ipdPatientId <= 0) {
+ alert('Task data missing (ipd_id/patient_id).');
+ return;
+ }
+
+ var origHtmlIpd = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = ' Submitting…';
+
+ post('= base_url('AbdmGateway/share_ipd_discharge_bundle') ?>', {
+ ipd_id: ipdId,
+ patient_id: ipdPatientId,
+ abha_id: _fhirSubmitAbha,
+ consent_handle: ''
+ }, function (res) {
+ btn.disabled = false;
+ btn.innerHTML = origHtmlIpd;
+
+ if (!res || parseInt(res.ok || '0', 10) !== 1) {
+ var err = (res && (res.error || res.error_text || res.message)) ? (res.error || res.error_text || res.message) : 'Gateway submit failed';
+ setStatus(err, true);
+ alert('Submission failed:\n' + err);
+ return;
+ }
+
+ var queueText = res.queue_id ? (' Queue ID: ' + res.queue_id) : '';
+ var warningText = res.warning ? (' Warning: ' + res.warning) : '';
+ setStatus('Submitted to ABDM gateway.' + queueText + warningText);
+ var badge = _fhirSubmitTaskRow.querySelector('.status-pill');
+ if (badge) {
+ badge.textContent = 'IN_PROGRESS';
+ badge.className = 'badge bg-info status-pill';
+ }
+ alert('Submitted to ABDM gateway.' + queueText + warningText);
if (res.queue_id) {
var logUrl = '= base_url('AbdmBridgeLog') ?>' + '?search=' + encodeURIComponent(res.queue_id);
window.open(logUrl, '_blank');
diff --git a/app/Views/billing/Patient_Profile_Image_V.php b/app/Views/billing/Patient_Profile_Image_V.php
index b2a95d4..c27a714 100644
--- a/app/Views/billing/Patient_Profile_Image_V.php
+++ b/app/Views/billing/Patient_Profile_Image_V.php
@@ -151,6 +151,7 @@ $(function() {
var $clearHistory = $('#clear_history');
var historyList = [];
var currentIndex = -1;
+ var cameraCleanupRequested = false;
var modalEl = document.getElementById('capturePreviewModal');
var modalInstance = modalEl ? new bootstrap.Modal(modalEl) : null;
var editModalEl = document.getElementById('profileEditModal');
@@ -464,6 +465,7 @@ $(function() {
return;
}
+ cameraCleanupRequested = false;
if (stream) {
stopCamera();
}
@@ -471,6 +473,10 @@ $(function() {
var preferredDeviceId = typeof deviceId === 'string' ? deviceId : ($cameraSelect.val() || selectedDeviceId || '');
function attachStream(s, persistedDeviceId) {
+ if (cameraCleanupRequested) {
+ s.getTracks().forEach(function(track) { track.stop(); });
+ return;
+ }
stream = s;
video.srcObject = s;
video.onloadedmetadata = function() {
@@ -522,8 +528,13 @@ $(function() {
$cameraResolution.text('Camera: stopped');
}
+ var previousPageCleanup = window.pageCleanup;
window.pageCleanup = function() {
+ cameraCleanupRequested = true;
stopCamera();
+ if (typeof previousPageCleanup === 'function') {
+ previousPageCleanup();
+ }
};
$startBtn.on('click', startCamera);
@@ -771,7 +782,8 @@ $(function() {
}
});
- $(window).on('beforeunload', function() {
+ $(window).off('beforeunload.profilecamera pagehide.profilecamera').on('beforeunload.profilecamera pagehide.profilecamera', function() {
+ cameraCleanupRequested = true;
stopCamera();
});
@@ -786,6 +798,7 @@ $(function() {
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
+ cameraCleanupRequested = true;
stopCamera();
}
});
diff --git a/app/Views/billing/Patient_V.php b/app/Views/billing/Patient_V.php
index 8a206af..12015b8 100644
--- a/app/Views/billing/Patient_V.php
+++ b/app/Views/billing/Patient_V.php
@@ -534,6 +534,10 @@
+
+
+
+
-
+
-
-
Scan & Share — Health Facility QR
-
In this method, the patient scans the hospital's facility QR code using their ABHA / PHR app.
-
- Patient opens their ABHA app (or PHR app)
- Selects "Scan Facility QR"
- Scans the hospital's QR code displayed at the OPD counter
- Grants consent — profile is sent to the HMS automatically
-
-
The hospital's facility QR code is provided by NHA/ABDM based on your Health Facility Registry (HFR) ID.
-
-
-
- Open ABHA Portal
-
+
+
+
+
+
+ Loading facility QR...
+
+
+
+
+
+ Refresh QR
+
+
+ Print QR
+
+
+
+
+
+
Scan & Share — Health Facility QR
+
Display or print this QR at the OPD counter. The patient scans it using their ABHA / PHR app.
+
+ Patient opens their ABHA app (or PHR app)
+ Selects "Scan Facility QR"
+ Scans this hospital QR code
+ Grants consent — profile appears in the ABDM OPD Queue automatically
+
+
Facility: —
+
HFR ID: —
+
+
Source: ABDM Bridge GET /api/v3/facility/qr-code.
+
@@ -1045,6 +1067,8 @@
var qrScriptLoading = false;
var qrCameraStorageKey = 'abha_qr_preferred_camera';
var qrSelectedCamera = '';
+ var facilityQrLoaded = false;
+ var qrCleanupRequested = false;
var csrf = function() { return $('input[name="= csrf_token() ?>"]').first().val(); };
try {
@@ -1074,6 +1098,7 @@
$('#abhareg_result').addClass('d-none');
if (method === 'qr') { initQrScanner(); }
else { stopQrScanner(); }
+ if (method === 'facility') { loadFacilityQr(false); }
};
window.abhaRegReset = function() {
@@ -1094,6 +1119,76 @@
$('#abhareg_mob_step1_alert,#abhareg_mob_step2_alert').html('');
};
+ function loadFacilityQr(force) {
+ if (facilityQrLoaded && !force) {
+ return;
+ }
+
+ $('#abhareg_facility_alert').html('');
+ $('#abhareg_facility_loading').removeClass('d-none').html('
Loading facility QR...');
+ $('#abhareg_facility_qr_img,#abhareg_facility_print_btn').addClass('d-none');
+ $('#abhareg_facility_refresh_btn').prop('disabled', true);
+
+ $.getJSON('= base_url('abha/register/facility_qr') ?>', function(resp) {
+ if (!resp || resp.ok != 1 || !resp.qr_data) {
+ var msg = (resp && resp.error_text) ? resp.error_text : 'Unable to load Health Facility QR.';
+ $('#abhareg_facility_loading').html('');
+ $('#abhareg_facility_alert').html('
' + msg + '
');
+ return;
+ }
+
+ var qrData = String(resp.qr_data || '');
+ if (qrData && qrData.indexOf('data:image') !== 0) {
+ qrData = 'data:image/png;base64,' + qrData;
+ }
+
+ facilityQrLoaded = true;
+ $('#abhareg_facility_qr_img').attr('src', qrData).removeClass('d-none');
+ $('#abhareg_facility_loading').addClass('d-none');
+ $('#abhareg_facility_print_btn').removeClass('d-none');
+ $('#abhareg_facility_name').text(resp.hospital_name || '—');
+ $('#abhareg_facility_hfr').text(resp.hfr_id || '—');
+ $('#abhareg_facility_qr_img').data('hospitalName', resp.hospital_name || 'Health Facility');
+ $('#abhareg_facility_qr_img').data('hfrId', resp.hfr_id || '');
+ }).fail(function(xhr) {
+ var msg = 'Unable to load Health Facility QR.';
+ if (xhr && xhr.responseJSON && xhr.responseJSON.error_text) {
+ msg = xhr.responseJSON.error_text;
+ }
+ $('#abhareg_facility_loading').html('');
+ $('#abhareg_facility_alert').html('
' + msg + '
');
+ }).always(function() {
+ $('#abhareg_facility_refresh_btn').prop('disabled', false);
+ });
+ }
+
+ function printFacilityQr() {
+ var qrSrc = $('#abhareg_facility_qr_img').attr('src') || '';
+ if (!qrSrc) {
+ return;
+ }
+
+ var hospitalName = $('#abhareg_facility_qr_img').data('hospitalName') || 'Health Facility';
+ var hfrId = $('#abhareg_facility_qr_img').data('hfrId') || '';
+ var safeHospitalName = $('
').text(hospitalName).html();
+ var safeHfrId = $('
').text(hfrId).html();
+ var printWindow = window.open('', '_blank', 'width=520,height=680');
+ if (!printWindow) {
+ alert('Please allow pop-ups to print the facility QR.');
+ return;
+ }
+
+ printWindow.document.write('
Health Facility QR '
+ + ''
+ + '
' + safeHospitalName + ' '
+ + '
Health Facility QR' + (safeHfrId ? ' | HFR ID: ' + safeHfrId : '') + '
'
+ + '
'
+ + '
Scan this QR using ABHA / PHR app for Scan & Share OPD registration.
'
+ + '
Print
');
+ printWindow.document.close();
+ printWindow.focus();
+ }
+
function regAlert(id, type, msg) {
$('#' + id).html(msg ? '
' + msg + '
' : '');
}
@@ -1172,6 +1267,7 @@
return;
}
+ qrCleanupRequested = false;
var selectedCameraId = typeof cameraId === 'string' ? cameraId : ($('#abhareg_qr_camera_select').val() || qrSelectedCamera || '');
if (!html5QrCode) {
@@ -1203,6 +1299,10 @@
},
function() {}
).then(function() {
+ if (qrCleanupRequested) {
+ stopQrScanner();
+ return;
+ }
saveQrSelectedCamera(selectedCameraId);
}).catch(function(err) {
if (selectedCameraId) {
@@ -1402,6 +1502,26 @@
});
}
+ function cleanupQrScanner() {
+ qrCleanupRequested = true;
+ stopQrScanner();
+ }
+
+ var previousPageCleanup = window.pageCleanup;
+ window.pageCleanup = function() {
+ cleanupQrScanner();
+ if (typeof previousPageCleanup === 'function') {
+ previousPageCleanup();
+ }
+ };
+
+ $(window).off('beforeunload.abhaqr pagehide.abhaqr').on('beforeunload.abhaqr pagehide.abhaqr', cleanupQrScanner);
+ document.addEventListener('visibilitychange', function() {
+ if (document.hidden) {
+ cleanupQrScanner();
+ }
+ });
+
$('#abhareg_qr_stop_btn').on('click', function() { stopQrScanner(); });
$('#abhareg_qr_camera_select').on('change', function() {
var cameraId = ($(this).val() || '').toString();
@@ -1416,6 +1536,8 @@
initQrScanner(qrSelectedCamera || '');
});
});
+ $('#abhareg_facility_refresh_btn').on('click', function() { loadFacilityQr(true); });
+ $('#abhareg_facility_print_btn').on('click', function() { printFacilityQr(); });
// Enter key on OTP fields
$('#abhareg_num_otp_input').on('keypress', function(e) {
diff --git a/app/Views/billing/ipd/ayushman_claim_sheet.php b/app/Views/billing/ipd/ayushman_claim_sheet.php
index dddd638..37a4f41 100644
--- a/app/Views/billing/ipd/ayushman_claim_sheet.php
+++ b/app/Views/billing/ipd/ayushman_claim_sheet.php
@@ -9,7 +9,7 @@ $abhaNumber = trim((string) (($person->abha_id ?? '') ?: ($person->abha_no ?? ''
$abhaAddress = trim((string) ($person->abha_address ?? ''));
$abhaVerifiedRaw = strtolower(trim((string) (($person->abha_verified_status ?? '') ?: ($person->abha_status ?? '') ?: ($person->abha_verified ?? ''))));
$abhaLooksVerified = in_array($abhaVerifiedRaw, ['1', 'verified', 'yes', 'y', 'true'], true);
-$canUseM3 = $patientId > 0 && $abhaAddress !== '';
+$canUseM3 = $patientId > 0 && $abhaAddress !== '' && $abhaLooksVerified;
?>
@@ -58,7 +58,13 @@ $canUseM3 = $patientId > 0 && $abhaAddress !== '';
- = $canUseM3 ? 'Use Fetch From ABDM M3 to request consent and poll approved records, or load already fetched documents.' : 'ABHA address is required before M3 fetch can be started.' ?>
+
+ Use Fetch From ABDM M3 to request consent and poll approved records, or load already fetched documents.
+
+ ABHA address is required before M3 fetch can be started.
+
+ Verified ABHA status is required before M3 fetch can be started.
+
diff --git a/app/Views/billing/opd_scan_modal_body.php b/app/Views/billing/opd_scan_modal_body.php
index 88b74a0..e56bc0f 100644
--- a/app/Views/billing/opd_scan_modal_body.php
+++ b/app/Views/billing/opd_scan_modal_body.php
@@ -107,6 +107,7 @@
var latestFileId = 0;
var cameraStorageKey = 'opd_scan_preferred_camera';
var selectedDeviceId = '';
+ var cameraCleanupRequested = false;
var editState = {
originalDataUrl: '',
currentDataUrl: '',
@@ -417,9 +418,14 @@
return;
}
+ cameraCleanupRequested = false;
stopCamera();
function attachStream(stream) {
+ if (cameraCleanupRequested) {
+ stream.getTracks().forEach(function(track) { track.stop(); });
+ return;
+ }
streamRef = stream;
var video = $video.get(0);
video.srcObject = stream;
@@ -486,9 +492,22 @@
streamRef.getTracks().forEach(function(track) { track.stop(); });
streamRef = null;
}
+ var video = $video.get(0);
+ if (video) {
+ try {
+ video.pause();
+ } catch (e) {
+ }
+ video.srcObject = null;
+ }
$resolution.text('Camera: stopped');
}
+ function cleanupCamera() {
+ cameraCleanupRequested = true;
+ stopCamera();
+ }
+
function loadLastList() {
// Refresh latest uploaded/captured files for this OPD.
if (opdid <= 0) {
@@ -774,8 +793,23 @@
startCamera(selectedDeviceId);
loadLastList();
+ var previousPageCleanup = window.pageCleanup;
+ window.pageCleanup = function() {
+ cleanupCamera();
+ if (typeof previousPageCleanup === 'function') {
+ previousPageCleanup();
+ }
+ };
+
+ $(window).off('beforeunload.opdscan pagehide.opdscan').on('beforeunload.opdscan pagehide.opdscan', cleanupCamera);
+ document.addEventListener('visibilitychange', function() {
+ if (document.hidden) {
+ cleanupCamera();
+ }
+ });
+
$('#tallModal').off('hidden.bs.modal.opdscan').on('hidden.bs.modal.opdscan', function() {
- stopCamera();
+ cleanupCamera();
$('#opd_scan_results').html('Captured image will appear here.');
$resolution.text('Camera: closed');
});
diff --git a/app/Views/diagnosis/lab_final_report_editor.php b/app/Views/diagnosis/lab_final_report_editor.php
index bfed3de..340073f 100644
--- a/app/Views/diagnosis/lab_final_report_editor.php
+++ b/app/Views/diagnosis/lab_final_report_editor.php
@@ -609,7 +609,9 @@ $editReason = trim((string) ($edit_reason ?? ''));
var chEl = document.getElementById('diag_abdm_consent_handle');
if (chEl && !chEl.value) { chEl.value = resp.consent_handle; }
}
- setStatus(resp && resp.status ? ('Queued: ' + resp.status) : (resp && resp.message ? resp.message : 'Share request sent.'), !(resp && (resp.ok || resp.queue_id)));
+ var msg = resp && resp.status ? ('Queued: ' + resp.status) : (resp && resp.message ? resp.message : 'Share request sent.');
+ if (resp && resp.warning) { msg += ' Warning: ' + resp.warning; }
+ setStatus(msg, !(resp && (resp.ok || resp.queue_id)));
});
});
}