feat: Add ABDM Task Board module with ABHA workflow

- Add ABDM sidebar heading and Task Board nav link
- Create migration 000040: abdm_work_tasks table
- Add AbdmTaskBoard controller with backfill methods
- Add AbdmWorkTaskService for task creation/deduplication
- Add AbdmGateway controller for ABDM API integration
- Separate task types: patient_abha_create vs patient_abha_update
- Add lab_report_publish and radiology_report_publish task types
- Patient.php: auto-create ABDM tasks on registration
- Diagnosis.php: auto-create tasks on report verify/finalize
- Task board UI: 7 filter buttons with default Unupdated ABHA view
- Fix SQL crash in backfillLabRadiologyTasks (dynamic column detection)
- Improve load_form AJAX error display with HTTP status + response text
- Add ABDM routes in Routes.php
- Add FHIR R4 builder library updates
- Add bridge sync service updates
This commit is contained in:
DevSoftTech Server 2026-04-05 23:42:24 +05:30
parent 9d88aaa2a6
commit 062fc7ab32
23 changed files with 3757 additions and 47 deletions

View File

@ -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

View File

@ -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');

View File

@ -0,0 +1,757 @@
<?php
namespace App\Controllers;
use App\Libraries\BridgeSyncService;
use App\Libraries\FhirR4Builder;
use CodeIgniter\I18n\Time;
class AbdmGateway extends BaseController
{
public function abhaValidate()
{
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 ($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;
}
}

View File

@ -0,0 +1,308 @@
<?php
namespace App\Controllers;
use App\Libraries\AbdmWorkTaskService;
use App\Libraries\BridgeSyncService;
class AbdmTaskBoard extends BaseController
{
private AbdmWorkTaskService $taskService;
public function __construct()
{
$this->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',
]
);
}
}
}

View File

@ -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')) {

View File

@ -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());
}
}
}

View File

@ -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.
}
}
}

View File

@ -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 = '<table width="100%" border="0" style="font-size:10pt;">'
. '<tr>'
@ -2098,6 +2237,41 @@ class Opd extends BaseController
'doctor' => '<p style="text-align:right;">Dr. ' . esc((string) ($opd->doc_name ?? '')) . '</p>',
'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 !== ''
? '<div style="margin:8px 0 6px 0;"><div style="font-weight:700;font-size:24px;line-height:1.2;margin-bottom:8px;">Rx :</div>' . $medicalHtml . '</div>'
@ -2707,6 +2924,49 @@ class Opd extends BaseController
? ('<br><strong>सलाह:</strong> ' . $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 !== '' ? '<strong>' . esc($hName) . '</strong>' : '',
trim($hAddress1 . ', ' . $hAddress2, ', ') !== '' ? esc(trim($hAddress1 . ', ' . $hAddress2, ', ')) : '',
$hPhone !== '' ? 'Ph: ' . esc($hPhone) : '',
$hEmail !== '' ? 'Email: ' . esc($hEmail) : '',
], static fn ($v): bool => $v !== '');
$tokens['hospital_section'] = implode('<br>', $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;
}

View File

@ -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;
}
}

View File

@ -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<string, mixed> $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<string, mixed> $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',
]
);
}
}

View File

@ -0,0 +1,361 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAbdmCoreTables extends Migration
{
public function up(): void
{
$this->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);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAbdmWorkTasksTable extends Migration
{
public function up(): 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);
}
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);
}
}

View File

@ -0,0 +1,223 @@
<?php
namespace App\Libraries;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\I18n\Time;
class AbdmWorkTaskService
{
private BaseConnection $db;
public function __construct()
{
$this->db = db_connect();
}
/**
* @param array<string, mixed> $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<int, array<string, mixed>>
*/
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<string, mixed>|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;
}
}

View File

@ -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<string, mixed> $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<string, mixed> $row
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $row
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $row
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
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, '/');
}
}

View File

@ -166,6 +166,95 @@ class FhirR4Builder
];
}
/**
* @param array<string, mixed> $patient
* @param array<string, mixed> $encounter
* @param array<string, mixed> $claim
*
* @return array<string, mixed>
*/
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<string, mixed> $patient
*

View File

