diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 896f5a6..50a1ff1 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -6009,7 +6009,8 @@ class AbdmGateway extends BaseController string $patientName, string $taskType = '', string $entityId = '', - int $taskId = 0 + int $taskId = 0, + string $source = '' ): array { $this->db = $this->db ?? \Config\Database::connect(); $careContextsV3 = []; @@ -6113,6 +6114,36 @@ class AbdmGateway extends BaseController 'is_primary' => true, ]); } + } elseif (in_array($taskType, ['invoice_publish', 'invoice_record_publish'], true)) { + $invoicePayload = null; + $candidateSources = []; + if ($source !== '' && in_array($source, ['opd_invoice', 'charges_invoice', 'ipd_invoice'], true)) { + $candidateSources[] = $source; + } + $candidateSources = array_values(array_unique(array_merge($candidateSources, ['opd_invoice', 'charges_invoice', 'ipd_invoice']))); + + foreach ($candidateSources as $candSource) { + try { + $invoicePayload = $this->buildInvoiceSourceRecordPayload($candSource, $taskEntityId, $patientId, ''); + if ($invoicePayload !== null) { + break; + } + } catch (\Throwable $e) { + // ignore and try next candidate source + } + } + + if ($invoicePayload !== null) { + $addContext([ + 'careContextId' => (string) $invoicePayload['care_context_reference'], + 'referenceNumber' => (string) $invoicePayload['care_context_reference'], + 'display' => (string) $invoicePayload['care_context_display'], + 'record_type' => 'InvoiceRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => true, + ]); + } } } @@ -6344,6 +6375,98 @@ class AbdmGateway extends BaseController } } + // 6b. Query recent invoices / billing records for this patient + if ($this->db->tableExists('opd_master')) { + $opdFields = $this->db->getFieldNames('opd_master') ?? []; + if (in_array('opd_fee_amount', $opdFields, true)) { + $codeCol = in_array('opd_code', $opdFields, true) ? 'opd_code' : 'opd_id'; + $opdInvoices = $this->db->table('opd_master') + ->select('opd_id, ' . $codeCol . ' as opd_code, p_id, apointment_date, opd_book_date, opd_fee_amount') + ->where('p_id', $patientId) + ->where('opd_fee_amount >', 0) + ->orderBy('opd_id', 'DESC') + ->limit(10) + ->get() + ->getResultArray(); + foreach ($opdInvoices as $oi) { + $rawDate = ! empty($oi['apointment_date']) && $oi['apointment_date'] !== '0000-00-00 00:00:00' + ? $oi['apointment_date'] + : (! empty($oi['opd_book_date']) ? $oi['opd_book_date'] : date('Y-m-d')); + $visitDate = date('Y-m-d', strtotime((string) $rawDate)); + $ccRef = 'INVOICE-OPD-' . $oi['opd_id'] . '-' . $visitDate; + $display = 'Invoice ' . ((string) ($oi['opd_code'] ?: $oi['opd_id'])) . ' (OPD - Rs ' . number_format((float) $oi['opd_fee_amount'], 2) . ')'; + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'InvoiceRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); + } + } + } + if ($this->db->tableExists('invoice_master')) { + $chgFields = $this->db->getFieldNames('invoice_master') ?? []; + if (in_array('net_amount', $chgFields, true) && in_array('attach_id', $chgFields, true)) { + $invCodeCol = in_array('invoice_code', $chgFields, true) ? 'invoice_code' : 'id'; + $dateCol = in_array('inv_date', $chgFields, true) ? 'inv_date' : 'id'; + $attachTypeClause = in_array('attach_type', $chgFields, true); + $builder = $this->db->table('invoice_master') + ->select('id, ' . $invCodeCol . ' as invoice_code, attach_id, ' . $dateCol . ' as inv_date, net_amount') + ->where('attach_id', $patientId); + if ($attachTypeClause) { + $builder->where('attach_type', 0); + } + $chgInvoices = $builder->orderBy('id', 'DESC')->limit(10)->get()->getResultArray(); + foreach ($chgInvoices as $ci) { + $rawDate = ! empty($ci['inv_date']) ? $ci['inv_date'] : date('Y-m-d'); + $visitDate = date('Y-m-d', strtotime((string) $rawDate)); + $ccRef = 'INVOICE-CHG-' . $ci['id'] . '-' . $visitDate; + $display = 'Invoice ' . ((string) ($ci['invoice_code'] ?: $ci['id'])) . ' (Charges - Rs ' . number_format((float) $ci['net_amount'], 2) . ')'; + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'InvoiceRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); + } + } + } + if ($this->db->tableExists('ipd_master')) { + $ipdFields = $this->db->getFieldNames('ipd_master') ?? []; + if (in_array('net_amount', $ipdFields, true)) { + $ipdCodeCol = in_array('ipd_code', $ipdFields, true) ? 'ipd_code' : 'id'; + $ipdInvoices = $this->db->table('ipd_master') + ->select('id, ' . $ipdCodeCol . ' as ipd_code, p_id, discharge_date, register_date, net_amount') + ->where('p_id', $patientId) + ->where('net_amount >', 0) + ->orderBy('id', 'DESC') + ->limit(5) + ->get() + ->getResultArray(); + foreach ($ipdInvoices as $ii) { + $rawDate = ! empty($ii['discharge_date']) ? $ii['discharge_date'] : (! empty($ii['register_date']) ? $ii['register_date'] : date('Y-m-d')); + $visitDate = date('Y-m-d', strtotime((string) $rawDate)); + $ccRef = 'INVOICE-IPD-' . $ii['id'] . '-' . $visitDate; + $display = 'Invoice ' . ((string) ($ii['ipd_code'] ?: $ii['id'])) . ' (IPD - Rs ' . number_format((float) $ii['net_amount'], 2) . ')'; + $addContext([ + 'careContextId' => $ccRef, + 'referenceNumber' => $ccRef, + 'display' => $display, + 'record_type' => 'InvoiceRecord', + 'patient_id' => $patientId, + 'is_fhir_ready' => true, + 'is_primary' => false, + ]); + } + } + } + // 7. Fallback: default registration file care context if (empty($careContextsV3)) { $ccRef = 'REG-' . $patientRef; @@ -10913,6 +11036,7 @@ class AbdmGateway extends BaseController $taskType = ''; $entityId = ''; $taskId = 0; + $source = ''; if (method_exists($this->request, 'getVar')) { $patientId = (int) ($this->request->getVar('patient_id') ?? 0); $abhaAddress = trim((string) ($this->request->getVar('abha_address') ?? '')); @@ -10920,6 +11044,7 @@ class AbdmGateway extends BaseController $taskType = trim((string) ($this->request->getVar('task_type') ?? '')); $entityId = trim((string) ($this->request->getVar('entity_id') ?? '')); $taskId = (int) ($this->request->getVar('task_id') ?? 0); + $source = trim((string) ($this->request->getVar('source') ?? '')); } if ($patientId <= 0 && ! empty($_REQUEST['patient_id'])) { $patientId = (int) $_REQUEST['patient_id']; @@ -10939,6 +11064,9 @@ class AbdmGateway extends BaseController if ($taskId <= 0 && ! empty($_REQUEST['task_id'])) { $taskId = (int) $_REQUEST['task_id']; } + if ($source === '' && ! empty($_REQUEST['source'])) { + $source = trim((string) $_REQUEST['source']); + } if ($patientId <= 0 && $abhaAddress === '' && $pCode === '') { return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id, abha_address, or p_code is required']); @@ -11000,7 +11128,7 @@ class AbdmGateway extends BaseController $rawPhone = trim((string) ($patient['mphone1'] ?? $patient['mphone2'] ?? '')); $cleanPhone = substr(preg_replace('/\D/', '', $rawPhone), -10); - [$careContextsV3, $careContextsFull] = $this->findCareContextsForPatient($patientId, $patientRef, $patientName, $taskType, $entityId, $taskId); + [$careContextsV3, $careContextsFull] = $this->findCareContextsForPatient($patientId, $patientRef, $patientName, $taskType, $entityId, $taskId, $source); // Fetch already-linked care contexts from Bridge if ABHA address is present $linkedRefs = []; diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index df6c28c..d4c0d7c 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -695,9 +695,13 @@ class AbdmTaskBoard extends BaseController $rows = []; if ($this->db->tableExists('opd_master')) { - foreach ($this->db->table('opd_master') - ->select('opd_id AS bill_id, opd_code AS bill_code, p_id AS patient_id, P_name AS patient_name, apointment_date AS bill_date, opd_fee_amount AS amount') - ->orderBy('opd_id', 'DESC') + $builder = $this->db->table('opd_master o') + ->select("o.opd_id AS bill_id, o.opd_code AS bill_code, o.p_id AS patient_id, COALESCE(NULLIF(o.P_name, ''), p.p_fname, '') AS patient_name, o.apointment_date AS bill_date, o.opd_fee_amount AS amount, COALESCE(NULLIF(p.abha_address, ''), NULLIF(p.abha_id, ''), '') AS abha_id", false); + if ($this->db->tableExists('patient_master')) { + $builder->join('patient_master p', 'p.id = o.p_id', 'left'); + } + foreach ($builder + ->orderBy('o.opd_id', 'DESC') ->limit(100) ->get() ->getResultArray() as $row) { @@ -706,9 +710,12 @@ class AbdmTaskBoard extends BaseController } if ($this->db->tableExists('invoice_master')) { - foreach ($this->db->table('invoice_master i') - ->select('i.id AS bill_id, i.invoice_code AS bill_code, i.attach_id AS patient_id, COALESCE(NULLIF(i.inv_name, ""), p.p_fname) AS patient_name, i.inv_date AS bill_date, i.net_amount AS amount') - ->join('patient_master p', 'p.id = i.attach_id AND i.attach_type = 0', 'left') + $builder = $this->db->table('invoice_master i') + ->select("i.id AS bill_id, i.invoice_code AS bill_code, i.attach_id AS patient_id, COALESCE(NULLIF(i.inv_name, ''), p.p_fname, '') AS patient_name, i.inv_date AS bill_date, i.net_amount AS amount, COALESCE(NULLIF(p.abha_address, ''), NULLIF(p.abha_id, ''), '') AS abha_id", false); + if ($this->db->tableExists('patient_master')) { + $builder->join('patient_master p', 'p.id = i.attach_id AND i.attach_type = 0', 'left'); + } + foreach ($builder ->orderBy('i.id', 'DESC') ->limit(100) ->get() @@ -718,9 +725,12 @@ class AbdmTaskBoard extends BaseController } if ($this->db->tableExists('ipd_master')) { - foreach ($this->db->table('ipd_master i') - ->select("i.id AS bill_id, i.ipd_code AS bill_code, i.p_id AS patient_id, COALESCE(NULLIF(NULLIF(TRIM(i.P_name), ''), '0'), NULLIF(TRIM(p.p_fname), '')) AS patient_name, COALESCE(i.discharge_date, i.register_date) AS bill_date, i.net_amount AS amount", false) - ->join('patient_master p', 'p.id = i.p_id', 'left') + $builder = $this->db->table('ipd_master i') + ->select("i.id AS bill_id, i.ipd_code AS bill_code, i.p_id AS patient_id, COALESCE(NULLIF(NULLIF(TRIM(i.P_name), ''), '0'), NULLIF(TRIM(p.p_fname), ''), '') AS patient_name, COALESCE(i.discharge_date, i.register_date) AS bill_date, i.net_amount AS amount, COALESCE(NULLIF(p.abha_address, ''), NULLIF(p.abha_id, ''), '') AS abha_id", false); + if ($this->db->tableExists('patient_master')) { + $builder->join('patient_master p', 'p.id = i.p_id', 'left'); + } + foreach ($builder ->orderBy('i.id', 'DESC') ->limit(100) ->get() @@ -819,13 +829,17 @@ class AbdmTaskBoard extends BaseController $statusTone = 'danger'; } + $billDate = ! empty($row['bill_date']) ? date('Y-m-d', strtotime((string) $row['bill_date'])) : date('Y-m-d'); + $sourcePrefix = $row['source_key'] === 'opd_invoice' ? 'OPD' : ($row['source_key'] === 'ipd_invoice' ? 'IPD' : 'CHG'); + $defaultCareContext = 'INVOICE-' . $sourcePrefix . '-' . $billId . '-' . $billDate; + $row['record_status_label'] = $statusLabel; $row['record_status_tone'] = $statusTone; $row['push_status'] = $pushStatus; $row['link_status'] = $linkStatus; $row['queue_id'] = trim((string) ($hr['abdm_txn_id'] ?? '')); $row['bridge_record_id'] = (int) ($hr['bridge_record_id'] ?? 0); - $row['care_context_reference'] = trim((string) ($hr['care_context_reference'] ?? '')); + $row['care_context_reference'] = trim((string) ($hr['care_context_reference'] ?? '')) ?: $defaultCareContext; } unset($row); diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index 6bbb194..03679d2 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -474,6 +474,8 @@ data-invoice-source="" data-bill-id="" data-patient-id="" + data-abha-id="" + data-care-context="" data-push-status="" > @@ -481,6 +483,9 @@
# + +
+ @@ -497,7 +502,17 @@ Linked - + @@ -3512,19 +3527,82 @@ }); }); - document.querySelectorAll('.btn-opd-care-context-link, .btn-invoice-care-link').forEach(function (btn) { + document.querySelectorAll('.btn-opd-care-context-link').forEach(function (btn) { btn.addEventListener('click', function () { + var row = btn.closest('tr'); var patientId = parseInt(btn.getAttribute('data-patient-id') || '0', 10); var abhaId = (btn.getAttribute('data-abha-id') || '').trim(); var patientName = (btn.getAttribute('data-patient-name') || '').trim(); var opdId = (btn.getAttribute('data-opd-id') || '').trim(); - var taskType = opdId ? 'opd_prescription_publish' : ''; - var entityId = opdId || ''; if (typeof window.openAbdmHipLinkModal === 'function') { window.openAbdmHipLinkModal(patientId, abhaId, { patient_name: patientName, - task_type: taskType, - entity_id: entityId + task_type: 'opd_prescription_publish', + entity_id: opdId, + onLinked: function (res) { + if (!row) return; + var actionCell = btn.parentNode; + if (actionCell) { + btn.outerHTML = ' Linked'; + } + var statusBadge = row.querySelector('.opd-consult-status-badge'); + if (statusBadge) { + statusBadge.className = 'badge bg-success opd-consult-status-badge'; + statusBadge.textContent = 'Linked'; + } + } + }); + } + }); + }); + + document.querySelectorAll('.btn-invoice-care-link').forEach(function (btn) { + btn.addEventListener('click', function () { + var row = btn.closest('tr'); + var patientId = parseInt(btn.getAttribute('data-patient-id') || (row ? row.getAttribute('data-patient-id') : '0') || '0', 10); + var abhaId = (btn.getAttribute('data-abha-id') || (row ? row.getAttribute('data-abha-id') : '') || '').trim(); + var patientName = (btn.getAttribute('data-patient-name') || '').trim(); + var billId = (btn.getAttribute('data-bill-id') || (row ? row.getAttribute('data-bill-id') : '') || '').trim(); + var invoiceSource = (btn.getAttribute('data-invoice-source') || (row ? row.getAttribute('data-invoice-source') : '') || '').trim(); + var careContextRef = (btn.getAttribute('data-care-context') || (row ? row.getAttribute('data-care-context') : '') || '').trim(); + + if (typeof window.openAbdmHipLinkModal === 'function') { + window.openAbdmHipLinkModal(patientId, abhaId, { + patient_name: patientName, + task_type: 'invoice_publish', + entity_id: billId, + source: invoiceSource, + care_context: careContextRef, + onLinked: function (res) { + if (!row) return; + // Update Link Status cell (cell 6) + var linkStatusCell = row.children[6]; + if (linkStatusCell) { + linkStatusCell.innerHTML = 'LINKED'; + } + // Update Status badge (cell 5) + var pushStatusBadge = row.querySelector('.invoice-status-badge'); + if (pushStatusBadge) { + pushStatusBadge.className = 'badge bg-success invoice-status-badge'; + pushStatusBadge.textContent = 'Linked'; + } + // Update Gateway Reference cell (cell 7) + var gatewayCell = row.querySelector('.invoice-details'); + if (gatewayCell && res && res.care_contexts && res.care_contexts.length > 0) { + var firstCc = res.care_contexts[0].ref || ''; + if (firstCc) { + gatewayCell.innerHTML = '
CC: ' + firstCc + '
'; + } + } + // Update Action button in Action cell (cell 8) + var actionCell = row.children[8]; + if (actionCell) { + var linkBtn = actionCell.querySelector('.btn-invoice-care-link'); + if (linkBtn) { + linkBtn.outerHTML = ' Linked'; + } + } + } }); } }); diff --git a/app/Views/partials/abdm_hip_link_modal.php b/app/Views/partials/abdm_hip_link_modal.php index 532674c..9838c4e 100644 --- a/app/Views/partials/abdm_hip_link_modal.php +++ b/app/Views/partials/abdm_hip_link_modal.php @@ -406,7 +406,7 @@ /** * Load patient demographics and care contexts from server. */ - window.loadPatientCareContexts = function(patientId, abhaAddress, taskType, entityId, taskId) { + window.loadPatientCareContexts = function(patientId, abhaAddress, taskType, entityId, taskId, source) { var loading = document.getElementById('hipCareContextsLoading'); var container = document.getElementById('hipCareContextsContainer'); if (loading) loading.classList.remove('d-none'); @@ -415,6 +415,7 @@ taskType = taskType || (window.currentHipTaskContext ? window.currentHipTaskContext.taskType : ''); entityId = entityId || (window.currentHipTaskContext ? window.currentHipTaskContext.entityId : ''); taskId = taskId || (window.currentHipTaskContext ? window.currentHipTaskContext.taskId : 0); + source = source || (window.currentHipTaskContext ? window.currentHipTaskContext.source : ''); var csrf = getCsrfData(); var url = '' @@ -422,7 +423,8 @@ + '&abha_address=' + encodeURIComponent(abhaAddress || '') + '&task_type=' + encodeURIComponent(taskType || '') + '&entity_id=' + encodeURIComponent(entityId || '') - + '&task_id=' + encodeURIComponent(taskId || 0); + + '&task_id=' + encodeURIComponent(taskId || 0) + + '&source=' + encodeURIComponent(source || ''); fetch(url, { method: 'GET', @@ -531,6 +533,8 @@ taskId: prefill.task_id || 0, taskType: prefill.task_type || '', entityId: prefill.entity_id || '', + source: prefill.source || '', + careContext: prefill.care_context || '', onLinked: prefill.onLinked || null }; @@ -567,7 +571,7 @@ // Load visits if (patientId > 0 || (abhaAddress && abhaAddress.length > 3)) { - loadPatientCareContexts(patientId, abhaAddress, prefill.task_type, prefill.entity_id, prefill.task_id); + loadPatientCareContexts(patientId, abhaAddress, prefill.task_type, prefill.entity_id, prefill.task_id, prefill.source); } else { var container = document.getElementById('hipCareContextsContainer'); if (container) { @@ -651,6 +655,29 @@ alertBox.innerHTML += '
FHIR Document Stored on Bridge: Consultation bundle is active on Bridge for Care Context ' + (pRes.care_context_reference || firstRef) + '.
To view in PHR App: Open your PHR app (e.g. ABHA App) → tap Linked Facilities → tap "Fetch Records" / "Pull Records".
'; } }).catch(function() {}); + } else if (ctxTaskType === 'invoice_publish') { + var pushData = new URLSearchParams(); + pushData.append('source', (window.currentHipTaskContext ? window.currentHipTaskContext.source : '') || ''); + pushData.append('bill_id', ctxEntityId); + pushData.append('patient_id', ctxPatientId); + pushData.append('abha_id', abhaAddress); + if (firstRef) pushData.append('care_context_reference', firstRef); + pushData.append(csrf.name || 'csrf_hms', csrf.hash); + + fetch('', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + }, + body: pushData.toString() + }).then(function(r) { return r.json(); }).then(function(pRes) { + updateCsrf(pRes); + if (pRes && pRes.ok === 1 && alertBox) { + alertBox.innerHTML += '
FHIR Document Stored on Bridge: Invoice bundle is active on Bridge for Care Context ' + (pRes.care_context_reference || firstRef) + '.
To view in PHR App: Open your PHR app (e.g. ABHA App) → tap Linked Facilities → tap "Fetch Records" / "Pull Records".
'; + } + }).catch(function() {}); } } diff --git a/tests/unit/Abdm/CareContextDiscoveryTest.php b/tests/unit/Abdm/CareContextDiscoveryTest.php index 27b186b..2385704 100644 --- a/tests/unit/Abdm/CareContextDiscoveryTest.php +++ b/tests/unit/Abdm/CareContextDiscoveryTest.php @@ -97,6 +97,17 @@ final class CareContextDiscoveryTest extends CIUnitTestCase abdm_care_context_reference TEXT )"); + $db->query("CREATE TABLE IF NOT EXISTS " . $db->prefixTable('invoice_master') . " ( + id INTEGER PRIMARY KEY, + invoice_code TEXT, + attach_id INTEGER, + attach_type INTEGER, + inv_date TEXT, + net_amount REAL + )"); + + $db->resetDataCache(); + $db->table('charge_master')->emptyTable(); $db->table('lab_request')->emptyTable(); $db->table('opd_prescription')->emptyTable(); @@ -105,6 +116,7 @@ final class CareContextDiscoveryTest extends CIUnitTestCase $db->table('patient_master')->emptyTable(); $db->table('ipd_master')->emptyTable(); $db->table('immunization_records')->emptyTable(); + $db->table('invoice_master')->emptyTable(); $db->table('patient_master')->insert([ 'id' => 12, @@ -353,5 +365,49 @@ final class CareContextDiscoveryTest extends CIUnitTestCase $refs = array_column($v3, 'referenceNumber'); $this->assertContains('OPD-12-S77-20260929', $refs); } + + public function testDiscoversInvoiceCareContextWhenInvoiceTaskRequested(): void + { + $db = \Config\Database::connect(); + $db->table('invoice_master')->emptyTable(); + $db->table('invoice_master')->insert([ + 'id' => 88, + 'invoice_code' => 'INV-88', + 'attach_id' => 12, + 'attach_type' => 0, + 'inv_date' => '2026-09-30', + 'net_amount' => 450.00, + ]); + + $refGateway = new ReflectionClass($this->gateway); + $method = $refGateway->getMethod('findCareContextsForPatient'); + $method->setAccessible(true); + + [$v3, $full] = $method->invoke( + $this->gateway, + 12, + 'P26071000012', + 'KESHAV SINGH', + 'invoice_publish', + '88', + 0, + 'charges_invoice' + ); + + $refs = array_column($v3, 'referenceNumber'); + $this->assertContains('INVOICE-CHG-88-2026-09-30', $refs); + + $invoiceCtx = null; + foreach ($full as $ctx) { + if ($ctx['careContextId'] === 'INVOICE-CHG-88-2026-09-30') { + $invoiceCtx = $ctx; + break; + } + } + $this->assertNotNull($invoiceCtx); + $this->assertSame('InvoiceRecord', $invoiceCtx['record_type']); + $this->assertTrue($invoiceCtx['is_primary']); + $this->assertTrue($invoiceCtx['is_fhir_ready']); + } }