From 062fc7ab326422dd7c07d589cc30dbf92b077fa4 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sun, 5 Apr 2026 23:42:24 +0530 Subject: [PATCH] feat: Add ABDM Task Board module with ABHA workflow - Add ABDM sidebar heading and Task Board nav link - Create migration 000040: abdm_work_tasks table - Add AbdmTaskBoard controller with backfill methods - Add AbdmWorkTaskService for task creation/deduplication - Add AbdmGateway controller for ABDM API integration - Separate task types: patient_abha_create vs patient_abha_update - Add lab_report_publish and radiology_report_publish task types - Patient.php: auto-create ABDM tasks on registration - Diagnosis.php: auto-create tasks on report verify/finalize - Task board UI: 7 filter buttons with default Unupdated ABHA view - Fix SQL crash in backfillLabRadiologyTasks (dynamic column detection) - Improve load_form AJAX error display with HTTP status + response text - Add ABDM routes in Routes.php - Add FHIR R4 builder library updates - Add bridge sync service updates --- README.md | 27 + app/Config/Routes.php | 15 + app/Controllers/AbdmGateway.php | 757 ++++++++++++++++++ app/Controllers/AbdmTaskBoard.php | 308 +++++++ app/Controllers/Billing/Ipd.php | 88 ++ app/Controllers/Diagnosis.php | 101 ++- app/Controllers/Ipd_discharge.php | 114 +++ app/Controllers/Opd.php | 298 ++++++- app/Controllers/Opd_prescription.php | 53 ++ app/Controllers/Patient.php | 123 +++ ...2026-04-05-000039_CreateAbdmCoreTables.php | 361 +++++++++ ...026-04-05-000040_AddAbdmWorkTasksTable.php | 123 +++ app/Libraries/AbdmWorkTaskService.php | 223 ++++++ app/Libraries/BridgeSyncService.php | 285 ++++++- app/Libraries/FhirR4Builder.php | 89 ++ app/Views/abdm/task_board.php | 269 +++++++ app/Views/billing/Patient_V.php | 45 ++ app/Views/billing/ipd/discharge_create.php | 172 ++++ app/Views/billing/opd_prescription_basic.php | 169 ++++ .../diagnosis/lab_final_report_editor.php | 121 +++ app/Views/partials/sidebar.php | 8 + app/Views/welcome_message.php | 26 +- env | 29 + 23 files changed, 3757 insertions(+), 47 deletions(-) create mode 100644 app/Controllers/AbdmGateway.php create mode 100644 app/Controllers/AbdmTaskBoard.php create mode 100644 app/Database/Migrations/2026-04-05-000039_CreateAbdmCoreTables.php create mode 100644 app/Database/Migrations/2026-04-05-000040_AddAbdmWorkTasksTable.php create mode 100644 app/Libraries/AbdmWorkTaskService.php create mode 100644 app/Views/abdm/task_board.php diff --git a/README.md b/README.md index 846d2c0..239bfe9 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,33 @@ Generate encryption key: php spark key:generate ``` +### ABDM via Eka.care (existing routes, provider switch) + +This project keeps the same ABDM controller routes and queue events, and can switch +the queue dispatcher to Eka for ABDM/NHCX integrations. + +Add these keys in `.env`: + +```dotenv +ABDM_SYNC_PROVIDER = eka +EKA_BASE_URL = https://api.eka.care +EKA_BEARER_TOKEN = your-token +# Optional alternatives +# EKA_API_KEY = your-api-key +# EKA_CLIENT_ID = your-client-id +# EKA_EVENT_ENDPOINTS_JSON = {"abdm.abha.validate":"/your/path"} +# EKA_WEBHOOK_SECRET = shared-secret-for-callback-signature +``` + +Then process queue normally: + +```bash +php spark bridge:sync +``` + +If `EKA_WEBHOOK_SECRET` is set, callback endpoints require a valid HMAC-SHA256 signature +in `X-Eka-Signature`, `X-Signature`, or `X-Hub-Signature-256`. + --- ## 5. Database Setup diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4d3cace..3975977 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -388,6 +388,7 @@ $routes->group('billing', function($routes) { $routes->post('patient/create', 'Patient::create'); $routes->post('patient/search', 'Patient::search'); $routes->post('patient/search_adv', 'Patient::search_adv'); + $routes->post('patient/abha_fetch_profile', 'Patient::abha_fetch_profile'); $routes->get('patient/person_record/(:num)', 'Patient::person_record/$1'); $routes->get('patient/person_record/(:num)/(:num)', 'Patient::person_record/$1/$2'); $routes->get('patient/show_cards/(:num)', 'Patient::show_cards/$1'); @@ -501,6 +502,20 @@ $routes->get('Opd_prescription/Prescription/(:num)', 'Opd_prescription::Prescrip $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'); +$routes->post('AbdmGateway/abha_validate', 'AbdmGateway::abhaValidate'); +$routes->post('AbdmGateway/scan_share_lookup', 'AbdmGateway::scanShareLookup'); +$routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest'); +$routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback'); +$routes->post('AbdmGateway/share_prescription_bundle', 'AbdmGateway::sharePrescriptionBundle'); +$routes->post('AbdmGateway/share_ipd_discharge_bundle', 'AbdmGateway::shareIpdDischargeBundle'); +$routes->post('AbdmGateway/share_diagnosis_report_bundle', 'AbdmGateway::shareDiagnosisReportBundle'); +$routes->post('AbdmGateway/nhcx_claim_create', 'AbdmGateway::nhcxClaimCreate'); +$routes->post('AbdmGateway/nhcx_claim_status_request', 'AbdmGateway::nhcxClaimStatusRequest'); +$routes->post('AbdmGateway/nhcx_claim_status_callback', 'AbdmGateway::nhcxClaimStatusCallback'); +$routes->get('AbdmTaskBoard', 'AbdmTaskBoard::index'); +$routes->get('AbdmTaskBoard/list', 'AbdmTaskBoard::list'); +$routes->post('AbdmTaskBoard/perform_action', 'AbdmTaskBoard::performAction'); +$routes->post('AbdmTaskBoard/mark_status', 'AbdmTaskBoard::markStatus'); $routes->get('Opd_prescription/fhir_bundle/(:num)', 'Opd_prescription::fhir_bundle/$1'); $routes->get('Opd_prescription/fhir_bundle/(:num)/(:num)', 'Opd_prescription::fhir_bundle/$1/$2'); $routes->get('Opd_prescription/fhir_bundle_history/(:num)', 'Opd_prescription::fhir_bundle_history/$1'); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php new file mode 100644 index 0000000..2077aa9 --- /dev/null +++ b/app/Controllers/AbdmGateway.php @@ -0,0 +1,757 @@ +request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $abhaId = trim((string) $this->request->getPost('abha_id')); + if ($abhaId === '' || preg_match('/^\d{14}$/', $abhaId) !== 1) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA ID must be a 14-digit number.']); + } + + $payload = [ + 'abha_id' => $abhaId, + 'requested_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]; + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.abha.validate', $payload, 'abha', $abhaId); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => 'queued', + 'queue_id' => $queueId, + 'message' => 'ABHA validation request queued to center server.', + ]); + } + + public function consentRequest() + { + 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')); + $purposeCode = trim((string) $this->request->getPost('purpose_code')); + $expiresAt = trim((string) $this->request->getPost('expires_at')); + + if ($patientId <= 0 || $abhaId === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id and abha_id are required']); + } + + if (! $this->db->tableExists('abdm_consent_records')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'abdm_consent_records table not found']); + } + + $consentHandle = 'CH-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(4))); + $requestedAt = Time::now('Asia/Kolkata')->toDateTimeString(); + + $rawPayload = [ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'purpose_code' => $purposeCode, + 'expires_at' => $expiresAt, + 'consent_handle' => $consentHandle, + 'requested_at' => $requestedAt, + ]; + + $this->db->table('abdm_consent_records')->insert([ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'consent_handle' => $consentHandle, + 'consent_status' => 'requested', + 'purpose_code' => $purposeCode, + 'requested_at' => $requestedAt, + 'expires_at' => $expiresAt !== '' ? $expiresAt : null, + 'raw_payload_json' => (string) json_encode($rawPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'created_at' => $requestedAt, + 'updated_at' => $requestedAt, + ]); + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.consent.requested', $rawPayload, 'consent', $consentHandle); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'consent_handle' => $consentHandle, + 'queue_id' => $queueId, + 'status' => 'requested', + ]); + } + + public function consentCallback() + { + $signatureFailure = $this->validateWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload) || empty($payload['consent_handle'])) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'consent_handle is required']); + } + + if (! $this->db->tableExists('abdm_consent_records')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'abdm_consent_records table not found']); + } + + $handle = trim((string) $payload['consent_handle']); + $status = trim((string) ($payload['consent_status'] ?? 'approved')); + $expiresAt = trim((string) ($payload['expires_at'] ?? '')); + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + + $existing = $this->db->table('abdm_consent_records') + ->where('consent_handle', $handle) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (empty($existing)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error_text' => 'consent_handle not found', + ]); + } + + $currentStatus = strtolower(trim((string) ($existing['consent_status'] ?? ''))); + $incomingStatus = strtolower($status); + $currentExpires = trim((string) ($existing['expires_at'] ?? '')); + $incomingExpires = $expiresAt !== '' ? $expiresAt : ''; + + if ($this->isStaleConsentTransition($currentStatus, $incomingStatus)) { + return $this->response->setJSON([ + 'ok' => 1, + 'consent_handle' => $handle, + 'status' => $currentStatus, + 'ignored' => 1, + 'reason' => 'stale_transition', + ]); + } + + $rawPayloadJson = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $existingPayload = trim((string) ($existing['raw_payload_json'] ?? '')); + $isDuplicate = ($currentStatus === $incomingStatus) && ($currentExpires === $incomingExpires) && ($existingPayload === $rawPayloadJson); + if ($isDuplicate) { + return $this->response->setJSON([ + 'ok' => 1, + 'consent_handle' => $handle, + 'status' => $incomingStatus, + 'duplicate' => 1, + ]); + } + + $update = [ + 'consent_status' => $incomingStatus, + 'granted_at' => $incomingStatus === 'approved' + ? ((string) ($existing['granted_at'] ?? '') !== '' ? (string) $existing['granted_at'] : $now) + : (string) ($existing['granted_at'] ?? ''), + 'expires_at' => $expiresAt !== '' ? $expiresAt : null, + 'raw_payload_json' => $rawPayloadJson, + 'updated_at' => $now, + ]; + + $this->db->table('abdm_consent_records') + ->where('consent_handle', $handle) + ->update($update); + + return $this->response->setJSON(['ok' => 1, 'consent_handle' => $handle, 'status' => $incomingStatus]); + } + + public function scanShareLookup() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $qrPayload = trim((string) $this->request->getPost('qr_payload')); + if ($qrPayload === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'qr_payload is required']); + } + + $abhaId = ''; + if (preg_match('/\b(\d{14})\b/', $qrPayload, $match) === 1) { + $abhaId = (string) ($match[1] ?? ''); + } + + $payload = [ + 'qr_payload' => $qrPayload, + 'abha_id_hint' => $abhaId, + 'requested_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]; + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.scan_share.lookup', $payload, 'abha_scan', $abhaId !== '' ? $abhaId : null); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => 'queued', + 'queue_id' => $queueId, + 'abha_id_hint' => $abhaId, + 'message' => 'Scan & Share lookup queued to center server.', + ]); + } + + public function sharePrescriptionBundle() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $opdId = (int) $this->request->getPost('opd_id'); + $sessionId = (int) $this->request->getPost('opd_session_id'); + $patientId = (int) $this->request->getPost('patient_id'); + $abhaId = trim((string) $this->request->getPost('abha_id')); + $consentHandle = trim((string) $this->request->getPost('consent_handle')); + + if ($opdId <= 0 || $patientId <= 0 || $abhaId === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'opd_id, patient_id and abha_id are required']); + } + + if (! $this->db->tableExists('opd_fhir_documents')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'opd_fhir_documents table not found']); + } + + $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.', + ]); + } + + $builder = $this->db->table('opd_fhir_documents') + ->where('opd_id', $opdId) + ->where('bundle_type', 'MedicationRequestBundle'); + if ($sessionId > 0) { + $builder->where('opd_session_id', $sessionId); + } + $bundleRow = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); + + if (empty($bundleRow)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'No FHIR bundle found for selected OPD/session']); + } + + $bundleJson = (string) ($bundleRow['bundle_json'] ?? '{}'); + $bundle = json_decode($bundleJson, true); + if (! is_array($bundle)) { + $bundle = ['raw' => $bundleJson]; + } + + $payload = [ + 'opd_id' => $opdId, + 'opd_session_id' => (int) ($bundleRow['opd_session_id'] ?? 0), + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'consent_handle' => (string) ($consent['consent_handle'] ?? ''), + 'bundle_type' => (string) ($bundleRow['bundle_type'] ?? 'MedicationRequestBundle'), + 'bundle' => $bundle, + ]; + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.fhir.share.requested', $payload, 'opd_fhir_document', (string) ($bundleRow['id'] ?? '')); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => $queueId, + 'consent_handle' => (string) ($consent['consent_handle'] ?? ''), + 'status' => 'queued', + ]); + } + + public function shareIpdDischargeBundle() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $ipdId = (int) $this->request->getPost('ipd_id'); + $patientId = (int) $this->request->getPost('patient_id'); + $abhaId = trim((string) $this->request->getPost('abha_id')); + $consentHandle = trim((string) $this->request->getPost('consent_handle')); + + if ($ipdId <= 0 || $patientId <= 0 || $abhaId === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'ipd_id, patient_id and abha_id are required']); + } + + $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.', + ]); + } + + $ipdRow = $this->db->tableExists('ipd_master') + ? ($this->db->table('ipd_master')->where('id', $ipdId)->get(1)->getRowArray() ?? []) + : []; + + $summaryHtml = ''; + if ($this->db->tableExists('ipd_discharge') && $this->db->fieldExists('content', 'ipd_discharge')) { + $summaryRow = $this->db->table('ipd_discharge') + ->select('content') + ->where('ipd_id', $ipdId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $summaryHtml = trim((string) ($summaryRow['content'] ?? '')); + } + + $payload = [ + 'ipd_id' => $ipdId, + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'consent_handle' => (string) ($consent['consent_handle'] ?? ''), + 'ipd_code' => trim((string) ($ipdRow['ipd_code'] ?? '')), + 'register_date' => trim((string) ($ipdRow['register_date'] ?? '')), + 'discharge_date' => trim((string) ($ipdRow['discharge_date'] ?? '')), + 'discharge_time' => trim((string) ($ipdRow['discharge_time'] ?? '')), + 'summary_html' => $summaryHtml, + ]; + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.ipd.discharge.share.requested', $payload, 'ipd_discharge', (string) $ipdId); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => $queueId, + 'consent_handle' => (string) ($consent['consent_handle'] ?? ''), + 'status' => 'queued', + ]); + } + + public function shareDiagnosisReportBundle() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(403)->setJSON(['error' => 'AJAX only']); + } + + $labReqId = (int) ($this->request->getPost('lab_req_id') ?? 0); + $patientId = (int) ($this->request->getPost('patient_id') ?? 0); + $abhaId = trim((string) ($this->request->getPost('abha_id') ?? '')); + $consentHandle = trim((string) ($this->request->getPost('consent_handle') ?? '')); + + if ($labReqId <= 0 || $patientId <= 0 || $abhaId === '') { + return $this->response->setJSON(['ok' => 0, 'error' => 'lab_req_id, patient_id and abha_id are required']); + } + + $db = \Config\Database::connect(); + + $labReq = $db->table('lab_request') + ->select('id, patient_name, lab_type, charge_id, Report_Data, report_data_Impression, status, reported_time') + ->where('id', $labReqId) + ->get(1) + ->getRow(); + + if (! $labReq) { + return $this->response->setJSON(['ok' => 0, 'error' => 'Lab request not found']); + } + + $consentRecord = $this->getActiveConsentRecord($patientId, $abhaId, $consentHandle); + + $payload = [ + 'lab_req_id' => $labReqId, + 'patient_id' => $patientId, + 'patient_name' => (string) ($labReq->patient_name ?? ''), + 'abha_id' => $abhaId, + 'lab_type' => (int) ($labReq->lab_type ?? 0), + 'invoice_id' => (int) ($labReq->charge_id ?? 0), + 'report_html' => (string) ($labReq->Report_Data ?? ''), + 'impression' => (string) ($labReq->report_data_Impression ?? ''), + 'report_status' => (int) ($labReq->status ?? 0), + 'reported_time' => (string) ($labReq->reported_time ?? ''), + 'consent_handle' => $consentRecord['consent_handle'] ?? $consentHandle, + ]; + + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.diagnosis.report.share.requested', $payload, 'lab_request', (string) $labReqId); + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => $queueId, + 'consent_handle' => $payload['consent_handle'], + 'status' => 'queued', + ]); + } + + public function nhcxClaimCreate() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + if (! $this->db->tableExists('nhcx_claim_documents')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'nhcx_claim_documents table not found']); + } + + $patientId = (int) $this->request->getPost('patient_id'); + $encounterId = (int) $this->request->getPost('encounter_id'); + $ipdId = (int) $this->request->getPost('ipd_id'); + $caseId = (int) $this->request->getPost('case_id'); + $totalAmount = (float) $this->request->getPost('total_amount'); + $claimType = trim((string) $this->request->getPost('claim_type')) ?: 'institutional'; + + if ($patientId <= 0 || $encounterId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id and encounter_id are required']); + } + + $patient = [ + 'id' => (string) $patientId, + 'name' => (string) $this->request->getPost('patient_name'), + 'gender' => (string) $this->request->getPost('patient_gender'), + 'birthDate' => (string) $this->request->getPost('patient_birthdate'), + 'abhaAddress' => (string) $this->request->getPost('abha_address'), + ]; + + $encounter = [ + 'id' => (string) $encounterId, + 'status' => 'finished', + ]; + + $items = $this->request->getPost('items'); + if (is_string($items)) { + $decoded = json_decode($items, true); + $items = is_array($decoded) ? $decoded : []; + } + if (! is_array($items)) { + $items = []; + } + + $claim = [ + 'id' => 'claim-' . $patientId . '-' . $encounterId . '-' . date('YmdHis'), + 'status' => 'active', + 'use' => 'claim', + 'type' => $claimType, + 'total' => $totalAmount, + 'provider' => (string) $this->request->getPost('provider_name'), + 'insurer' => (string) $this->request->getPost('insurer_name'), + 'priority' => (string) $this->request->getPost('priority'), + 'items' => $items, + ]; + + $fhir = new FhirR4Builder(); + $bundle = $fhir->buildClaimBundle($patient, $encounter, $claim); + $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $this->db->table('nhcx_claim_documents')->insert([ + 'ipd_id' => $ipdId > 0 ? $ipdId : null, + 'case_id' => $caseId > 0 ? $caseId : null, + 'patient_id' => $patientId, + 'claim_type' => $claimType, + 'claim_json' => $bundleJson, + 'claim_status' => 'draft', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $documentId = (int) $this->db->insertID(); + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue( + 'nhcx.claim.created', + [ + 'nhcx_claim_document_id' => $documentId, + 'patient_id' => $patientId, + 'encounter_id' => $encounterId, + 'bundle' => $bundle, + ], + 'nhcx_claim_document', + (string) $documentId + ); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'document_id' => $documentId, + 'queue_id' => $queueId, + 'claim_status' => 'draft', + ]); + } + + public function nhcxClaimStatusRequest() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + if (! $this->db->tableExists('nhcx_claim_documents')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'nhcx_claim_documents table not found']); + } + + $documentId = (int) $this->request->getPost('document_id'); + if ($documentId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'document_id is required']); + } + + $row = $this->db->table('nhcx_claim_documents')->where('id', $documentId)->get(1)->getRowArray(); + if (empty($row)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'claim document not found']); + } + + $payload = [ + 'nhcx_claim_document_id' => $documentId, + 'external_ref' => (string) ($row['external_ref'] ?? ''), + 'claim_status' => (string) ($row['claim_status'] ?? ''), + 'requested_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]; + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('nhcx.claim.status.requested', $payload, 'nhcx_claim_document', (string) $documentId); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'document_id' => $documentId, + 'queue_id' => $queueId, + 'status' => 'queued', + ]); + } + + public function nhcxClaimStatusCallback() + { + $signatureFailure = $this->validateWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON payload']); + } + + if (! $this->db->tableExists('nhcx_claim_documents')) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'nhcx_claim_documents table not found']); + } + + $documentId = (int) ($payload['document_id'] ?? 0); + $externalRef = trim((string) ($payload['external_ref'] ?? '')); + $status = trim((string) ($payload['claim_status'] ?? 'unknown')); + $errorMessage = trim((string) ($payload['error_message'] ?? '')); + + if ($documentId <= 0 && $externalRef === '') { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'document_id or external_ref is required']); + } + + $existingBuilder = $this->db->table('nhcx_claim_documents'); + if ($documentId > 0) { + $existingBuilder->where('id', $documentId); + } else { + $existingBuilder->where('external_ref', $externalRef); + } + $existing = $existingBuilder->get(1)->getRowArray(); + if (empty($existing)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error_text' => 'claim document not found', + ]); + } + + $currentStatus = strtolower(trim((string) ($existing['claim_status'] ?? 'unknown'))); + $incomingStatus = strtolower($status); + $currentExternalRef = trim((string) ($existing['external_ref'] ?? '')); + $currentError = trim((string) ($existing['error_message'] ?? '')); + + if ($this->isStaleClaimTransition($currentStatus, $incomingStatus)) { + return $this->response->setJSON([ + 'ok' => 1, + 'document_id' => (int) ($existing['id'] ?? $documentId), + 'external_ref' => $currentExternalRef, + 'claim_status' => $currentStatus, + 'ignored' => 1, + 'reason' => 'stale_transition', + ]); + } + + $isDuplicate = ($currentStatus === $incomingStatus) + && ($currentExternalRef === $externalRef) + && ($currentError === $errorMessage); + if ($isDuplicate) { + return $this->response->setJSON([ + 'ok' => 1, + 'document_id' => (int) ($existing['id'] ?? $documentId), + 'external_ref' => $currentExternalRef, + 'claim_status' => $currentStatus, + 'duplicate' => 1, + ]); + } + + $builder = $this->db->table('nhcx_claim_documents')->where('id', (int) $existing['id']); + + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $builder->update([ + 'claim_status' => $incomingStatus, + 'external_ref' => $externalRef !== '' ? $externalRef : null, + 'error_message' => $errorMessage !== '' ? mb_substr($errorMessage, 0, 1000) : null, + 'pushed_at' => $incomingStatus === 'submitted' + ? ((string) ($existing['pushed_at'] ?? '') !== '' ? (string) $existing['pushed_at'] : $now) + : (string) ($existing['pushed_at'] ?? ''), + 'updated_at' => $now, + ]); + + return $this->response->setJSON([ + 'ok' => 1, + 'document_id' => (int) ($existing['id'] ?? $documentId), + 'external_ref' => $externalRef, + 'claim_status' => $incomingStatus, + ]); + } + + private function validateWebhookSignature() + { + $secret = $this->readRuntimeSetting('EKA_WEBHOOK_SECRET'); + if ($secret === '') { + return null; + } + + $signature = trim((string) ($this->request->getHeaderLine('X-Eka-Signature') ?: '')); + if ($signature === '') { + $signature = trim((string) ($this->request->getHeaderLine('X-Signature') ?: '')); + } + if ($signature === '') { + $signature = trim((string) ($this->request->getHeaderLine('X-Hub-Signature-256') ?: '')); + } + if ($signature === '') { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error_text' => 'Missing signature header', + ]); + } + + $signature = strtolower($signature); + if (str_starts_with($signature, 'sha256=')) { + $signature = substr($signature, 7); + } + + $rawBody = (string) $this->request->getBody(); + $expected = hash_hmac('sha256', $rawBody, $secret); + if (! hash_equals($expected, $signature)) { + return $this->response->setStatusCode(401)->setJSON([ + 'ok' => 0, + 'error_text' => 'Invalid signature', + ]); + } + + return null; + } + + private function readRuntimeSetting(string $name): string + { + $envValue = getenv($name); + if ($envValue !== false) { + $value = trim((string) $envValue); + if ($value !== '') { + return $value; + } + } + + if ($this->db->tableExists('hospital_setting')) { + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $name) + ->get(1) + ->getRowArray(); + $value = trim((string) ($row['s_value'] ?? '')); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function isStaleConsentTransition(string $currentStatus, string $incomingStatus): bool + { + if ($incomingStatus === '' || $currentStatus === '') { + return false; + } + + if ($incomingStatus === 'requested' && $currentStatus !== 'requested') { + return true; + } + + return false; + } + + private function isStaleClaimTransition(string $currentStatus, string $incomingStatus): bool + { + if ($incomingStatus === '' || $currentStatus === '' || $incomingStatus === 'unknown') { + return false; + } + + $rank = [ + 'draft' => 10, + 'submitted' => 20, + 'in_review' => 30, + 'approved' => 40, + 'rejected' => 40, + 'failed' => 40, + 'settled' => 50, + 'paid' => 50, + ]; + + $currentRank = (int) ($rank[$currentStatus] ?? 0); + $incomingRank = (int) ($rank[$incomingStatus] ?? 0); + + return $currentRank > 0 && $incomingRank > 0 && $incomingRank < $currentRank; + } + + private function getActiveConsentRecord(int $patientId, string $abhaId, string $consentHandle = ''): ?array + { + if (! $this->db->tableExists('abdm_consent_records')) { + return null; + } + + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $builder = $this->db->table('abdm_consent_records') + ->where('patient_id', $patientId) + ->where('abha_id', $abhaId) + ->where('consent_status', 'approved') + ->groupStart() + ->where('expires_at IS NULL', null, false) + ->orWhere('expires_at >', $now) + ->groupEnd(); + + if ($consentHandle !== '') { + $builder->where('consent_handle', $consentHandle); + } + + $row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); + return ! empty($row) ? $row : null; + } +} diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php new file mode 100644 index 0000000..b49e839 --- /dev/null +++ b/app/Controllers/AbdmTaskBoard.php @@ -0,0 +1,308 @@ +taskService = new AbdmWorkTaskService(); + } + + public function index() + { + $this->backfillPatientAbhaTasks(); + $this->backfillLabRadiologyTasks(); + $tasks = $this->taskService->getOpenTasks(300); + + return view('abdm/task_board', [ + 'tasks' => $tasks, + ]); + } + + public function list() + { + $this->backfillPatientAbhaTasks(); + $this->backfillLabRadiologyTasks(); + return $this->response->setJSON([ + 'ok' => 1, + 'tasks' => $this->taskService->getOpenTasks(300), + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function markStatus() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $taskId = (int) $this->request->getPost('task_id'); + $status = trim((string) $this->request->getPost('status')); + $note = trim((string) $this->request->getPost('note')); + + if ($taskId <= 0 || $status === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'task_id and status are required']); + } + + $ok = $this->taskService->markTaskStatus($taskId, $status, $note); + + return $this->response->setJSON([ + 'ok' => $ok ? 1 : 0, + 'task_id' => $taskId, + 'status' => $status, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + public function performAction() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $taskId = (int) $this->request->getPost('task_id'); + $action = trim((string) $this->request->getPost('action')); + + $task = $this->taskService->getTask($taskId); + if ($task === null) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Task not found']); + } + + $abhaId = trim((string) $this->request->getPost('abha_id')); + if (! $this->isValidAbhaNumber($abhaId)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA ID must be a 14-digit number']); + } + + $payload = [ + 'task_id' => $taskId, + 'task_code' => (string) ($task['task_code'] ?? ''), + 'task_type' => (string) ($task['task_type'] ?? ''), + 'patient_id' => (int) ($task['patient_id'] ?? 0), + 'patient_name' => (string) ($task['patient_name'] ?? ''), + 'abha_id' => $abhaId, + 'entity_type' => (string) ($task['entity_type'] ?? ''), + 'entity_id' => (string) ($task['entity_id'] ?? ''), + 'opd_session_id' => (int) $this->request->getPost('opd_session_id'), + ]; + + $eventType = $this->resolveActionEventType($action, (string) ($task['task_type'] ?? '')); + if ($eventType === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Unsupported action']); + } + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue($eventType, $payload, 'abdm_task', (string) $taskId); + } catch (\Throwable $e) { + $this->taskService->markTaskStatus($taskId, 'failed', $e->getMessage()); + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Queue failure: ' . $e->getMessage()]); + } + + $this->taskService->markTaskStatus($taskId, 'in_progress', 'Action queued: ' . $eventType); + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => $queueId, + 'event_type' => $eventType, + 'task_id' => $taskId, + 'status' => 'in_progress', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + private function resolveActionEventType(string $action, string $taskType): string + { + $action = strtolower(trim($action)); + $taskType = strtolower(trim($taskType)); + + if ($action === 'create_abha') { + return 'abdm.abha.create.requested'; + } + + if ($action === 'update_abha') { + return 'abdm.abha.update.requested'; + } + + if ($action === 'submit') { + if ($taskType === 'opd_prescription_publish') { + return 'abdm.opd.prescription.share.requested'; + } + if ($taskType === 'ipd_admission_publish') { + return 'abdm.ipd.admission.share.requested'; + } + if ($taskType === 'ipd_discharge_publish') { + return 'abdm.ipd.discharge.share.requested'; + } + if ($taskType === 'lab_report_publish' || $taskType === 'radiology_report_publish') { + return 'abdm.diagnosis.report.share.requested'; + } + } + + return ''; + } + + private function isValidAbhaNumber(string $abhaId): bool + { + return preg_match('/^\d{14}$/', $abhaId) === 1; + } + + private function backfillPatientAbhaTasks(): void + { + if (! $this->db->tableExists('abdm_work_tasks') || ! $this->db->tableExists('patient_master')) { + return; + } + + $abhaField = $this->resolvePatientAbhaField(); + if ($abhaField === null) { + return; + } + + $rows = $this->db->table('patient_master') + ->select('id,p_fname,' . $abhaField) + ->orderBy('id', 'DESC') + ->limit(500) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $patientId = (int) ($row['id'] ?? 0); + if ($patientId <= 0) { + continue; + } + + $abha = trim((string) ($row[$abhaField] ?? '')); + if (preg_match('/^\d{14}$/', $abha) === 1) { + continue; + } + + $exists = $this->db->table('abdm_work_tasks') + ->select('id') + ->whereIn('task_type', ['patient_abha_create', 'patient_abha_link']) + ->where('entity_type', 'patient') + ->where('entity_id', (string) $patientId) + ->whereIn('status', ['pending', 'in_progress']) + ->get(1) + ->getRowArray(); + + if (! empty($exists)) { + continue; + } + + $this->taskService->createOrRefreshTask( + 'patient_abha_create', + 'patient_registration', + 'patient', + (string) $patientId, + $patientId, + trim((string) ($row['p_fname'] ?? '')), + $abha, + 'create_abha', + ['trigger' => 'task_board.backfill'] + ); + } + } + + private function resolvePatientAbhaField(): ?string + { + if (! $this->db->tableExists('patient_master')) { + return null; + } + + $fields = $this->db->getFieldNames('patient_master') ?? []; + foreach (['abha_id', 'abha_no', 'abha'] as $field) { + if (in_array($field, $fields, true)) { + return $field; + } + } + + if (in_array('abha_address', $fields, true)) { + return 'abha_address'; + } + + return null; + } + + private function backfillLabRadiologyTasks(): void + { + if (! $this->db->tableExists('abdm_work_tasks') || ! $this->db->tableExists('lab_request') || ! $this->db->tableExists('patient_master')) { + return; + } + + $patientFields = $this->db->getFieldNames('patient_master') ?? []; + $abhaSelectParts = []; + foreach (['abha_id', 'abha_no', 'abha_address', 'abha'] as $field) { + if (in_array($field, $patientFields, true)) { + $abhaSelectParts[] = 'p.' . $field; + } + } + + if (empty($abhaSelectParts)) { + return; + } + + $select = 'r.id, r.patient_id, r.patient_name, r.lab_type, r.charge_id, r.status, ' . implode(', ', $abhaSelectParts); + + $rows = $this->db->table('lab_request r') + ->select($select) + ->join('patient_master p', 'p.id = r.patient_id', 'left') + ->where('r.status >=', 2) + ->orderBy('r.id', 'DESC') + ->limit(500) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $labReqId = (int) ($row['id'] ?? 0); + $patientId = (int) ($row['patient_id'] ?? 0); + if ($labReqId <= 0 || $patientId <= 0) { + continue; + } + + $abha = trim((string) ($row['abha_id'] ?? $row['abha_no'] ?? $row['abha_address'] ?? $row['abha'] ?? '')); + if (preg_match('/^\d{14}$/', $abha) !== 1) { + continue; + } + + $labType = (int) ($row['lab_type'] ?? 0); + $taskType = in_array($labType, [1, 2, 3, 4, 6], true) ? 'radiology_report_publish' : 'lab_report_publish'; + + $exists = $this->db->table('abdm_work_tasks') + ->select('id') + ->where('task_type', $taskType) + ->where('entity_type', 'lab_request') + ->where('entity_id', (string) $labReqId) + ->whereIn('status', ['pending', 'in_progress']) + ->get(1) + ->getRowArray(); + if (! empty($exists)) { + continue; + } + + $this->taskService->createOrRefreshTask( + $taskType, + 'diagnosis', + 'lab_request', + (string) $labReqId, + $patientId, + trim((string) ($row['patient_name'] ?? '')), + $abha, + 'submit', + [ + 'lab_type' => $labType, + 'invoice_id' => (int) ($row['charge_id'] ?? 0), + 'trigger' => 'task_board.backfill', + ] + ); + } + } +} diff --git a/app/Controllers/Billing/Ipd.php b/app/Controllers/Billing/Ipd.php index db61547..797445e 100644 --- a/app/Controllers/Billing/Ipd.php +++ b/app/Controllers/Billing/Ipd.php @@ -3,6 +3,8 @@ namespace App\Controllers\Billing; use App\Controllers\BaseController; +use App\Libraries\AbdmWorkTaskService; +use App\Libraries\BridgeSyncService; use App\Models\BedAssignmentHistoryModel; use App\Models\ItemIpdModel; use App\Models\IpdBillingModel; @@ -280,6 +282,8 @@ class Ipd extends BaseController ->where('id', $caseId) ->update(['ipd_id' => $insertId]); } + + $this->enqueueIpdAdmissionSync((int) $insertId, 'ipd.admission.created'); } return $this->response->setJSON([ @@ -583,6 +587,8 @@ class Ipd extends BaseController return $this->response->setStatusCode(500)->setJSON(['update' => 0, 'message' => 'Unable to update admission details']); } + $this->enqueueIpdAdmissionSync($ipdId, 'ipd.admission.updated'); + $freshPanelData = $this->ipdModel->getIpdPanelInfo($ipdId); $admissionData = $this->buildAdmissionTabData($freshPanelData, $ipdId); @@ -2654,6 +2660,88 @@ class Ipd extends BaseController return (int) $this->db->insertID(); } + private function enqueueIpdAdmissionSync(int $ipdId, string $eventType): void + { + if ($ipdId <= 0 || ! $this->db->tableExists('ipd_master')) { + return; + } + + $ipdRow = $this->db->table('ipd_master')->where('id', $ipdId)->get(1)->getRowArray(); + if (empty($ipdRow)) { + return; + } + + $patientId = (int) ($ipdRow['p_id'] ?? 0); + $patientRow = []; + if ($patientId > 0 && $this->db->tableExists('patient_master')) { + $patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? []; + } + + $abhaId = ''; + foreach (['abha_id', 'abha_no', 'abha_address', 'abha'] as $field) { + if (trim((string) ($patientRow[$field] ?? '')) !== '') { + $abhaId = trim((string) ($patientRow[$field] ?? '')); + break; + } + } + + $doctorIds = []; + if ($this->db->tableExists('ipd_doctor')) { + $doctorRows = $this->db->table('ipd_doctor') + ->select('doc_id') + ->where('ipd_id', $ipdId) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + foreach ($doctorRows as $doctorRow) { + $docId = (int) ($doctorRow['doc_id'] ?? 0); + if ($docId > 0) { + $doctorIds[$docId] = $docId; + } + } + } + + $payload = [ + 'ipd_id' => $ipdId, + 'ipd_code' => trim((string) ($ipdRow['ipd_code'] ?? '')), + 'patient_id' => $patientId, + 'patient_name' => trim((string) ($patientRow['p_fname'] ?? $ipdRow['P_name'] ?? '')), + 'abha_id' => $abhaId, + 'register_date' => trim((string) ($ipdRow['register_date'] ?? '')), + 'reg_time' => trim((string) ($ipdRow['reg_time'] ?? '')), + 'discharge_date' => trim((string) ($ipdRow['discharge_date'] ?? '')), + 'discharge_time' => trim((string) ($ipdRow['discharge_time'] ?? '')), + 'dept_id' => (int) ($ipdRow['dept_id'] ?? 0), + 'doctor_ids' => array_values($doctorIds), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + try { + $bridgeSync = new BridgeSyncService(); + $bridgeSync->enqueue($eventType, $payload, 'ipd', (string) $ipdId); + } catch (\Throwable $e) { + // Do not block IPD admission workflows if queue service is unavailable. + } + + if ($eventType === 'ipd.admission.created' && preg_match('/^\d{14}$/', $abhaId) === 1) { + $taskService = new AbdmWorkTaskService(); + $taskService->createOrRefreshTask( + 'ipd_admission_publish', + 'ipd', + 'ipd', + (string) $ipdId, + $patientId, + trim((string) ($patientRow['p_fname'] ?? $ipdRow['P_name'] ?? '')), + $abhaId, + 'submit', + [ + 'ipd_id' => $ipdId, + 'trigger' => 'ipd.admission.created', + ] + ); + } + } + private function syncNursingChargesToInvoice(int $ipdId): void { if (! $this->db->tableExists('nursing_ipd_charges')) { diff --git a/app/Controllers/Diagnosis.php b/app/Controllers/Diagnosis.php index ec0c7f3..7b5d193 100644 --- a/app/Controllers/Diagnosis.php +++ b/app/Controllers/Diagnosis.php @@ -3,6 +3,8 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\AbdmWorkTaskService; +use App\Libraries\BridgeSyncService; use CodeIgniter\I18n\Time; use Exception; use Mpdf\HTMLParserMode; @@ -1082,11 +1084,16 @@ class Diagnosis extends BaseController } $patientRow = $this->db->table('patient_master') - ->select('gender, age, age_in, age_in_month, dob') + ->select('gender, age, age_in, age_in_month, dob, abha_id, abha_no, abha_address, abha') ->where('id', (int) ($reportRow->patient_id ?? 0)) ->get() ->getRow(); + $reportRow->patient_abha = ''; + if ($patientRow) { + $reportRow->patient_abha = trim((string) ($patientRow->abha_id ?? $patientRow->abha_no ?? $patientRow->abha_address ?? $patientRow->abha ?? '')); + } + $genderRaw = (int) ($patientRow->gender ?? 0); $reportRow->gender_text = $genderRaw === 1 ? 'Male' : ($genderRaw === 2 ? 'Female' : '-'); @@ -1227,6 +1234,8 @@ class Diagnosis extends BaseController ]); } + $this->enqueueLabReportSync($labReqId, 'radiology.report.updated'); + return $this->response->setJSON(['status' => 'success', 'message' => 'Saved']); } catch (\Throwable $e) { return $this->response->setJSON(['status' => 'error', 'message' => $e->getMessage()]); @@ -1303,6 +1312,8 @@ class Diagnosis extends BaseController ]); } + $this->enqueueLabReportSync($labReqId, 'lab.report.updated'); + return $this->response->setJSON(['status' => 'success', 'message' => 'Saved']); } catch (\Throwable $e) { return $this->response->setJSON(['status' => 'error', 'message' => $e->getMessage()]); @@ -1327,6 +1338,8 @@ class Diagnosis extends BaseController 'status' => 2, ]); + $this->enqueueLabReportSync($labReqId, 'lab.report.confirmed'); + return $this->response->setJSON(['status' => 'success', 'message' => 'Verified and Ready for Print']); } catch (\Throwable $e) { return $this->response->setJSON(['status' => 'error', 'message' => $e->getMessage()]); @@ -3803,4 +3816,90 @@ class Diagnosis extends BaseController { return $this->selectLabInvoicePath((int) $invoiceId, (int) $labType); } + + private function enqueueLabReportSync(int $labReqId, string $eventType): void + { + try { + if (! $this->db->tableExists('bridge_sync_queue')) { + return; + } + + $labReq = $this->db->table('lab_request') + ->select('id, patient_id, patient_name, lab_type, charge_id, status, reported_time') + ->where('id', $labReqId) + ->get(1) + ->getRow(); + + if (! $labReq) { + return; + } + + $patientId = (int) ($labReq->patient_id ?? 0); + $abhaId = ''; + + if ($patientId > 0) { + $patientFields = $this->db->getFieldNames('patient_master') ?? []; + $abhaColumns = []; + foreach (['abha_id', 'abha_no', 'abha_address', 'abha'] as $field) { + if (in_array($field, $patientFields, true)) { + $abhaColumns[] = $field; + } + } + + $patient = null; + if (! empty($abhaColumns)) { + $patient = $this->db->table('patient_master') + ->select(implode(',', $abhaColumns)) + ->where('id', $patientId) + ->get(1) + ->getRow(); + } + + if ($patient) { + $abhaId = trim((string) ($patient->abha_id ?? $patient->abha_no ?? $patient->abha_address ?? $patient->abha ?? '')); + } + } + + $payload = [ + 'lab_req_id' => $labReqId, + 'patient_id' => $patientId, + 'patient_name' => (string) ($labReq->patient_name ?? ''), + 'abha_id' => $abhaId, + 'lab_type' => (int) ($labReq->lab_type ?? 0), + 'invoice_id' => (int) ($labReq->charge_id ?? 0), + 'report_status' => (int) ($labReq->status ?? 0), + 'reported_time' => (string) ($labReq->reported_time ?? ''), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $bridgeSync = new BridgeSyncService(); + $bridgeSync->enqueue($eventType, $payload, 'lab_request', (string) $labReqId); + + // Create ABDM work tasks for verified/finalized reports so staff can submit them. + if ((int) ($labReq->status ?? 0) >= 2 && preg_match('/^\d{14}$/', $abhaId) === 1) { + $taskType = $this->isRadiologyType((int) ($labReq->lab_type ?? 0)) + ? 'radiology_report_publish' + : 'lab_report_publish'; + + $taskService = new AbdmWorkTaskService(); + $taskService->createOrRefreshTask( + $taskType, + 'diagnosis', + 'lab_request', + (string) $labReqId, + $patientId, + (string) ($labReq->patient_name ?? ''), + $abhaId, + 'submit', + [ + 'lab_type' => (int) ($labReq->lab_type ?? 0), + 'invoice_id' => (int) ($labReq->charge_id ?? 0), + 'trigger' => $eventType, + ] + ); + } + } catch (\Throwable $e) { + log_message('error', '[ABDM] enqueueLabReportSync failed for lab_req ' . $labReqId . ': ' . $e->getMessage()); + } + } } \ No newline at end of file diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index 3eb3449..c1a800b 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -3,6 +3,8 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\AbdmWorkTaskService; +use App\Libraries\BridgeSyncService; use App\Models\IpdBillingModel; use Mpdf\Mpdf; use Mpdf\Output\Destination; @@ -3296,6 +3298,10 @@ class Ipd_discharge extends BaseController $savedAny = $this->upsertByIpd('ipd_discharge_drug_food_interaction', $ipdId, $legacyData) || $savedAny; } + if ($savedAny) { + $this->enqueueIpdDischargeSync($ipdId, $patientId, 'dietary_autosave'); + } + return $this->response->setJSON([ 'update' => $savedAny ? 1 : 0, 'error_text' => $savedAny ? 'Dietary advice saved.' : 'Unable to save dietary advice.', @@ -4165,6 +4171,10 @@ class Ipd_discharge extends BaseController $noticeType = 'warning'; } + if ($savedAny) { + $this->enqueueIpdDischargeSync($ipdId, $patientId, $action); + } + $ajaxMode = strtolower(trim((string) ($this->request->getPost('ajax_mode') ?? ''))); if ($ajaxMode === 'json') { return $this->response->setJSON([ @@ -4415,6 +4425,8 @@ class Ipd_discharge extends BaseController $renderedHtml = (string) ($templatePack['rendered_html'] ?? $content); $templateName = (string) ($templatePack['selected_template_name'] ?? 'Discharge Template'); + $this->createIpdDischargeWorkTask($ipdId, $panelData); + try { $patient = $panelData['person_info'] ?? null; $ipd = $panelData['ipd_info'] ?? null; @@ -4478,6 +4490,47 @@ class Ipd_discharge extends BaseController ]); } + private function createIpdDischargeWorkTask(int $ipdId, array $panelData): void + { + $person = $panelData['person_info'] ?? null; + if (! $person) { + return; + } + + $patientId = (int) ($person->id ?? $person->p_id ?? 0); + if ($patientId <= 0) { + return; + } + + $abhaId = trim((string) ( + $person->abha_id + ?? $person->abha_no + ?? $person->abha_address + ?? $person->abha + ?? '' + )); + + if (preg_match('/^\d{14}$/', $abhaId) !== 1) { + return; + } + + $taskService = new AbdmWorkTaskService(); + $taskService->createOrRefreshTask( + 'ipd_discharge_publish', + 'ipd_discharge', + 'ipd', + (string) $ipdId, + $patientId, + trim((string) ($person->p_fname ?? '')), + $abhaId, + 'submit', + [ + 'ipd_id' => $ipdId, + 'trigger' => 'ipd.discharge.printed', + ] + ); + } + private function buildDischargePdfHtml(array $panelData, string $renderedContent, bool $withHeader, string $templateName): string { $ipd = $panelData['ipd_info'] ?? null; @@ -4541,4 +4594,65 @@ class Ipd_discharge extends BaseController { return $this->show_discharge($ipdId, 3); } + + private function enqueueIpdDischargeSync(int $ipdId, int $patientId, string $action): void + { + if ($ipdId <= 0) { + return; + } + + $ipdRow = $this->db->tableExists('ipd_master') + ? ($this->db->table('ipd_master')->where('id', $ipdId)->get(1)->getRowArray() ?? []) + : []; + + if ($patientId <= 0) { + $patientId = (int) ($ipdRow['p_id'] ?? 0); + } + + $patientRow = []; + if ($patientId > 0 && $this->db->tableExists('patient_master')) { + $patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? []; + } + + $abhaId = ''; + foreach (['abha_id', 'abha_no', 'abha_address', 'abha'] as $field) { + $candidate = trim((string) ($patientRow[$field] ?? '')); + if ($candidate !== '') { + $abhaId = $candidate; + break; + } + } + + $dischargeContent = ''; + if ($this->db->tableExists('ipd_discharge') && $this->db->fieldExists('content', 'ipd_discharge')) { + $dischargeRow = $this->db->table('ipd_discharge') + ->select('content') + ->where('ipd_id', $ipdId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $dischargeContent = trim((string) ($dischargeRow['content'] ?? '')); + } + + $payload = [ + 'ipd_id' => $ipdId, + 'patient_id' => $patientId, + 'ipd_code' => trim((string) ($ipdRow['ipd_code'] ?? '')), + 'patient_name' => trim((string) ($patientRow['p_fname'] ?? $ipdRow['P_name'] ?? '')), + 'abha_id' => $abhaId, + 'action' => trim($action) !== '' ? trim($action) : 'save_main', + 'discharge_date' => trim((string) ($ipdRow['discharge_date'] ?? '')), + 'discharge_time' => trim((string) ($ipdRow['discharge_time'] ?? '')), + 'discharge_status' => trim((string) ($ipdRow['discarge_patient_status'] ?? '')), + 'summary_html' => $dischargeContent, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + try { + $bridgeSync = new BridgeSyncService(); + $bridgeSync->enqueue('ipd.discharge.updated', $payload, 'ipd_discharge', (string) $ipdId); + } catch (\Throwable $e) { + // Do not block discharge workflows if queue service is unavailable. + } + } } diff --git a/app/Controllers/Opd.php b/app/Controllers/Opd.php index f922625..3caaec2 100644 --- a/app/Controllers/Opd.php +++ b/app/Controllers/Opd.php @@ -86,13 +86,60 @@ class Opd extends BaseController $content = $this->readOpdTemplateContentByName($name); $placeholders = [ - 'hospital_name', 'hospital_address', 'hospital_phone', 'hospital_email', 'doctor_name', 'print_time', + // Hospital + 'hospital_name', 'hospital_address', 'hospital_phone', 'hospital_email', + 'H_Name', 'H_address_1', 'H_address_2', 'H_phone_No', 'H_Email', 'H_logo', + // Doctor + 'doctor_name', 'doctor_full_name', 'doctor_title', + 'doctor_reg_no', 'doctor_registration', + 'doctor_phone', 'doctor_email', 'doctor_address', + 'doctor_specialization', 'SpecName', 'short_description', 'doctor_short_description', - 'pName', 'pRelative', 'age_sex', 'phoneno', 'p_address', 'uhid_no', 'opd_sr_no', 'opd_no', - 'opd_date', 'exp_date', 'SpecName', 'opd_fee_desc', 'total_no_visit', 'last_opdvisit_date', 'str_opd_book_date', - 'Complaint', 'diagnosis', 'Provisional_diagnosis', 'Finding_Examinations', 'medical', 'investigation', - 'Prescriber_Remarks', 'advice', 'next_visit', 'refer_to', 'vital_content', 'painscale_img', - 'hospital_section', 'patient_section', 'content_section', + 'doctor_sign_html', + // Print metadata + 'print_time', 'current_date', + // Patient + 'pName', 'pRelative', 'age_sex', 'phoneno', 'p_address', + 'uhid_no', 'opd_sr_no', 'opd_no', 'opd_date', 'exp_date', + 'opd_fee_desc', 'total_no_visit', 'last_opdvisit_date', 'str_opd_book_date', + // General Examination (raw values) + 'bp', 'bp_diastolic', 'pulse', 'temp', 'spo2', 'rr_min', + 'height', 'weight', 'waist', + 'pallor', 'icterus', 'cyanosis', 'clubbing', 'edema', + // General Examination (formatted blocks) + 'vital_content', // BP/Pulse/Temp/SpO2/RR/Ht/Wt/Waist formatted + 'vital_content_extra', // Pallor/Icterus/Cyanosis/Clubbing/Edema + 'vital_content_full', // All vitals + extra combined + 'general_examination', // Alias of vital_content_full + // Pain scale + 'pain_value', // 0-4 + 'pain_label', // e.g. "Mild Pain" + 'pain_scale', // Formatted block: Pain Scale : Mild Pain + // Complication & Addiction + 'Complication', 'complication', // Formatted block of checked flags + 'Addiction', 'addiction', // Formatted block of checked flags + // Drug allergy / NABH + 'drug_allergy_status', 'drug_allergy_details', + 'adr_history', 'current_medications', + 'drug_allergy_block', // Formatted: Drug Allergy : status — details + 'adr_history_block', // Formatted: ADR History : ... + 'current_medications_block', // Formatted: Current Medications : ... + // Women related + 'women_lmp', 'women_last_baby', 'women_pregnancy_related', 'women_related_problems', + 'women_block', // Formatted: Women Related : LMP | Last Baby | ... + // Co-morbidities + 'morbidities', // Raw comma-separated list + 'morbidities_block', // Formatted: Co-Morbidities : Diabetes, HTn, ... + // Clinical content + 'painscale_img', + 'Complaint', 'diagnosis', 'Provisional_diagnosis', 'Finding_Examinations', + 'medical', 'investigation', 'Prescriber_Remarks', 'advice', 'next_visit', 'refer_to', + // Composed blocks + 'Rx', 'RxTable', 'RxFullBlock', + 'VitalsBlock', 'ComplaintBlock', 'DiagnosisBlock', 'InvestigationBlock', + 'RemarksBlock', 'AdviceBlock', 'NextVisitBlock', + // Legacy layout sections + 'hospital_section', 'patient_section', 'content_section', 'content', ]; return view('billing/opd_template_builder', [ @@ -1402,7 +1449,16 @@ class Opd extends BaseController } else { $customHtml = ''; if ($templateKey !== '') { + // 1) Try DB-stored template first $customHtml = $this->readStoredOpdTemplateContent($templateKey); + + // 2) Fall back to filesystem HTML file in opd_templates/ + if ($customHtml === '') { + $fsPath = APPPATH . 'Views/billing/opd_templates/' . $templateKey . '.html'; + if (is_file($fsPath)) { + $customHtml = (string) file_get_contents($fsPath); + } + } } if ($customHtml !== '') { @@ -1869,23 +1925,49 @@ class Opd extends BaseController $generic = trim((string) ($med['genericname'] ?? $med['generic_name'] ?? $med['salt_name'] ?? '')); $dose = trim((string) ($med['dosage_label'] ?? $med['dosage'] ?? $med['drug_dose'] ?? $med['dose'] ?? '')); $freq = trim((string) ($med['dosage_freq_label'] ?? $med['dosage_freq'] ?? $med['drug_freq'] ?? $med['frequency'] ?? '')); + $doseLocalRaw = trim((string) ($med['dosage_local_label'] ?? $med['dosage_local'] ?? '')); + $freqLocalRaw = trim((string) ($med['dosage_freq_local_label'] ?? $med['dosage_freq_local'] ?? '')); $days = trim((string) ($med['no_of_days'] ?? $med['drug_day'] ?? $med['days'] ?? '')); $when = trim((string) ($med['dosage_when_label'] ?? $med['dosage_when'] ?? '')); $remark = trim((string) ($med['remark'] ?? '')); $inst = trim(($when !== '' ? $when : '') . ($remark !== '' ? ($when !== '' ? ' | ' : '') . $remark : '')); $doseFreqText = trim($dose . ' ' . $freq); - // Hindi for freq/schedule: - // 1) exact match against doseShedHindiMap (DB: opd_dose_shed) - // 2) extract "ABBR (SIGN)" part up to closing ')' — handles "OD (ONE TIME A DAY) Daily" - // 3) exact match in doseFreqHindiMap (DB: opd_dose_frequency) - // 4) translated fallback - $freqKey = strtolower(trim($freq)); - $doseFreqLocal = $doseShedHindiMap[$freqKey] ?? $doseFreqHindiMap[$freqKey] ?? ''; - if ($doseFreqLocal === '' && preg_match('/^(.+?\))/u', $freq, $_m) === 1) { - $signKey = strtolower(trim($_m[1])); - $doseFreqLocal = $doseShedHindiMap[$signKey] ?? ''; + // Hindi for dose + frequency: + // Prefer pre-resolved local labels from buildOpdLetterPrintData(), then resolve via maps. + $doseLocal = $doseLocalRaw; + if ($doseLocal === '' && $dose !== '') { + $doseKey = strtolower(trim($dose)); + $doseLocal = $doseShedHindiMap[$doseKey] ?? ''; + if ($doseLocal === '' && preg_match('/^(.+?\))/u', $dose, $_m) === 1) { + $doseSignKey = strtolower(trim($_m[1])); + $doseLocal = $doseShedHindiMap[$doseSignKey] ?? ''; + } + if ($doseLocal === '') { + $doseFallback = $this->translateToLocalPatientText($dose); + if (strtolower($doseFallback) !== strtolower($dose)) { + $doseLocal = $doseFallback; + } + } } + + $freqLocal = $freqLocalRaw; + if ($freqLocal === '' && $freq !== '') { + $freqKey = strtolower(trim($freq)); + $freqLocal = $doseShedHindiMap[$freqKey] ?? $doseFreqHindiMap[$freqKey] ?? ''; + if ($freqLocal === '' && preg_match('/^(.+?\))/u', $freq, $_m) === 1) { + $signKey = strtolower(trim($_m[1])); + $freqLocal = $doseShedHindiMap[$signKey] ?? ''; + } + if ($freqLocal === '') { + $freqFallback = $this->translateToLocalPatientText($freq); + if (strtolower($freqFallback) !== strtolower($freq)) { + $freqLocal = $freqFallback; + } + } + } + + $doseFreqLocal = trim($doseLocal . (($doseLocal !== '' && $freqLocal !== '') ? ' ' : '') . $freqLocal); if ($doseFreqLocal === '' && $doseFreqText !== '') { $fallback = $this->translateToLocalPatientText($doseFreqText); if (strtolower($fallback) !== strtolower($doseFreqText)) { @@ -2015,6 +2097,15 @@ class Opd extends BaseController $pulse = trim((string) ($rx['pulse'] ?? '')); $temp = trim((string) ($rx['temp'] ?? '')); $spo2 = trim((string) ($rx['spo2'] ?? '')); + $rrMin = trim((string) ($rx['rr_min'] ?? '')); + $height = trim((string) ($rx['height'] ?? '')); + $weight = trim((string) ($rx['weight'] ?? '')); + $waist = trim((string) ($rx['waist'] ?? '')); + $pallor = trim((string) ($rx['pallor'] ?? '')); + $icterus = trim((string) ($rx['icterus'] ?? $rx['Icterus'] ?? '')); + $cyanosis = trim((string) ($rx['cyanosis'] ?? '')); + $clubbing = trim((string) ($rx['clubbing'] ?? '')); + $edema = trim((string) ($rx['edema'] ?? '')); $vitals = []; if ($bp !== '') { @@ -2029,6 +2120,54 @@ class Opd extends BaseController if ($spo2 !== '') { $vitals[] = 'SpO2: ' . $spo2; } + if ($rrMin !== '') { + $vitals[] = 'RR: ' . $rrMin . '/min'; + } + if ($height !== '') { + $vitals[] = 'Ht: ' . $height; + } + if ($weight !== '') { + $vitals[] = 'Wt: ' . $weight; + } + if ($waist !== '') { + $vitals[] = 'Waist: ' . $waist; + } + + // ── Pain scale ────────────────────────────────────────────────────── + $painValue = trim((string) ($rx['pain_value'] ?? '')); + $painLabels = ['0' => 'No Pain', '1' => 'Mild Pain', '2' => 'Moderate', '3' => 'Intense', '4' => 'Worst Pain Possible']; + $painLabel = $painValue !== '' ? ($painLabels[$painValue] ?? '') : ''; + + // ── Complication flags ─────────────────────────────────────────────── + $complicationFlags = []; + if (!empty($rx['pregnancy'])) { $complicationFlags[] = 'Pregnancy'; } + if (!empty($rx['lactation'])) { $complicationFlags[] = 'Lactation'; } + if (!empty($rx['liver_insufficiency'])) { $complicationFlags[] = 'Liver Insufficiency'; } + if (!empty($rx['renal_insufficiency'])) { $complicationFlags[] = 'Renal Insufficiency'; } + if (!empty($rx['pulmonary_insufficiency'])) { $complicationFlags[] = 'Pulmonary Insufficiency'; } + if (!empty($rx['corona_suspected'])) { $complicationFlags[] = 'Corona Suspected'; } + if (!empty($rx['dengue'])) { $complicationFlags[] = 'Dengue'; } + $complicationText = implode(', ', $complicationFlags); + + // ── Addiction flags (from patient_master or rx) ────────────────────── + $pRowCopy = $data['patient_master'][0] ?? null; + $addictionFlags = []; + if (!empty($rx['is_smoking'] ?? $pRowCopy->is_smoking ?? 0)) { $addictionFlags[] = 'Smoking'; } + if (!empty($rx['is_alcohol'] ?? $pRowCopy->is_alcohol ?? 0)) { $addictionFlags[] = 'Alcohol'; } + if (!empty($rx['is_drug_abuse'] ?? $pRowCopy->is_drug_abuse ?? 0)) { $addictionFlags[] = 'Drug Abuse'; } + $addictionText = implode(', ', $addictionFlags); + + // ── Drug allergy / NABH ────────────────────────────────────────────── + $drugAllergyStatus = trim((string) ($rx['drug_allergy_status'] ?? '')); + $drugAllergyDetails = trim((string) ($rx['drug_allergy_details'] ?? '')); + $adrHistory = trim((string) ($rx['adr_history'] ?? '')); + $currentMedications = trim((string) ($rx['current_medications'] ?? '')); + + // ── Women related ──────────────────────────────────────────────────── + $womenLmp = trim((string) ($rx['women_lmp'] ?? '')); + $womenLastBaby = trim((string) ($rx['women_last_baby'] ?? '')); + $womenPregnancyRelated = trim((string) ($rx['women_pregnancy_related'] ?? '')); + $womenRelatedProblems = trim((string) ($rx['women_related_problems'] ?? '')); $printContent = '' . '' @@ -2098,6 +2237,41 @@ class Opd extends BaseController 'doctor' => '