@ -0,0 +1,269 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ABDM Work Task Board</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f5f7fb; }
.container-wrap { max-width: 1280px; margin: 24px auto; }
.table td, .table th { vertical-align: middle; }
.status-pill { text-transform: uppercase; font-size: 11px; letter-spacing: .4px; }
</style>
</head>
<body>
<div class="container-wrap px-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0">ABDM Work Task Board</h4>
<div class="small text-muted">Unupdated ABHA Patient List + Separate ABDM Task Queues</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="btnRefresh">Refresh</button>
</div>
<div class="d-flex flex-wrap gap-2 mb-3" id="taskFilters">
<button class="btn btn-sm btn-primary filter-btn" data-filter="all">All</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="patient_abha_create">Unupdated ABHA</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="patient_abha_update">ABHA Update</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="opd_prescription_publish">OPD Publish</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="lab_report_publish">Lab Reports</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="radiology_report_publish">Radiology Reports</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="ipd_admission_publish">IPD Admission</button>
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="ipd_discharge_publish">IPD Discharge</button>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0" id="taskTable">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Task</th>
<th>Patient</th>
<th>ABHA</th>
<th>Entity</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php foreach (($tasks ?? []) as $t): ?>
<tr data-task-id="<?= (int) ($t['id'] ?? 0) ?>" data-task-type="<?= esc((string) ($t['task_type'] ?? '')) ?>" data-entity-id="<?= esc((string) ($t['entity_id'] ?? '')) ?>">
<td><?= (int) ($t['id'] ?? 0) ?></td>
<td>
<div><strong><?= esc((string) ($t['task_code'] ?? '')) ?></strong></div>
<div class="text-muted small"><?= esc((string) ($t['task_type'] ?? '')) ?></div>
</td>
<td>
<div><?= esc((string) ($t['patient_name'] ?? '')) ?></div>
<div class="text-muted small">#<?= (int) ($t['patient_id'] ?? 0) ?></div>
</td>
<td><input type="text" class="form-control form-control-sm abha-input" value="<?= esc((string) ($t['abha_id'] ?? '')) ?>" placeholder="14-digit ABHA"></td>
<td>
<div><?= esc((string) ($t['entity_type'] ?? '')) ?></div>
<div class="text-muted small"><?= esc((string) ($t['entity_id'] ?? '')) ?></div>
</td>
<td><span class="badge bg-secondary status-pill"><?= esc((string) ($t['status'] ?? 'pending')) ?></span></td>
<td>
<div class="d-flex gap-1 flex-wrap">
<?php $type = strtolower((string) ($t['task_type'] ?? '')); ?>
<?php if ($type === 'patient_abha_create' || $type === 'patient_abha_link'): ?>
<button class="btn btn-sm btn-outline-success action-btn" data-action="create_abha">Create ABHA</button>
<?php elseif ($type === 'patient_abha_update'): ?>
<button class="btn btn-sm btn-outline-primary action-btn" data-action="update_abha">Update ABHA</button>
<?php else: ?>
<button class="btn btn-sm btn-outline-warning action-btn" data-action="submit">Submit</button>
<?php endif; ?>
<button class="btn btn-sm btn-outline-dark close-btn">Close</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="small text-muted mt-2" id="statusBox">Ready</div>
</div>
<div class="modal fade" id="taskActionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">ABDM Guided Action</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2 small text-muted" id="modalTaskSummary"></div>
<label class="form-label">ABHA Number</label>
<input type="text" class="form-control" id="modalAbhaInput" maxlength="14" placeholder="Enter 14-digit ABHA">
<div class="form-text">ABHA validation is required before queueing the ABDM API action.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="modalConfirmBtn">Queue Action</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
var csrfName = '<?= csrf_token() ?>';
var csrfHash = '<?= csrf_hash() ?>';
var statusBox = document.getElementById('statusBox');
var modalEl = document.getElementById('taskActionModal');
var actionModal = new bootstrap.Modal(modalEl);
var modalAbhaInput = document.getElementById('modalAbhaInput');
var modalTaskSummary = document.getElementById('modalTaskSummary');
var modalConfirmBtn = document.getElementById('modalConfirmBtn');
var selectedAction = '';
var selectedRow = null;
function applyFilter(filter) {
var rows = document.querySelectorAll('#taskTable tbody tr');
rows.forEach(function (row) {
var type = (row.getAttribute('data-task-type') || '').toLowerCase();
row.style.display = (filter === 'all' || type === filter.toLowerCase()) ? '' : 'none';
});
document.querySelectorAll('.filter-btn').forEach(function (btn) {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
});
var active = document.querySelector('.filter-btn[data-filter="' + filter + '"]');
if (active) {
active.classList.remove('btn-outline-primary');
active.classList.add('btn-primary');
}
}
function setStatus(msg, isError) {
statusBox.textContent = msg;
statusBox.style.color = isError ? '#dc3545' : '#6c757d';
}
function post(url, payload, cb) {
payload[csrfName] = csrfHash;
var body = Object.keys(payload).map(function (key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(payload[key]);
}).join('&');
fetch(url, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body
}).then(function (r) {
return r.json();
}).then(function (json) {
if (json && json.csrfHash) {
csrfHash = json.csrfHash;
}
cb(json || {});
}).catch(function (e) {
setStatus('Request failed: ' + e.message, true);
});
}
function runAction(row, action) {
var taskId = row.getAttribute('data-task-id');
var abha = modalAbhaInput.value.trim();
setStatus('Submitting action...');
post('<?= base_url('AbdmTaskBoard/perform_action') ?>', {
task_id: taskId,
action: action,
abha_id: abha,
opd_session_id: ''
}, function (res) {
if (res.ok !== 1) {
setStatus(res.error_text || 'Action failed', true);
return;
}
var badge = row.querySelector('.status-pill');
if (badge) {
badge.textContent = 'IN_PROGRESS';
badge.className = 'badge bg-info status-pill';
}
setStatus('Action queued: ' + (res.event_type || '-'));
actionModal.hide();
});
}
document.querySelectorAll('.action-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
selectedRow = btn.closest('tr');
if (!selectedRow) {
return;
}
selectedAction = btn.getAttribute('data-action') || 'submit';
var abhaInput = selectedRow.querySelector('.abha-input');
var taskType = selectedRow.getAttribute('data-task-type') || '';
modalAbhaInput.value = abhaInput ? abhaInput.value.trim() : '';
modalTaskSummary.textContent = 'Task: ' + taskType + ' | Action: ' + selectedAction.toUpperCase();
actionModal.show();
});
});
document.querySelectorAll('.filter-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
applyFilter(btn.getAttribute('data-filter') || 'all');
});
});
applyFilter('patient_abha_create');
modalConfirmBtn.addEventListener('click', function () {
if (!selectedRow || !selectedAction) {
return;
}
var abha = modalAbhaInput.value.trim();
if (!/^\d{14}$/.test(abha)) {
setStatus('ABHA must be a 14-digit number.', true);
return;
}
var rowInput = selectedRow.querySelector('.abha-input');
if (rowInput) {
rowInput.value = abha;
}
runAction(selectedRow, selectedAction);
});
document.querySelectorAll('.close-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var row = btn.closest('tr');
if (!row) {
return;
}
var taskId = row.getAttribute('data-task-id');
post('<?= base_url('AbdmTaskBoard/mark_status') ?>', {
task_id: taskId,
status: 'completed',
note: 'Closed from task board'
}, function (res) {
if (res.ok !== 1) {
setStatus(res.error_text || 'Close failed', true);
return;
}
row.remove();
setStatus('Task closed');
});
});
});
document.getElementById('btnRefresh').addEventListener('click', function () {
window.location.reload();
});
})();
</script>
</body>
</html>

