From 5d6acdd480a0b47e6fed687bca94145fe4184f83 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Mon, 20 Jul 2026 03:02:09 +0530 Subject: [PATCH] Implement ABDM bridge health record flows - add facility QR support and OPD queue bridge fixes - support care-context push warnings for lab, radiology, IPD and remaining HI types - improve ABHA patient matching and diagnosis autocomplete performance - clean up webcam streams on page navigation --- app/Config/Routes.php | 90 +-- app/Controllers/AbdmGateway.php | 698 +++++++++++++++++- app/Controllers/AbdmOpdQueue.php | 147 +++- app/Controllers/Abha.php | 90 ++- app/Controllers/Opd_prescription.php | 6 +- app/Controllers/Patient.php | 2 +- app/Libraries/Abdm/AbdmConnectorInterface.php | 6 + app/Libraries/Abdm/DirectAbdmConnector.php | 8 + app/Libraries/Abdm/DreamsoftConnector.php | 8 + app/Libraries/Abdm/EAtriaBridgeConnector.php | 16 +- app/Libraries/CsnotkTerminologyService.php | 8 +- app/Views/abdm/opd_queue.php | 3 + app/Views/abdm/task_board.php | 51 +- app/Views/billing/Patient_Profile_Image_V.php | 15 +- app/Views/billing/Patient_V.php | 154 +++- .../billing/ipd/ayushman_claim_sheet.php | 10 +- app/Views/billing/opd_scan_modal_body.php | 36 +- .../diagnosis/lab_final_report_editor.php | 4 +- 18 files changed, 1243 insertions(+), 109 deletions(-) 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 = '' + '?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('', { + 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 = '' + '?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 @@ + + +
+
@@ -623,23 +627,41 @@
- +
-
-
Scan & Share — Health Facility QR
-

In this method, the patient scans the hospital's facility QR code using their ABHA / PHR app.

-
    -
  1. Patient opens their ABHA app (or PHR app)
  2. -
  3. Selects "Scan Facility QR"
  4. -
  5. Scans the hospital's QR code displayed at the OPD counter
  6. -
  7. Grants consent — profile is sent to the HMS automatically
  8. -
-

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... +
+ Health Facility QR +
+
+ + +
+
+
+
+
Scan & Share — Health Facility QR
+

Display or print this QR at the OPD counter. The patient scans it using their ABHA / PHR app.

+
    +
  1. Patient opens their ABHA app (or PHR app)
  2. +
  3. Selects "Scan Facility QR"
  4. +
  5. Scans this hospital QR code
  6. +
  7. Grants consent — profile appears in the ABDM OPD Queue automatically
  8. +
+
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=""]').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('', 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 : '') + '
' + + 'Health Facility QR' + + '
Scan this QR using ABHA / PHR app for Scan & Share OPD registration.
' + + '

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