Dr. ' . esc((string) ($opd->doc_name ?? '')) . '

', 'top_content' => '', 'vital_content' => implode(' | ', $vitals), + 'vital_content_extra' => implode(' | ', array_filter([ + $pallor !== '' ? 'Pallor: ' . $pallor : '', + $icterus !== '' ? 'Icterus: ' . $icterus : '', + $cyanosis !== '' ? 'Cyanosis: ' . $cyanosis : '', + $clubbing !== '' ? 'Clubbing: ' . $clubbing : '', + $edema !== '' ? 'Edema: ' . $edema : '', + ], static fn ($v): bool => $v !== '')), + 'height' => $height, + 'weight' => $weight, + 'waist' => $waist, + 'rr_min' => $rrMin, + 'pallor' => $pallor, + 'icterus' => $icterus, + 'cyanosis' => $cyanosis, + 'clubbing' => $clubbing, + 'edema' => $edema, + 'bp' => $bp, + 'bp_diastolic' => $diastolic, + 'pulse' => $pulse, + 'temp' => $temp, + 'spo2' => $spo2, + 'pain_value' => $painValue, + 'pain_label' => $painLabel, + 'Complication' => $complicationText, + 'complication' => $complicationText, + 'Addiction' => $addictionText, + 'addiction' => $addictionText, + 'drug_allergy_status' => $drugAllergyStatus, + 'drug_allergy_details' => $drugAllergyDetails, + 'adr_history' => $adrHistory, + 'current_medications' => $currentMedications, + 'women_lmp' => $womenLmp, + 'women_last_baby' => $womenLastBaby, + 'women_pregnancy_related' => $womenPregnancyRelated, + 'women_related_problems' => $womenRelatedProblems, 'Finding_Examinations' => $findingExaminationsText, 'finding_examinations' => $findingExaminationsText, 'Prescriber_Remarks' => $prescriberRemarksText, @@ -2105,11 +2279,9 @@ class Opd extends BaseController 'advice_local' => $rxAdviceLocal, 'next_visit' => $nextVisitText, 'refer_to' => (string) ($rx['refer_to'] ?? ''), - 'painscale' => '', + 'painscale' => $painLabel, 'painscale_img' => '', 'morbidities' => implode(', ', is_array($data['selected_morbidities'] ?? null) ? ($data['selected_morbidities'] ?? []) : []), - 'Addiction' => '', - 'Complication' => '', ]; } @@ -2611,6 +2783,51 @@ class Opd extends BaseController } $tokens['next_visit'] = $formatBlock('Next Visit', (string) ($tokens['next_visit_raw'] ?? '')); + // ── General Examination extras (formatted) ──────────────────────────── + $vitalRaw = (string) ($tokens['vital_content_raw'] ?? ''); + $vitalExtraRaw = trim((string) ($tokens['vital_content_extra'] ?? '')); + $vitalFull = trim($vitalRaw . ($vitalExtraRaw !== '' ? ' | ' . $vitalExtraRaw : '')); + $tokens['vital_content_full'] = $formatBlock('Vitals', $vitalFull); + $tokens['general_examination'] = $tokens['vital_content_full']; + + // ── Pain scale (formatted) ──────────────────────────────────────────── + $painLabel = trim((string) ($tokens['pain_label'] ?? '')); + $tokens['pain_scale'] = $painLabel !== '' ? $formatBlock('Pain Scale', $painLabel) : ''; + + // ── Complication & Addiction (formatted) ────────────────────────────── + $tokens['Complication'] = $formatBlock('Complication', (string) ($tokens['complication'] ?? '')); + $tokens['complication'] = $tokens['Complication']; + $tokens['Addiction'] = $formatBlock('Addiction', (string) ($tokens['addiction'] ?? '')); + $tokens['addiction'] = $tokens['Addiction']; + + // ── Drug allergy / NABH (formatted) ────────────────────────────────── + $allergyStatus = trim((string) ($tokens['drug_allergy_status'] ?? '')); + $allergyDetails = trim((string) ($tokens['drug_allergy_details'] ?? '')); + $adrHistory = trim((string) ($tokens['adr_history'] ?? '')); + $currentMeds = trim((string) ($tokens['current_medications'] ?? '')); + + $allergyBlock = $allergyStatus !== '' ? esc($allergyStatus) : ''; + if ($allergyDetails !== '') { + $allergyBlock .= ($allergyBlock !== '' ? ' — ' : '') . esc($allergyDetails); + } + $tokens['drug_allergy_block'] = $allergyBlock !== '' ? $formatBlock('Drug Allergy', $allergyBlock) : ''; + $tokens['adr_history_block'] = $adrHistory !== '' ? $formatBlock('ADR History', esc($adrHistory)) : ''; + $tokens['current_medications_block'] = $currentMeds !== '' ? $formatBlock('Current Medications', esc($currentMeds)) : ''; + + // ── Women related (formatted) ───────────────────────────────────────── + $womenParts = []; + $womenLmpRaw = trim((string) ($tokens['women_lmp'] ?? '')); + if ($womenLmpRaw !== '') { $womenParts[] = 'LMP: ' . esc($womenLmpRaw) . ' days ago'; } + $womenLastBabyRaw = trim((string) ($tokens['women_last_baby'] ?? '')); + if ($womenLastBabyRaw !== '') { $womenParts[] = 'Last Baby: ' . esc($womenLastBabyRaw); } + $womenPregRaw = trim((string) ($tokens['women_pregnancy_related'] ?? '')); + if ($womenPregRaw !== '') { $womenParts[] = esc($womenPregRaw); } + $womenBlock = implode(' | ', $womenParts); + $tokens['women_block'] = $womenBlock !== '' ? $formatBlock('Women Related', $womenBlock) : ''; + + // ── Morbidities (formatted) ─────────────────────────────────────────── + $tokens['morbidities_block'] = $formatBlock('Co-Morbidities', (string) ($tokens['morbidities'] ?? '')); + $medicalHtml = trim((string) ($tokens['medical'] ?? '')); $tokens['Rx'] = $medicalHtml !== '' ? '
Rx :
' . $medicalHtml . '
' @@ -2707,6 +2924,49 @@ class Opd extends BaseController ? ('
सलाह: ' . $adviceLocal) : ''; + // ── Doctor master tokens ────────────────────────────────────────────── + $docRow = $data['doctor_master'][0] ?? null; + $docTitle = trim((string) ($docRow->p_title ?? 'Dr.')); + $docFname = trim((string) ($docRow->p_fname ?? '')); + $docMname = trim((string) ($docRow->p_mname ?? '')); + $docLname = trim((string) ($docRow->p_lname ?? '')); + $docFullName = trim(trim($docTitle . ' ' . $docFname . ' ' . $docMname) . ' ' . $docLname); + $docRegNo = trim((string) ($docRow->doctor_reg_no ?? '')); + $docPhone = trim((string) ($docRow->mphone1 ?? '')); + $docEmail = trim((string) ($docRow->email1 ?? '')); + $docSpec = trim((string) ($docRow->SpecName ?? $tokens['SpecName'] ?? '')); + $docAdd1 = trim((string) ($docRow->add1 ?? '')); + $docAdd2 = trim((string) ($docRow->add2 ?? '')); + $docCity = trim((string) ($docRow->city ?? '')); + $docAddress = trim(trim($docAdd1 . ', ' . $docAdd2, ', ') . (($docCity !== '') ? ', ' . $docCity : ''), ', '); + + $tokens['doctor_title'] = esc($docTitle); + $tokens['doctor_full_name'] = esc($docFullName); + $tokens['doctor_reg_no'] = esc($docRegNo); + $tokens['doctor_registration'] = esc($docRegNo); + $tokens['doctor_phone'] = esc($docPhone); + $tokens['doctor_email'] = esc($docEmail); + $tokens['doctor_specialization'] = esc($docSpec); + $tokens['doctor_address'] = esc($docAddress); + // ───────────────────────────────────────────────────────────────────── + + // ── Composed section tokens (available in flat/custom templates too) ─ + // hospital_section : hospital name + address + phone/email block + $hospitalSectionParts = array_filter([ + $hName !== '' ? '' . esc($hName) . '' : '', + trim($hAddress1 . ', ' . $hAddress2, ', ') !== '' ? esc(trim($hAddress1 . ', ' . $hAddress2, ', ')) : '', + $hPhone !== '' ? 'Ph: ' . esc($hPhone) : '', + $hEmail !== '' ? 'Email: ' . esc($hEmail) : '', + ], static fn ($v): bool => $v !== ''); + $tokens['hospital_section'] = implode('
', $hospitalSectionParts); + + // patient_section : reuse the existing patient info table ({{content}}) + $tokens['patient_section'] = (string) ($tokens['content'] ?? ''); + + // content_section : all clinical blocks combined (same as {{RxFullBlock}}) + $tokens['content_section'] = (string) ($tokens['RxFullBlock'] ?? ''); + // ───────────────────────────────────────────────────────────────────── + return $tokens; } diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index a34a0ca..1fa860d 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\AbdmWorkTaskService; use App\Libraries\BridgeSyncService; use App\Models\OpdMedicineModel; use CodeIgniter\I18n\Time; @@ -7712,6 +7713,8 @@ OPD SNAPSHOT JSON: " . $payload; public function opd_prescription_print(int $opdId, int $opdSessionId, int $printType = 0) { + $this->createOpdPrescriptionWorkTask($opdId, $opdSessionId); + $printConfig = $this->resolvePrescriptionLayoutByDoctorField($opdId, $printType); $layoutMode = (string) ($printConfig['layout'] ?? 'content_only'); $templateKey = (string) ($printConfig['template'] ?? ''); @@ -7786,4 +7789,54 @@ OPD SNAPSHOT JSON: " . $payload; return ['layout' => $fallback, 'template' => '']; } + + private function createOpdPrescriptionWorkTask(int $opdId, int $opdSessionId): void + { + if ($opdId <= 0 || ! $this->db->tableExists('opd_master') || ! $this->db->tableExists('patient_master')) { + return; + } + + $opdRow = $this->db->table('opd_master')->select('opd_id,p_id')->where('opd_id', $opdId)->get(1)->getRowArray(); + if (empty($opdRow)) { + return; + } + + $patientId = (int) ($opdRow['p_id'] ?? 0); + if ($patientId <= 0) { + return; + } + + $patient = $this->db->table('patient_master') + ->select('p_fname,abha_id,abha_no,abha_address,abha') + ->where('id', $patientId) + ->get(1) + ->getRowArray(); + + $abhaId = trim((string) ($patient['abha_id'] ?? $patient['abha_no'] ?? $patient['abha_address'] ?? $patient['abha'] ?? '')); + if (! $this->isValidAbhaNumber($abhaId)) { + return; + } + + $taskService = new AbdmWorkTaskService(); + $taskService->createOrRefreshTask( + 'opd_prescription_publish', + 'opd_prescription', + 'opd', + (string) $opdId, + $patientId, + trim((string) ($patient['p_fname'] ?? '')), + $abhaId, + 'submit', + [ + 'opd_id' => $opdId, + 'opd_session_id' => $opdSessionId, + 'trigger' => 'opd.prescription.printed', + ] + ); + } + + private function isValidAbhaNumber(string $abhaId): bool + { + return preg_match('/^\d{14}$/', trim($abhaId)) === 1; + } } diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 4a145e4..8cdf39e 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -3,6 +3,8 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\AbdmWorkTaskService; +use App\Libraries\BridgeSyncService; use App\Models\BloodGroupModel; use App\Models\PatientModel; @@ -139,6 +141,9 @@ class Patient extends BaseController (string) $this->request->getPost('input_relative_name'), ]); + $this->enqueuePatientAbhaSync($insertId, $data, 'patient.created'); + $this->createPatientAbdmWorkTask($insertId, $data, $abhaId); + // Check Multiple UHID $relativeName = trim(strtoupper((string) $this->request->getPost('input_relative_name'))); $patientName = trim(strtoupper((string) $this->request->getPost('input_name'))); @@ -220,6 +225,56 @@ class Patient extends BaseController return view('billing/search_adv_data', ['search_result' => $searchResult]); } + public function abha_fetch_profile() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $abhaId = trim((string) $this->request->getPost('abha_id')); + if (! $this->isValidAbhaId($abhaId)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA ID must be a 14-digit number']); + } + + $profile = []; + $abhaField = $this->resolvePatientAbhaIdField(); + if ($abhaField !== null) { + $row = $this->db->table('patient_master') + ->select('id,p_fname,gender,dob,mphone1,city,state') + ->where($abhaField, $abhaId) + ->get(1) + ->getRowArray(); + + if (! empty($row)) { + $profile = [ + 'name' => (string) ($row['p_fname'] ?? ''), + 'gender' => (string) ($row['gender'] ?? ''), + 'dob' => (string) ($row['dob'] ?? ''), + 'mobile' => (string) ($row['mphone1'] ?? ''), + 'city' => (string) ($row['city'] ?? ''), + 'state' => (string) ($row['state'] ?? ''), + ]; + } + } + + $queueId = null; + try { + $bridge = new BridgeSyncService(); + $queueId = $bridge->enqueue('abdm.abha.profile.fetch.requested', [ + 'abha_id' => $abhaId, + 'requested_at' => date('Y-m-d H:i:s'), + ], 'abha', $abhaId); + } catch (\Throwable $e) { + } + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => $queueId, + 'profile' => $profile, + 'source' => ! empty($profile) ? 'local_cache' : 'abdm_queue', + ]); + } + public function search() { $sdata = (string) $this->request->getPost('txtsearch'); @@ -467,6 +522,8 @@ class Patient extends BaseController (string) $this->request->getPost('input_relative_name'), ]); + $this->enqueuePatientAbhaSync($pid, $data, 'patient.updated'); + return $this->response->setJSON([ 'update' => 1, 'showcontent' => 'Data Saved successfully', @@ -522,6 +579,7 @@ class Patient extends BaseController $patientModel = new PatientModel(); $patientModel->updatePatient($data, $pid); + $this->enqueuePatientAbhaSync($pid, $data, 'patient.abha.updated'); return $this->response->setJSON([ 'update' => 1, @@ -1063,5 +1121,70 @@ class Patient extends BaseController return preg_match('/^\d{14}$/', $value) === 1; } + /** + * @param array $patientData + */ + private function enqueuePatientAbhaSync(int $patientId, array $patientData, string $eventType): void + { + if ($patientId <= 0) { + return; + } + + $abhaField = $this->resolvePatientAbhaIdField(); + $abhaId = $abhaField !== null ? trim((string) ($patientData[$abhaField] ?? '')) : ''; + + $payload = [ + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'name' => trim((string) ($patientData['p_fname'] ?? '')), + 'mobile' => trim((string) ($patientData['mphone1'] ?? '')), + 'gender' => trim((string) ($patientData['gender'] ?? '')), + 'dob' => trim((string) ($patientData['dob'] ?? '')), + 'city' => trim((string) ($patientData['city'] ?? '')), + 'state' => trim((string) ($patientData['state'] ?? '')), + ]; + + try { + $bridgeSync = new BridgeSyncService(); + $bridgeSync->enqueue( + $eventType, + $payload, + 'patient', + (string) $patientId + ); + } catch (\Throwable $e) { + // Do not block patient workflows if queue service is unavailable. + } + } + + /** + * @param array $patientData + */ + private function createPatientAbdmWorkTask(int $patientId, array $patientData, string $abhaId): void + { + if ($patientId <= 0) { + return; + } + + $taskService = new AbdmWorkTaskService(); + $patientName = trim((string) ($patientData['p_fname'] ?? '')); + $actionMode = $abhaId !== '' ? 'update_abha' : 'create_abha'; + $taskType = $abhaId !== '' ? 'patient_abha_update' : 'patient_abha_create'; + + $taskService->createOrRefreshTask( + $taskType, + 'patient_registration', + 'patient', + (string) $patientId, + $patientId, + $patientName, + $abhaId, + $actionMode, + [ + 'trigger' => 'patient.created', + ] + ); + } + } diff --git a/app/Database/Migrations/2026-04-05-000039_CreateAbdmCoreTables.php b/app/Database/Migrations/2026-04-05-000039_CreateAbdmCoreTables.php new file mode 100644 index 0000000..e37cf53 --- /dev/null +++ b/app/Database/Migrations/2026-04-05-000039_CreateAbdmCoreTables.php @@ -0,0 +1,361 @@ +createAbdmApiLogsTable(); + $this->createAbdmConsentRecordsTable(); + $this->createAbdmWorkTasksTable(); + $this->createNhcxClaimDocumentsTable(); + } + + public function down(): void + { + $this->forge->dropTable('nhcx_claim_documents', true); + $this->forge->dropTable('abdm_work_tasks', true); + $this->forge->dropTable('abdm_consent_records', true); + $this->forge->dropTable('abdm_api_logs', true); + } + + private function createAbdmApiLogsTable(): void + { + if ($this->hasTable('abdm_api_logs')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'channel' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'default' => 'bridge', + ], + 'event_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'endpoint' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'http_method' => [ + 'type' => 'VARCHAR', + 'constraint' => 16, + 'null' => true, + ], + 'entity_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'entity_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 80, + 'null' => true, + ], + 'request_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'response_code' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'response_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'pending', + ], + 'error_message' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('channel'); + $this->forge->addKey('status'); + $this->forge->addKey('event_type'); + $this->forge->createTable('abdm_api_logs', true); + } + + private function createAbdmConsentRecordsTable(): void + { + if ($this->hasTable('abdm_consent_records')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'consent_handle' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => false, + ], + 'consent_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'requested', + ], + 'purpose_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => true, + ], + 'requested_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'granted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'raw_payload_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('patient_id'); + $this->forge->addKey('abha_id'); + $this->forge->addKey('consent_handle'); + $this->forge->addKey('consent_status'); + $this->forge->createTable('abdm_consent_records', true); + } + + private function createNhcxClaimDocumentsTable(): void + { + if ($this->hasTable('nhcx_claim_documents')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'ipd_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'case_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'claim_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'default' => 'institutional', + ], + 'claim_json' => [ + 'type' => 'LONGTEXT', + 'null' => false, + ], + 'claim_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'draft', + ], + 'external_ref' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'error_message' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'pushed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('ipd_id'); + $this->forge->addKey('case_id'); + $this->forge->addKey('patient_id'); + $this->forge->addKey('claim_status'); + $this->forge->createTable('nhcx_claim_documents', true); + } + + private function createAbdmWorkTasksTable(): void + { + if ($this->hasTable('abdm_work_tasks')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => false, + ], + 'task_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => false, + ], + 'source_module' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'entity_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'entity_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'patient_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => true, + ], + 'action_mode' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'pending', + ], + 'priority' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'normal', + ], + 'payload_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'last_action_result' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'completed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('task_code'); + $this->forge->addKey('task_type'); + $this->forge->addKey('status'); + $this->forge->addKey('patient_id'); + $this->forge->addKey(['entity_type', 'entity_id']); + $this->forge->createTable('abdm_work_tasks', true); + } + + private function hasTable(string $table): bool + { + if (! preg_match('/^[a-zA-Z0-9_]+$/', $table)) { + return false; + } + + $row = $this->db->query("SHOW TABLES LIKE '" . $table . "'")->getRowArray(); + return ! empty($row); + } +} diff --git a/app/Database/Migrations/2026-04-05-000040_AddAbdmWorkTasksTable.php b/app/Database/Migrations/2026-04-05-000040_AddAbdmWorkTasksTable.php new file mode 100644 index 0000000..bd9c589 --- /dev/null +++ b/app/Database/Migrations/2026-04-05-000040_AddAbdmWorkTasksTable.php @@ -0,0 +1,123 @@ +hasTable('abdm_work_tasks')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'task_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => false, + ], + 'task_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => false, + ], + 'source_module' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'entity_type' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'entity_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'patient_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'patient_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'abha_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => true, + ], + 'action_mode' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'pending', + ], + 'priority' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'normal', + ], + 'payload_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'last_action_result' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'completed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('task_code'); + $this->forge->addKey('task_type'); + $this->forge->addKey('status'); + $this->forge->addKey('patient_id'); + $this->forge->addKey(['entity_type', 'entity_id']); + $this->forge->createTable('abdm_work_tasks', true); + } + + public function down(): void + { + $this->forge->dropTable('abdm_work_tasks', true); + } + + private function hasTable(string $table): bool + { + if (! preg_match('/^[a-zA-Z0-9_]+$/', $table)) { + return false; + } + + $row = $this->db->query("SHOW TABLES LIKE '" . $table . "'")->getRowArray(); + return ! empty($row); + } +} diff --git a/app/Libraries/AbdmWorkTaskService.php b/app/Libraries/AbdmWorkTaskService.php new file mode 100644 index 0000000..aeccbb0 --- /dev/null +++ b/app/Libraries/AbdmWorkTaskService.php @@ -0,0 +1,223 @@ +db = db_connect(); + } + + /** + * @param array $meta + */ + public function createOrRefreshTask( + string $taskType, + string $sourceModule, + string $entityType, + string $entityId, + int $patientId, + string $patientName, + string $abhaId, + string $actionMode, + array $meta = [] + ): ?int { + if (! $this->db->tableExists('abdm_work_tasks')) { + return null; + } + + $entityId = trim($entityId); + if ($taskType === '' || $entityType === '' || $entityId === '') { + return null; + } + + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $payload = [ + 'task_type' => $taskType, + 'source_module' => $sourceModule, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'patient_id' => $patientId, + 'patient_name' => $patientName, + 'abha_id' => $abhaId, + 'action_mode' => $actionMode, + 'meta' => $meta, + ]; + + $existing = $this->db->table('abdm_work_tasks') + ->where('task_type', $taskType) + ->where('entity_type', $entityType) + ->where('entity_id', $entityId) + ->whereIn('status', ['pending', 'in_progress']) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $row = [ + 'source_module' => $sourceModule, + 'patient_id' => $patientId > 0 ? $patientId : null, + 'patient_name' => $patientName !== '' ? $patientName : null, + 'abha_id' => $abhaId !== '' ? $abhaId : null, + 'action_mode' => $actionMode !== '' ? $actionMode : null, + 'payload_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => $now, + ]; + + if (! empty($existing)) { + $this->db->table('abdm_work_tasks')->where('id', (int) $existing['id'])->update($row); + return (int) $existing['id']; + } + + $row['task_code'] = $this->generateTaskCode(); + $row['task_type'] = $taskType; + $row['entity_type'] = $entityType; + $row['entity_id'] = $entityId; + $row['status'] = 'pending'; + $row['priority'] = 'normal'; + $row['created_at'] = $now; + + $this->db->table('abdm_work_tasks')->insert($row); + $insertId = (int) $this->db->insertID(); + + return $insertId > 0 ? $insertId : null; + } + + /** + * @return array> + */ + public function getOpenTasks(int $limit = 200): array + { + if (! $this->db->tableExists('abdm_work_tasks')) { + return []; + } + + $this->syncPatientAbhaBacklog(max(200, $limit)); + + return $this->db->table('abdm_work_tasks') + ->whereIn('status', ['pending', 'in_progress']) + ->orderBy('id', 'DESC') + ->limit(max(1, $limit)) + ->get() + ->getResultArray(); + } + + public function markTaskStatus(int $taskId, string $status, string $resultText = ''): bool + { + if (! $this->db->tableExists('abdm_work_tasks') || $taskId <= 0) { + return false; + } + + $status = strtolower(trim($status)); + if (! in_array($status, ['pending', 'in_progress', 'completed', 'failed', 'cancelled'], true)) { + $status = 'pending'; + } + + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $row = [ + 'status' => $status, + 'last_action_result' => $resultText !== '' ? mb_substr($resultText, 0, 1000) : null, + 'updated_at' => $now, + 'completed_at' => in_array($status, ['completed', 'cancelled'], true) ? $now : null, + ]; + + return (bool) $this->db->table('abdm_work_tasks')->where('id', $taskId)->update($row); + } + + /** + * @return array|null + */ + public function getTask(int $taskId): ?array + { + if (! $this->db->tableExists('abdm_work_tasks') || $taskId <= 0) { + return null; + } + + $row = $this->db->table('abdm_work_tasks')->where('id', $taskId)->get(1)->getRowArray(); + return ! empty($row) ? $row : null; + } + + private function generateTaskCode(): string + { + return 'ABDM-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3))); + } + + private function syncPatientAbhaBacklog(int $limit): void + { + if (! $this->db->tableExists('patient_master')) { + return; + } + + $abhaField = $this->resolvePatientAbhaField(); + if ($abhaField === null) { + return; + } + + $select = 'id, p_fname'; + if ($abhaField !== '') { + $select .= ', ' . $abhaField; + } + + $rows = $this->db->table('patient_master') + ->select($select) + ->orderBy('id', 'DESC') + ->limit(max(1, $limit)) + ->get() + ->getResultArray(); + + if (empty($rows)) { + return; + } + + foreach ($rows as $row) { + $patientId = (int) ($row['id'] ?? 0); + if ($patientId <= 0) { + continue; + } + + $abhaId = trim((string) ($row[$abhaField] ?? '')); + if (preg_match('/^\d{14}$/', $abhaId) === 1) { + continue; + } + + $this->createOrRefreshTask( + 'patient_abha_create', + 'patient_registration', + 'patient', + (string) $patientId, + $patientId, + trim((string) ($row['p_fname'] ?? '')), + $abhaId, + 'create_abha', + [ + 'trigger' => 'patient.backlog.sync', + ] + ); + } + } + + private function resolvePatientAbhaField(): ?string + { + if (! $this->db->tableExists('patient_master')) { + return null; + } + + $fields = $this->db->getFieldNames('patient_master') ?? []; + foreach (['abha_id', 'abha_no', 'abha'] as $field) { + if (in_array($field, $fields, true)) { + return $field; + } + } + + if (in_array('abha_address', $fields, true)) { + return 'abha_address'; + } + + return null; + } +} diff --git a/app/Libraries/BridgeSyncService.php b/app/Libraries/BridgeSyncService.php index 5757432..feeee13 100644 --- a/app/Libraries/BridgeSyncService.php +++ b/app/Libraries/BridgeSyncService.php @@ -66,18 +66,6 @@ class BridgeSyncService return $summary; } - $endpoint = $this->readSetting('BRIDGE_SYNC_URL'); - if ($endpoint === '') { - $summary['message'] = 'BRIDGE_SYNC_URL is not configured'; - return $summary; - } - - $token = $this->readSetting('BRIDGE_SYNC_TOKEN'); - $source = $this->readSetting('BRIDGE_SOURCE_CODE'); - if ($source === '') { - $source = (string) ($this->readSetting('HOSPITAL_CODE') ?: 'hms-local'); - } - $rows = $this->db->table('bridge_sync_queue') ->whereIn('status', ['pending', 'retry']) ->groupStart() @@ -134,23 +122,33 @@ class BridgeSyncService $payload = ['raw' => (string) ($row['payload_json'] ?? '')]; } - $postBody = [ - 'queue_id' => $queueId, - 'source' => $source, - 'event_type' => (string) ($row['event_type'] ?? ''), - 'entity_type' => (string) ($row['entity_type'] ?? ''), - 'entity_id' => (string) ($row['entity_id'] ?? ''), - 'payload' => $payload, - 'occurred_at' => (string) ($row['created_at'] ?? Time::now('Asia/Kolkata')->toDateTimeString()), - ]; + $dispatch = $this->buildDispatchContext($row, $payload); + if (($dispatch['ok'] ?? false) !== true) { + $nextAttempts = $attempts + 1; + $isFinalFailure = $nextAttempts >= $maxAttempts; + $delaySeconds = min(3600, (int) pow(2, min(10, $nextAttempts)) * 30); + $reason = trim((string) ($dispatch['error'] ?? 'No dispatch configuration')); - $headers = ['Content-Type' => 'application/json']; - if ($token !== '') { - $headers['Authorization'] = 'Bearer ' . $token; + $this->db->table('bridge_sync_queue')->where('id', $queueId)->update([ + 'status' => $isFinalFailure ? 'failed' : 'retry', + 'attempts' => $nextAttempts, + 'next_attempt_at' => $isFinalFailure ? null : date('Y-m-d H:i:s', time() + $delaySeconds), + 'last_error' => mb_substr($reason, 0, 500), + 'locked_at' => null, + 'locked_by' => null, + ]); + $summary['failed']++; + continue; } + $channel = (string) ($dispatch['channel'] ?? 'bridge'); + $endpoint = (string) ($dispatch['endpoint'] ?? ''); + $method = strtoupper((string) ($dispatch['method'] ?? 'POST')); + $headers = (array) ($dispatch['headers'] ?? ['Content-Type' => 'application/json']); + $postBody = (array) ($dispatch['body'] ?? []); + try { - $response = $client->post($endpoint, [ + $response = $client->request($method, $endpoint, [ 'headers' => $headers, 'json' => $postBody, ]); @@ -158,6 +156,20 @@ class BridgeSyncService $status = $response->getStatusCode(); $body = (string) $response->getBody(); + $this->logApiCall( + $channel, + (string) ($row['event_type'] ?? ''), + $endpoint, + $method, + (string) ($row['entity_type'] ?? ''), + (string) ($row['entity_id'] ?? ''), + $postBody, + $status, + $body, + $status >= 200 && $status < 300 ? 'success' : 'error', + $status >= 200 && $status < 300 ? '' : ('HTTP ' . $status) + ); + if ($status >= 200 && $status < 300) { $this->db->table('bridge_sync_queue')->where('id', $queueId)->update([ 'status' => 'sent', @@ -184,6 +196,20 @@ class BridgeSyncService ]); $summary['failed']++; } catch (\Throwable $e) { + $this->logApiCall( + $channel, + (string) ($row['event_type'] ?? ''), + $endpoint, + $method, + (string) ($row['entity_type'] ?? ''), + (string) ($row['entity_id'] ?? ''), + $postBody, + null, + '', + 'error', + $e->getMessage() + ); + $nextAttempts = $attempts + 1; $isFinalFailure = $nextAttempts >= $maxAttempts; $delaySeconds = min(3600, (int) pow(2, min(10, $nextAttempts)) * 30); @@ -204,8 +230,58 @@ class BridgeSyncService return $summary; } + /** + * @param array $requestPayload + */ + private function logApiCall( + string $channel, + string $eventType, + string $endpoint, + string $method, + string $entityType, + string $entityId, + array $requestPayload, + ?int $responseCode, + string $responseBody, + string $status, + string $errorMessage + ): void { + if (! $this->db->tableExists('abdm_api_logs')) { + return; + } + + $requestJson = (string) json_encode($requestPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $decodedBody = json_decode($responseBody, true); + $responseJson = is_array($decodedBody) + ? (string) json_encode($decodedBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + : trim($responseBody); + + $this->db->table('abdm_api_logs')->insert([ + 'channel' => $channel !== '' ? $channel : 'bridge', + 'event_type' => $eventType, + 'endpoint' => $endpoint, + 'http_method' => strtoupper($method), + 'entity_type' => $entityType !== '' ? $entityType : null, + 'entity_id' => $entityId !== '' ? $entityId : null, + 'request_json' => $requestJson, + 'response_code' => $responseCode, + 'response_json' => $responseJson !== '' ? $responseJson : null, + 'status' => $status, + 'error_message' => $errorMessage !== '' ? mb_substr($errorMessage, 0, 1000) : null, + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + private function readSetting(string $name): string { + $envValue = getenv($name); + if ($envValue !== false) { + $value = trim((string) $envValue); + if ($value !== '') { + return $value; + } + } + if (defined($name)) { $value = trim((string) constant($name)); if ($value !== '') { @@ -225,4 +301,163 @@ class BridgeSyncService return trim((string) ($row['s_value'] ?? '')); } + + /** + * @param array $row + * @param array $payload + * @return array + */ + private function buildDispatchContext(array $row, array $payload): array + { + $eventType = (string) ($row['event_type'] ?? ''); + if ($eventType === '') { + return ['ok' => false, 'error' => 'Missing event_type']; + } + + $isAbdmOrNhcx = str_starts_with($eventType, 'abdm.') || str_starts_with($eventType, 'nhcx.'); + $provider = strtolower($this->readSetting('ABDM_SYNC_PROVIDER')); + if ($provider === '' || ! $isAbdmOrNhcx) { + $provider = strtolower($this->readSetting('BRIDGE_SYNC_PROVIDER')); + } + if ($provider === '') { + $provider = 'bridge'; + } + + if ($provider === 'eka' && $isAbdmOrNhcx) { + return $this->buildEkaDispatchContext($row, $payload); + } + + return $this->buildBridgeDispatchContext($row, $payload); + } + + /** + * @param array $row + * @param array $payload + * @return array + */ + private function buildBridgeDispatchContext(array $row, array $payload): array + { + $endpoint = $this->readSetting('BRIDGE_SYNC_URL'); + if ($endpoint === '') { + return ['ok' => false, 'error' => 'BRIDGE_SYNC_URL is not configured']; + } + + $token = $this->readSetting('BRIDGE_SYNC_TOKEN'); + $source = $this->readSetting('BRIDGE_SOURCE_CODE'); + if ($source === '') { + $source = (string) ($this->readSetting('HOSPITAL_CODE') ?: 'hms-local'); + } + + $headers = ['Content-Type' => 'application/json']; + if ($token !== '') { + $headers['Authorization'] = 'Bearer ' . $token; + } + + return [ + 'ok' => true, + 'channel' => 'bridge', + 'endpoint' => $endpoint, + 'method' => 'POST', + 'headers' => $headers, + 'body' => [ + 'queue_id' => (int) ($row['id'] ?? 0), + 'source' => $source, + 'event_type' => (string) ($row['event_type'] ?? ''), + 'entity_type' => (string) ($row['entity_type'] ?? ''), + 'entity_id' => (string) ($row['entity_id'] ?? ''), + 'payload' => $payload, + 'occurred_at' => (string) ($row['created_at'] ?? Time::now('Asia/Kolkata')->toDateTimeString()), + ], + ]; + } + + /** + * @param array $row + * @param array $payload + * @return array + */ + private function buildEkaDispatchContext(array $row, array $payload): array + { + $baseUrl = rtrim($this->readSetting('EKA_BASE_URL'), '/'); + if ($baseUrl === '') { + return ['ok' => false, 'error' => 'EKA_BASE_URL is not configured']; + } + + $eventType = (string) ($row['event_type'] ?? ''); + $eventPath = $this->resolveEkaEventPath($eventType); + if ($eventPath === '') { + return ['ok' => false, 'error' => 'No Eka endpoint mapping found for event ' . $eventType]; + } + + $token = $this->readSetting('EKA_BEARER_TOKEN'); + $apiKey = $this->readSetting('EKA_API_KEY'); + $clientId = $this->readSetting('EKA_CLIENT_ID'); + + $headers = ['Content-Type' => 'application/json']; + if ($token !== '') { + $headers['Authorization'] = 'Bearer ' . $token; + } elseif ($apiKey !== '') { + $headers['x-api-key'] = $apiKey; + } + if ($clientId !== '') { + $headers['x-client-id'] = $clientId; + } + + $source = (string) ($this->readSetting('HOSPITAL_CODE') ?: 'hms-local'); + + return [ + 'ok' => true, + 'channel' => 'eka', + 'endpoint' => $baseUrl . $eventPath, + 'method' => 'POST', + 'headers' => $headers, + 'body' => [ + 'queue_id' => (int) ($row['id'] ?? 0), + 'source' => $source, + 'event_type' => $eventType, + 'entity_type' => (string) ($row['entity_type'] ?? ''), + 'entity_id' => (string) ($row['entity_id'] ?? ''), + 'occurred_at' => (string) ($row['created_at'] ?? Time::now('Asia/Kolkata')->toDateTimeString()), + 'payload' => $payload, + ], + ]; + } + + private function resolveEkaEventPath(string $eventType): string + { + $mapping = [ + 'abdm.abha.validate' => '/api/v1/abdm/abha/validate', + 'abdm.abha.profile.fetch.requested' => '/api/v1/abdm/abha/profile', + 'abdm.abha.create.requested' => '/api/v1/abdm/abha/create', + 'abdm.abha.update.requested' => '/api/v1/abdm/abha/update', + 'abdm.scan_share.lookup' => '/api/v1/abdm/scan-share/lookup', + 'abdm.consent.requested' => '/api/v1/abdm/consents/request', + 'abdm.fhir.share.requested' => '/api/v1/abdm/health-information/share', + 'abdm.opd.prescription.share.requested' => '/api/v1/abdm/health-information/share', + 'abdm.ipd.admission.share.requested' => '/api/v1/abdm/health-information/ipd-admission/share', + 'abdm.ipd.discharge.share.requested' => '/api/v1/abdm/health-information/ipd-discharge/share', + 'abdm.diagnosis.report.share.requested' => '/api/v1/abdm/health-information/diagnosis-report/share', + 'nhcx.claim.created' => '/api/v1/nhcx/claims/create', + 'nhcx.claim.status.requested' => '/api/v1/nhcx/claims/status', + ]; + + $jsonMap = trim($this->readSetting('EKA_EVENT_ENDPOINTS_JSON')); + if ($jsonMap !== '') { + $decoded = json_decode($jsonMap, true); + if (is_array($decoded)) { + foreach ($decoded as $key => $value) { + if (is_string($key) && is_string($value) && $key !== '' && $value !== '') { + $mapping[$key] = $value; + } + } + } + } + + $path = trim((string) ($mapping[$eventType] ?? '')); + if ($path === '') { + return ''; + } + + return '/' . ltrim($path, '/'); + } } diff --git a/app/Libraries/FhirR4Builder.php b/app/Libraries/FhirR4Builder.php index 9c0c500..9bea4d8 100644 --- a/app/Libraries/FhirR4Builder.php +++ b/app/Libraries/FhirR4Builder.php @@ -166,6 +166,95 @@ class FhirR4Builder ]; } + /** + * @param array $patient + * @param array $encounter + * @param array $claim + * + * @return array + */ + public function buildClaimBundle(array $patient, array $encounter, array $claim): array + { + $issuedAt = Time::now('Asia/Kolkata')->toDateTimeString(); + $patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown'); + $encounterRef = 'Encounter/' . (string) ($encounter['id'] ?? 'unknown'); + + $claimId = (string) ($claim['id'] ?? ('claim-' . time())); + $items = is_array($claim['items'] ?? null) ? ($claim['items'] ?? []) : []; + + $claimItems = []; + foreach ($items as $index => $item) { + if (! is_array($item)) { + continue; + } + + $claimItems[] = [ + 'sequence' => $index + 1, + 'productOrService' => [ + 'text' => (string) ($item['name'] ?? $item['description'] ?? ''), + ], + 'quantity' => [ + 'value' => (float) ($item['qty'] ?? 1), + ], + 'unitPrice' => [ + 'value' => (float) ($item['unit_price'] ?? 0), + 'currency' => (string) ($item['currency'] ?? 'INR'), + ], + 'net' => [ + 'value' => (float) ($item['amount'] ?? 0), + 'currency' => (string) ($item['currency'] ?? 'INR'), + ], + ]; + } + + $claimResource = [ + 'resourceType' => 'Claim', + 'id' => $claimId, + 'status' => (string) ($claim['status'] ?? 'active'), + 'use' => (string) ($claim['use'] ?? 'claim'), + 'type' => [ + 'text' => (string) ($claim['type'] ?? 'institutional'), + ], + 'patient' => ['reference' => $patientRef], + 'created' => $issuedAt, + 'provider' => [ + 'display' => (string) ($claim['provider'] ?? ''), + ], + 'insurer' => [ + 'display' => (string) ($claim['insurer'] ?? ''), + ], + 'priority' => [ + 'text' => (string) ($claim['priority'] ?? 'normal'), + ], + 'item' => $claimItems, + 'total' => [ + 'value' => (float) ($claim['total'] ?? 0), + 'currency' => (string) ($claim['currency'] ?? 'INR'), + ], + 'encounter' => [[ + 'reference' => $encounterRef, + ]], + ]; + + return [ + 'resourceType' => 'Bundle', + 'type' => 'collection', + 'timestamp' => $issuedAt, + 'entry' => [[ + 'resource' => $this->buildPatientResource($patient), + ], [ + 'resource' => [ + 'resourceType' => 'Encounter', + 'id' => (string) ($encounter['id'] ?? 'unknown'), + 'status' => (string) ($encounter['status'] ?? 'finished'), + 'subject' => ['reference' => $patientRef], + ], + ], [ + 'resource' => $claimResource, + ]], + ]; + } + /** * @param array $patient * diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php new file mode 100644 index 0000000..fe7b06a --- /dev/null +++ b/app/Views/abdm/task_board.php @@ -0,0 +1,269 @@ + + + + + + ABDM Work Task Board + + + + +
+
+
+

ABDM Work Task Board

+
Unupdated ABHA Patient List + Separate ABDM Task Queues
+
+ +
+ +
+ + + + + + + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
IDTaskPatientABHAEntityStatusAction
+
+
+
+
+
#
+
+
+
+
+
+ + + + + + + + + +
+
+ + + + +
Ready
+ + + + + + + + diff --git a/app/Views/billing/Patient_V.php b/app/Views/billing/Patient_V.php index d41b7d0..8737c91 100644 --- a/app/Views/billing/Patient_V.php +++ b/app/Views/billing/Patient_V.php @@ -280,6 +280,51 @@ }); }); + $('#input_abha_id').on('change blur', function() { + var abhaId = ($(this).val() || '').toString().trim(); + if (!/^\d{14}$/.test(abhaId)) { + return; + } + + $.post('', { + abha_id: abhaId, + '': $('input[name=""]').val() + }, function(resp) { + if (!resp || resp.ok != 1 || !resp.profile) { + return; + } + + var p = resp.profile || {}; + if (p.name && !$('#input_name').val()) { + $('#input_name').val(p.name); + } + if (p.mobile && !$('#input_mphone1').val()) { + $('#input_mphone1').val(p.mobile); + } + if (p.city && !$('#input_city').val()) { + $('#input_city').val(p.city); + } + if (p.state && !$('#input_state').val()) { + $('#input_state').val(p.state); + } + if (p.dob && !$('#datepicker_dob').val()) { + $('#datepicker_dob').val(p.dob); + if ($('#chk_age').is(':checked')) { + $('#chk_age').prop('checked', false).trigger('click'); + } + } + + if (p.gender) { + var g = (p.gender + '').toLowerCase(); + if (g === '1' || g === 'male' || g === 'm') { + $('#options_gender1').prop('checked', true); + } else if (g === '2' || g === 'female' || g === 'f') { + $('#options_gender2').prop('checked', true); + } + } + }, 'json'); + }); + $("#input_city").autocomplete({ source: "", minLength: 3, diff --git a/app/Views/billing/ipd/discharge_create.php b/app/Views/billing/ipd/discharge_create.php index a2f8700..bde0a8b 100644 --- a/app/Views/billing/ipd/discharge_create.php +++ b/app/Views/billing/ipd/discharge_create.php @@ -22,6 +22,18 @@ if ($person) { )); } +$patientId = (int) ($person->id ?? $person->p_id ?? 0); +$patientAbha = ''; +if ($person) { + $patientAbha = trim((string) ( + $person->abha_id + ?? $person->abha_no + ?? $person->abha_address + ?? $person->abha + ?? '' + )); +} + $age = ''; if ($person) { $age = get_age_1($person->dob ?? null, $person->age ?? '', $person->age_in_month ?? '', $person->estimate_dob ?? ''); @@ -615,6 +627,41 @@ $allergyStatusNoKnown = in_array($allergyStatusNormalized, ['no known drug aller
+ +
+
ABDM Sandbox Actions
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
Ready
+
+
@@ -3858,6 +3905,131 @@ $allergyStatusNoKnown = in_array($allergyStatusNormalized, ['no known drug aller } }, { passive: true, capture: true }); syncNavOnScroll(); + + // ─── ABDM Sandbox Actions (IPD Discharge) ───────────────────────────── + (function initIpdAbdmActions() { + var csrfName = ''; + var csrfHash = ''; + var ipdId = ; + var patientId = ; + + function setStatus(msg, isError) { + var el = document.getElementById('ipd_abdm_action_status'); + if (!el) { return; } + el.textContent = msg; + el.style.color = isError ? '#dc3545' : '#6c757d'; + } + + function abdmPost(url, payload, onSuccess) { + setStatus('Sending…'); + var data = Object.assign({}, payload); + data[csrfName] = csrfHash; + $.ajax({ + url: url, + type: 'POST', + data: data, + dataType: 'json' + }).done(function(resp) { + // refresh CSRF token if returned + if (resp && resp.csrf_hash) { csrfHash = resp.csrf_hash; } + if (resp && resp.csrf_token) { csrfName = resp.csrf_token; } + onSuccess(resp); + }).fail(function(xhr) { + setStatus('Request failed: ' + xhr.status + ' ' + xhr.statusText, true); + }); + } + + // Scan & Share Lookup + var btnScan = document.getElementById('btn_ipd_abdm_scan_lookup'); + if (btnScan) { + btnScan.addEventListener('click', function() { + var qrPayload = (document.getElementById('ipd_abdm_qr_payload') || {}).value || ''; + if (!qrPayload.trim()) { setStatus('Enter QR payload first.', true); return; } + abdmPost('', { + qr_payload: qrPayload, + patient_id: patientId + }, function(resp) { + if (resp && resp.abha_address) { + var addr = document.getElementById('ipd_abha_address'); + if (addr) { addr.value = resp.abha_address; } + } + setStatus(resp && resp.message ? resp.message : 'Lookup done.'); + }); + }); + } + + // Validate ABHA + var btnVal = document.getElementById('btn_ipd_abdm_validate_abha'); + if (btnVal) { + btnVal.addEventListener('click', function() { + var abhaAddr = (document.getElementById('ipd_abha_address') || {}).value || ''; + if (!abhaAddr.trim()) { setStatus('Enter ABHA Address first.', true); return; } + abdmPost('', { + abha_address: abhaAddr, + patient_id: patientId + }, function(resp) { + setStatus(resp && resp.message ? resp.message : (resp && resp.ok ? 'ABHA valid.' : 'Validation failed.'), !(resp && resp.ok)); + }); + }); + } + + // Request Consent + var btnConsent = document.getElementById('btn_ipd_abdm_consent_request'); + if (btnConsent) { + btnConsent.addEventListener('click', function() { + var abhaAddr = (document.getElementById('ipd_abha_address') || {}).value || ''; + var purpose = (document.getElementById('ipd_abdm_purpose_code') || {}).value || 'CAREMGT'; + var expiresAt = (document.getElementById('ipd_abdm_consent_expires_at') || {}).value || ''; + if (!abhaAddr.trim()) { setStatus('Enter ABHA Address first.', true); return; } + abdmPost('', { + abha_address: abhaAddr, + patient_id: patientId, + purpose_code: purpose, + expires_at: expiresAt + }, function(resp) { + if (resp && resp.consent_handle) { + var ch = document.getElementById('ipd_abdm_consent_handle'); + if (ch) { ch.value = resp.consent_handle; } + } + setStatus(resp && resp.message ? resp.message : (resp && resp.ok ? 'Consent requested.' : 'Consent request failed.'), !(resp && resp.ok)); + }); + }); + } + + // Share Discharge FHIR Bundle + var btnShare = document.getElementById('btn_ipd_abdm_share_discharge'); + if (btnShare) { + btnShare.addEventListener('click', function() { + var abhaAddr = (document.getElementById('ipd_abha_address') || {}).value || ''; + var consentHandle = (document.getElementById('ipd_abdm_consent_handle') || {}).value || ''; + if (!abhaAddr.trim()) { setStatus('Enter ABHA Address first.', true); return; } + abdmPost('', { + ipd_id: ipdId, + patient_id: patientId, + abha_id: abhaAddr, + consent_handle: consentHandle + }, function(resp) { + if (resp && resp.consent_handle) { + var ch = document.getElementById('ipd_abdm_consent_handle'); + if (ch && !ch.value) { ch.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))); + }); + }); + } + + // Set default consent expiry to +30 days + (function setDefaultExpiry() { + var el = document.getElementById('ipd_abdm_consent_expires_at'); + if (!el || el.value) { return; } + var d = new Date(); + d.setDate(d.getDate() + 30); + var iso = d.toISOString().slice(0, 16); + el.value = iso; + })(); + })(); + // ─── end ABDM Sandbox Actions ────────────────────────────────────────── + })(); diff --git a/app/Views/billing/opd_prescription_basic.php b/app/Views/billing/opd_prescription_basic.php index be67ef6..cf79436 100644 --- a/app/Views/billing/opd_prescription_basic.php +++ b/app/Views/billing/opd_prescription_basic.php @@ -308,6 +308,48 @@ value="abha_address ?? $patient_master[0]->abha ?? '') ?>" placeholder="Enter ABHA Address"> +
+
ABDM Sandbox Actions
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+
+ +
+
+ +
+
+ +
Ready
+
+
OPD: opd_code ?? '') ?>
Date: apointment_date ?? '') ?>
Doctor: doc_name ?? '') ?>
@@ -1105,6 +1147,30 @@ cardiac: ['ECG', 'Lipid Profile', 'CBC'], viral_infection: ['CBC', 'LFT', 'KFT'] }; + var patientId = id ?? 0) ?>; + + function setAbdmStatus(msg, level) { + var $box = $('#abdm_action_status'); + if (!$box.length) { + return; + } + level = (level || 'muted').toString(); + $box.removeClass('text-muted text-success text-danger text-warning'); + if (level === 'success') { + $box.addClass('text-success'); + } else if (level === 'danger') { + $box.addClass('text-danger'); + } else if (level === 'warning') { + $box.addClass('text-warning'); + } else { + $box.addClass('text-muted'); + } + $box.text(msg || 'Ready'); + } + + function abdmCurrentAbha() { + return ($('#abha_address').val() || '').toString().trim(); + } function alignPanelOrderToExample() { var $medicine = $('#panel_medicine'); @@ -3390,6 +3456,109 @@ scheduleAutoSave(); }); + $('#btn_abdm_scan_lookup').on('click', function() { + var qrPayload = ($('#abdm_qr_payload').val() || '').toString().trim(); + if (!qrPayload) { + setAbdmStatus('Paste QR payload first.', 'warning'); + return; + } + setAbdmStatus('Queuing Scan & Share lookup...', 'muted'); + apiPost('', { + qr_payload: qrPayload + }, function(data) { + if (data.ok != 1) { + setAbdmStatus(data.error_text || 'Scan lookup failed.', 'danger'); + return; + } + if (data.abha_id_hint) { + $('#abha_address').val(data.abha_id_hint).trigger('input'); + } + setAbdmStatus('Scan lookup queued. Queue ID: ' + (data.queue_id || '-'), 'success'); + }); + }); + + $('#btn_abdm_validate_abha').on('click', function() { + var abha = abdmCurrentAbha(); + if (!abha) { + setAbdmStatus('Enter ABHA first.', 'warning'); + return; + } + setAbdmStatus('Queuing ABHA validation...', 'muted'); + apiPost('', { + abha_id: abha + }, function(data) { + if (data.ok != 1) { + setAbdmStatus(data.error_text || 'ABHA validation queue failed.', 'danger'); + return; + } + setAbdmStatus('ABHA validation queued. Queue ID: ' + (data.queue_id || '-'), 'success'); + }); + }); + + $('#btn_abdm_consent_request').on('click', function() { + var abha = abdmCurrentAbha(); + if (!abha) { + setAbdmStatus('Enter ABHA first.', 'warning'); + return; + } + setAbdmStatus('Requesting consent...', 'muted'); + apiPost('', { + patient_id: patientId, + abha_id: abha, + purpose_code: ($('#abdm_purpose_code').val() || '').toString().trim(), + expires_at: ($('#abdm_consent_expires_at').val() || '').toString().trim().replace('T', ' ') + }, function(data) { + if (data.ok != 1) { + setAbdmStatus(data.error_text || 'Consent request failed.', 'danger'); + return; + } + if (data.consent_handle) { + $('#abdm_consent_handle').val(data.consent_handle); + } + setAbdmStatus('Consent requested. Handle: ' + (data.consent_handle || '-'), 'success'); + }); + }); + + $('#btn_abdm_share_fhir').on('click', function() { + var abha = abdmCurrentAbha(); + if (!abha) { + setAbdmStatus('Enter ABHA first.', 'warning'); + return; + } + setAbdmStatus('Checking consent and queuing FHIR share...', 'muted'); + apiPost('', { + opd_id: ($('#opd_id').val() || '0').toString(), + opd_session_id: ($('#opd_session_id').val() || '0').toString(), + patient_id: patientId, + abha_id: abha, + consent_handle: ($('#abdm_consent_handle').val() || '').toString().trim() + }, function(data) { + if (data.ok != 1) { + setAbdmStatus(data.error_text || 'FHIR share queue failed.', 'danger'); + return; + } + setAbdmStatus('FHIR share queued. Queue ID: ' + (data.queue_id || '-'), 'success'); + }); + }); + + $('#btn_abdm_claim_status').on('click', function() { + var docId = parseInt(($('#abdm_claim_document_id').val() || '0').toString(), 10) || 0; + if (docId <= 0) { + setAbdmStatus('Enter Claim Document ID first.', 'warning'); + return; + } + setAbdmStatus('Queuing claim status request...', 'muted'); + apiPost('', { + document_id: docId + }, function(data) { + if (data.ok != 1) { + setAbdmStatus(data.error_text || 'Claim status queue failed.', 'danger'); + return; + } + setAbdmStatus('Claim status queued. Queue ID: ' + (data.queue_id || '-'), 'success'); + }); + }); + $('.rx-instant, #pregnancy, #lactation, #liver_insufficiency, #renal_insufficiency, #pulmonary_insufficiency, #corona_suspected, #dengue, #is_smoking, #is_alcohol, #is_drug_abuse, input[name="morbidities"], input[name="options-pain"]').on('input change', function() { markDirty('Clinical details updated'); scheduleAutoSave(); diff --git a/app/Views/diagnosis/lab_final_report_editor.php b/app/Views/diagnosis/lab_final_report_editor.php index 8bdaeb7..8ce45ce 100644 --- a/app/Views/diagnosis/lab_final_report_editor.php +++ b/app/Views/diagnosis/lab_final_report_editor.php @@ -130,6 +130,36 @@ $editReason = trim((string) ($edit_reason ?? '')); + +
+ +
+
ABDM – Share Report
+
+
+ + +
+
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+ +
Ready
+
+
@@ -413,6 +443,97 @@ $editReason = trim((string) ($edit_reason ?? '')); if (inst) inst.hide(); } } + + // ─── ABDM Lab/Radiology Report Share ────────────────────────────── + (function initDiagAbdmActions() { + var csrfName = ''; + var csrfHash = ''; + var labReqId = id ?? 0) ?>; + var patientId = patient_id ?? 0) ?>; + + function setStatus(msg, isError) { + var el = document.getElementById('diag_abdm_action_status'); + if (!el) { return; } + el.textContent = msg; + el.style.color = isError ? '#dc3545' : '#6c757d'; + } + + function abdmPost(url, payload, onSuccess) { + setStatus('Sending…'); + var data = Object.assign({}, payload); + data[csrfName] = csrfHash; + fetch(url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(data).toString() + }) + .then(function(r) { return r.json(); }) + .then(function(resp) { + if (resp && resp.csrf_hash) { csrfHash = resp.csrf_hash; } + if (resp && resp.csrf_token) { csrfName = resp.csrf_token; } + onSuccess(resp); + }) + .catch(function(e) { setStatus('Request failed: ' + e.message, true); }); + } + + var btnValidate = document.getElementById('btn_diag_abdm_validate_abha'); + if (btnValidate) { + btnValidate.addEventListener('click', function() { + var abha = (document.getElementById('diag_abha_address') || {}).value || ''; + if (!abha.trim()) { setStatus('Enter ABHA Address first.', true); return; } + abdmPost(baseUrl + 'AbdmGateway/abha_validate', { abha_address: abha, patient_id: patientId }, function(resp) { + setStatus(resp && resp.message ? resp.message : (resp && resp.ok ? 'ABHA valid.' : 'Validation failed.'), !(resp && resp.ok)); + }); + }); + } + + var btnConsent = document.getElementById('btn_diag_abdm_consent_request'); + if (btnConsent) { + btnConsent.addEventListener('click', function() { + var abha = (document.getElementById('diag_abha_address') || {}).value || ''; + var purpose = (document.getElementById('diag_abdm_purpose_code') || {}).value || 'CAREMGT'; + var expires = (document.getElementById('diag_abdm_consent_expires_at') || {}).value || ''; + if (!abha.trim()) { setStatus('Enter ABHA Address first.', true); return; } + abdmPost(baseUrl + 'AbdmGateway/consent_request', { + abha_address: abha, patient_id: patientId, purpose_code: purpose, expires_at: expires + }, function(resp) { + if (resp && resp.consent_handle) { + var ch = document.getElementById('diag_abdm_consent_handle'); + if (ch) { ch.value = resp.consent_handle; } + } + setStatus(resp && resp.message ? resp.message : (resp && resp.ok ? 'Consent requested.' : 'Consent request failed.'), !(resp && resp.ok)); + }); + }); + } + + var btnShare = document.getElementById('btn_diag_abdm_share_report'); + if (btnShare) { + btnShare.addEventListener('click', function() { + var abha = (document.getElementById('diag_abha_address') || {}).value || ''; + var ch = (document.getElementById('diag_abdm_consent_handle') || {}).value || ''; + if (!abha.trim()) { setStatus('Enter ABHA Address first.', true); return; } + abdmPost(baseUrl + 'AbdmGateway/share_diagnosis_report_bundle', { + lab_req_id: labReqId, patient_id: patientId, abha_id: abha, consent_handle: ch + }, function(resp) { + if (resp && resp.consent_handle) { + 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))); + }); + }); + } + + // Default consent expiry +30 days + (function() { + var el = document.getElementById('diag_abdm_consent_expires_at'); + if (!el || el.value) { return; } + var d = new Date(); d.setDate(d.getDate() + 30); + el.value = d.toISOString().slice(0, 16); + })(); + })(); + // ─── end ABDM ───────────────────────────────────────────────────── + diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index a9e4f4a..2acbb4c 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -286,6 +286,14 @@ if (! $canDiagnosis && $user && method_exists($user, 'inGroup')) { + + +