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 !== ''
? ''
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Task |
+ Patient |
+ ABHA |
+ Entity |
+ Status |
+ Action |
+
+
+
+
+
+ | = (int) ($t['id'] ?? 0) ?> |
+
+ = esc((string) ($t['task_code'] ?? '')) ?>
+ = esc((string) ($t['task_type'] ?? '')) ?>
+ |
+
+ = esc((string) ($t['patient_name'] ?? '')) ?>
+ #= (int) ($t['patient_id'] ?? 0) ?>
+ |
+ |
+
+ = esc((string) ($t['entity_type'] ?? '')) ?>
+ = esc((string) ($t['entity_id'] ?? '')) ?>
+ |
+ = esc((string) ($t['status'] ?? 'pending')) ?> |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
Ready
+
+
+
+
+
+
+
+
+
+
+
ABHA validation is required before queueing the ABDM API action.
+
+
+
+
+
+
+
+
+
+
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('= base_url('patient/abha_fetch_profile') ?>', {
+ abha_id: abhaId,
+ '= csrf_token() ?>': $('input[name="= csrf_token() ?>"]').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: "= base_url('billing/patient/city') ?>",
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 = '= csrf_token() ?>';
+ var csrfHash = '= csrf_hash() ?>';
+ var ipdId = = (int) $ipdId ?>;
+ var patientId = = (int) $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('= base_url('AbdmGateway/scan_share_lookup') ?>', {
+ 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('= base_url('AbdmGateway/abha_validate') ?>', {
+ 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('= base_url('AbdmGateway/consent_request') ?>', {
+ 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('= base_url('AbdmGateway/share_ipd_discharge_bundle') ?>', {
+ 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="= esc($patient_master[0]->abha_address ?? $patient_master[0]->abha ?? '') ?>"
placeholder="Enter ABHA Address">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ready
+
+
OPD: = esc($opd_master[0]->opd_code ?? '') ?>
Date: = esc($opd_master[0]->apointment_date ?? '') ?>
Doctor: = esc($opd_master[0]->doc_name ?? '') ?>
@@ -1105,6 +1147,30 @@
cardiac: ['ECG', 'Lipid Profile', 'CBC'],
viral_infection: ['CBC', 'LFT', 'KFT']
};
+ var patientId = = (int) ($patient_master[0]->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('= base_url('AbdmGateway/scan_share_lookup') ?>', {
+ 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('= base_url('AbdmGateway/abha_validate') ?>', {
+ 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('= base_url('AbdmGateway/consent_request') ?>', {
+ 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('= base_url('AbdmGateway/share_prescription_bundle') ?>', {
+ 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('= base_url('AbdmGateway/nhcx_claim_status_request') ?>', {
+ 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 ?? ''));
No templates found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ready
+
+
@@ -413,6 +443,97 @@ $editReason = trim((string) ($edit_reason ?? ''));
if (inst) inst.hide();
}
}
+
+ // ─── ABDM Lab/Radiology Report Share ──────────────────────────────
+ (function initDiagAbdmActions() {
+ var csrfName = '= csrf_token() ?>';
+ var csrfHash = '= csrf_hash() ?>';
+ var labReqId = = (int) ($report->id ?? 0) ?>;
+ var patientId = = (int) ($report->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')) {
+ ABDM
+
+
+
+ ABDM Task Board
+
+
+
Reports
diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php
index 49d718d..6d3d43c 100644
--- a/app/Views/welcome_message.php
+++ b/app/Views/welcome_message.php
@@ -172,7 +172,18 @@
$('#main').html('Request timed out. Please try again.');
return;
}
- $('#main').html('Error loading content.');
+ var details = 'Error loading content.';
+ if (jqXHR && jqXHR.status) {
+ details += ' [HTTP ' + jqXHR.status + ']';
+ }
+ var raw = (jqXHR && jqXHR.responseText) ? String(jqXHR.responseText) : '';
+ if (raw !== '') {
+ raw = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ if (raw !== '') {
+ details += '
' + raw.substring(0, 220) + '';
+ }
+ }
+ $('#main').html(details);
});
};
@@ -204,7 +215,18 @@
$("#" + xdiv).html('Request timed out. Please try again.');
return;
}
- $("#" + xdiv).html('Error loading content.');
+ var details = 'Error loading content.';
+ if (jqXHR && jqXHR.status) {
+ details += ' [HTTP ' + jqXHR.status + ']';
+ }
+ var raw = (jqXHR && jqXHR.responseText) ? String(jqXHR.responseText) : '';
+ if (raw !== '') {
+ raw = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ if (raw !== '') {
+ details += '
' + raw.substring(0, 220) + '';
+ }
+ }
+ $("#" + xdiv).html(details);
});
};
})();
diff --git a/env b/env
index f359ec2..fc050a1 100644
--- a/env
+++ b/env
@@ -67,3 +67,32 @@
#--------------------------------------------------------------------
# logger.threshold = 4
+
+#--------------------------------------------------------------------
+# ABDM / EKA CONNECTOR
+#--------------------------------------------------------------------
+
+# Use "eka" to route ABDM/NHCX queue events to Eka, "bridge" to keep legacy bridge route.
+# ABDM_SYNC_PROVIDER = eka
+
+# Optional global override for all queue events.
+# BRIDGE_SYNC_PROVIDER = bridge
+
+# Legacy bridge endpoint settings.
+# BRIDGE_SYNC_URL =
+# BRIDGE_SYNC_TOKEN =
+# BRIDGE_SOURCE_CODE =
+
+# Eka dispatch settings.
+# EKA_BASE_URL = https://api.eka.care
+# EKA_BEARER_TOKEN =
+# EKA_API_KEY =
+# EKA_CLIENT_ID =
+
+# Optional JSON map to override default event->path mapping.
+# Example: {"abdm.abha.validate":"/user-app/abdm-connect/abha/verify"}
+# EKA_EVENT_ENDPOINTS_JSON =
+
+# Webhook signature verification secret for callback endpoints.
+# If empty, signature validation is skipped.
+# EKA_WEBHOOK_SECRET =