View File

@ -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,

View File

@ -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
<div class="discharge-side-actions">
<button type="button" class="btn btn-primary btn-sm" id="btn_preview_side" onclick="openDischargePreview('<?= site_url('Ipd_discharge/preview_discharge_report/' . $ipdId . '?regen=1') ?>', 'Discharge Preview');">Preview</button>
</div>
<div class="card mt-2 border-primary-subtle" id="ipd_abdm_panel">
<div class="card-header py-1"><strong>ABDM Sandbox Actions</strong></div>
<div class="card-body p-2">
<div class="mb-2">
<label class="form-label mb-1 small">ABHA Address/ID</label>
<input type="text" class="form-control form-control-sm" id="ipd_abha_address" value="<?= esc($patientAbha) ?>" maxlength="32" placeholder="Enter ABHA Address">
</div>
<div class="mb-2">
<label class="form-label mb-1 small">QR Payload (Scan &amp; Share)</label>
<input type="text" class="form-control form-control-sm" id="ipd_abdm_qr_payload" placeholder="Paste scanned QR payload">
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="btn_ipd_abdm_scan_lookup">Scan Lookup</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_ipd_abdm_validate_abha">Validate ABHA</button>
</div>
<div class="row g-1 mb-2">
<div class="col-6">
<input type="text" class="form-control form-control-sm" id="ipd_abdm_purpose_code" placeholder="Purpose (CAREMGT)">
</div>
<div class="col-6">
<input type="datetime-local" class="form-control form-control-sm" id="ipd_abdm_consent_expires_at">
</div>
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-outline-success btn-sm" id="btn_ipd_abdm_consent_request">Request Consent</button>
<button type="button" class="btn btn-outline-warning btn-sm" id="btn_ipd_abdm_share_discharge">Share Discharge</button>
</div>
<div class="mb-2">
<label class="form-label mb-1 small">Consent Handle</label>
<input type="text" class="form-control form-control-sm" id="ipd_abdm_consent_handle" placeholder="Auto-filled after consent request">
</div>
<div class="small text-muted" id="ipd_abdm_action_status">Ready</div>
</div>
</div>
</div>
</div>
@ -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 ──────────────────────────────────────────
})();
</script>
</section>

View File

@ -308,6 +308,48 @@
value="<?= esc($patient_master[0]->abha_address ?? $patient_master[0]->abha ?? '') ?>"
placeholder="Enter ABHA Address">
</div>
<div class="card mt-2 border-primary-subtle">
<div class="card-header py-1"><strong>ABDM Sandbox Actions</strong></div>
<div class="card-body p-2">
<div class="mb-2">
<label class="form-label mb-1 small">QR Payload (Scan &amp; Share)</label>
<input type="text" class="form-control form-control-sm" id="abdm_qr_payload" placeholder="Paste scanned QR payload">
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" id="btn_abdm_scan_lookup">Scan Lookup</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_abdm_validate_abha">Validate ABHA</button>
</div>
<div class="row g-1 mb-2">
<div class="col-6">
<input type="text" class="form-control form-control-sm" id="abdm_purpose_code" placeholder="Purpose (ex: CAREMGT)">
</div>
<div class="col-6">
<input type="datetime-local" class="form-control form-control-sm" id="abdm_consent_expires_at">
</div>
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-outline-success btn-sm" id="btn_abdm_consent_request">Request Consent</button>
<button type="button" class="btn btn-outline-warning btn-sm" id="btn_abdm_share_fhir">Share FHIR</button>
</div>
<div class="mb-2">
<label class="form-label mb-1 small">Consent Handle</label>
<input type="text" class="form-control form-control-sm" id="abdm_consent_handle" placeholder="Auto-filled after consent request">
</div>
<div class="row g-1 mb-2">
<div class="col-7">
<input type="number" min="1" class="form-control form-control-sm" id="abdm_claim_document_id" placeholder="Claim Document ID">
</div>
<div class="col-5">
<button type="button" class="btn btn-outline-dark btn-sm w-100" id="btn_abdm_claim_status">Claim Status</button>
</div>
</div>
<div class="small text-muted" id="abdm_action_status">Ready</div>
</div>
</div>
<div><strong>OPD:</strong> <?= esc($opd_master[0]->opd_code ?? '') ?></div>
<div><strong>Date:</strong> <?= esc($opd_master[0]->apointment_date ?? '') ?></div>
<div><strong>Doctor:</strong> <?= esc($opd_master[0]->doc_name ?? '') ?></div>
@ -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();

View File

@ -130,6 +130,36 @@ $editReason = trim((string) ($edit_reason ?? ''));
<?php endforeach; ?>
<div id="no_templates_msg" style="display:none; color:#888; padding:6px;">No templates found</div>
</div>
<hr class="my-3">
<div class="card border-primary-subtle">
<div class="card-header py-1"><strong>ABDM – Share Report</strong></div>
<div class="card-body p-2">
<div class="mb-2">
<label class="form-label mb-1 small">ABHA Address/ID</label>
<input type="text" class="form-control form-control-sm" id="diag_abha_address" value="<?= esc($report->patient_abha ?? '') ?>" placeholder="Patient ABHA">
</div>
<div class="row g-1 mb-2">
<div class="col-7">
<input type="text" class="form-control form-control-sm" id="diag_abdm_purpose_code" value="CAREMGT" placeholder="Purpose (CAREMGT)">
</div>
<div class="col-5">
<input type="datetime-local" class="form-control form-control-sm" id="diag_abdm_consent_expires_at">
</div>
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_diag_abdm_validate_abha">Validate ABHA</button>
<button type="button" class="btn btn-outline-success btn-sm" id="btn_diag_abdm_consent_request">Request Consent</button>
</div>
<div class="mb-2">
<label class="form-label mb-1 small">Consent Handle</label>
<input type="text" class="form-control form-control-sm" id="diag_abdm_consent_handle" placeholder="Auto-filled after consent">
</div>
<button type="button" class="btn btn-outline-warning btn-sm w-100 mb-2" id="btn_diag_abdm_share_report">Share Report (FHIR)</button>
<div class="small text-muted" id="diag_abdm_action_status">Ready</div>
</div>
</div>
</div>
</div>
@ -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 ─────────────────────────────────────────────────────
</script>
<?php endif; ?>
</div>

View File

@ -286,6 +286,14 @@ if (! $canDiagnosis && $user && method_exists($user, 'inGroup')) {
</li>
<?php } ?>
<li class="nav-heading">ABDM</li>
<li class="nav-item">
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('AbdmTaskBoard') ?>','ABDM Task Board')">
<i class="bi bi-shield-check"></i>
<span>ABDM Task Board</span>
</a>
</li>
<li class="nav-heading">Reports</li>
<li class="nav-item">

View File

@ -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 += '<br><small>' + raw.substring(0, 220) + '</small>';
}
}
$('#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 += '<br><small>' + raw.substring(0, 220) + '</small>';
}
}
$("#" + xdiv).html(details);
});
};
})();

29
env
View File

@ -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 =