diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 1bc78a9..e6cad84 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -69,12 +69,38 @@ $routes->get('Medical/SupplierEdit/(:num)', 'Medical::supplier_edit/$1'); $routes->post('Medical/SupplierUpdate', 'Medical::supplier_update'); $routes->get('product_master/company_master_list', 'Medical::company_master_list'); $routes->get('product_master/CompanyListSub', 'Medical::company_list_sub'); +$routes->get('product_master/CompanyListData', 'Medical::company_list_data'); $routes->get('product_master/CompanyEdit/(:num)', 'Medical::company_edit/$1'); $routes->post('product_master/CompanyUpdate', 'Medical::company_update'); +$routes->get('product_master/formulation_master_list', 'Medical::formulation_master_list'); +$routes->get('product_master/FormulationListSub', 'Medical::formulation_list_sub'); +$routes->get('product_master/FormulationListData', 'Medical::formulation_list_data'); +$routes->get('product_master/FormulationEdit/(:num)', 'Medical::formulation_edit/$1'); +$routes->post('product_master/FormulationUpdate', 'Medical::formulation_update'); +$routes->get('product_master/short_formulation_master_list', 'Medical::short_formulation_master_list'); +$routes->get('product_master/ShortFormulationListSub', 'Medical::short_formulation_list_sub'); +$routes->get('product_master/ShortFormulationListData', 'Medical::short_formulation_list_data'); +$routes->get('product_master/ShortFormulationEdit/(:num)', 'Medical::short_formulation_edit/$1'); +$routes->post('product_master/ShortFormulationUpdate', 'Medical::short_formulation_update'); +$routes->post('product_master/SyncDrugMasters', 'Medical::sync_drug_masters'); +$routes->post('product_master/RebuildDrugMasters', 'Medical::rebuild_drug_masters'); $routes->get('Product_master/company_master_list', 'Medical::company_master_list'); $routes->get('Product_master/CompanyListSub', 'Medical::company_list_sub'); +$routes->get('Product_master/CompanyListData', 'Medical::company_list_data'); $routes->get('Product_master/CompanyEdit/(:num)', 'Medical::company_edit/$1'); $routes->post('Product_master/CompanyUpdate', 'Medical::company_update'); +$routes->get('Product_master/formulation_master_list', 'Medical::formulation_master_list'); +$routes->get('Product_master/FormulationListSub', 'Medical::formulation_list_sub'); +$routes->get('Product_master/FormulationListData', 'Medical::formulation_list_data'); +$routes->get('Product_master/FormulationEdit/(:num)', 'Medical::formulation_edit/$1'); +$routes->post('Product_master/FormulationUpdate', 'Medical::formulation_update'); +$routes->get('Product_master/short_formulation_master_list', 'Medical::short_formulation_master_list'); +$routes->get('Product_master/ShortFormulationListSub', 'Medical::short_formulation_list_sub'); +$routes->get('Product_master/ShortFormulationListData', 'Medical::short_formulation_list_data'); +$routes->get('Product_master/ShortFormulationEdit/(:num)', 'Medical::short_formulation_edit/$1'); +$routes->post('Product_master/ShortFormulationUpdate', 'Medical::short_formulation_update'); +$routes->post('Product_master/SyncDrugMasters', 'Medical::sync_drug_masters'); +$routes->post('Product_master/RebuildDrugMasters', 'Medical::rebuild_drug_masters'); $routes->get('product_master/medicine_category', 'Medical::medicine_category'); $routes->get('product_master/medicine_category_edit/(:num)', 'Medical::medicine_category_edit/$1'); $routes->get('product_master/medicine_category_Sub', 'Medical::medicine_category_sub'); @@ -544,6 +570,10 @@ $routes->post('Opd_prescription/opd_prescription_save', 'Opd_prescription::opd_p $abdmPermFilter = 'permission:abdm.access,abdm.taskboard.access,abdm.gateway.use'; $routes->post('AbdmGateway/abha_validate', 'AbdmGateway::abhaValidate', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/scan_share_lookup', 'AbdmGateway::scanShareLookup', ['filter' => $abdmPermFilter]); +$routes->get('AbdmGateway/scan_share_lookup_status/(:num)', 'AbdmGateway::scanShareLookupStatus/$1', ['filter' => $abdmPermFilter]); +$routes->get('AbdmGateway/scan_share_resolve_patient/(:num)', 'AbdmGateway::scanShareResolvePatient/$1', ['filter' => $abdmPermFilter]); +$routes->post('AbdmGateway/scan_share_link_patient/(:num)', 'AbdmGateway::scanShareLinkPatient/$1', ['filter' => $abdmPermFilter]); +$routes->post('AbdmGateway/scan_share_lookup_result_callback', 'AbdmGateway::scanShareLookupResultCallback'); $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback'); $routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback'); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 7b86e5e..a00f8d9 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -247,6 +247,10 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error_text' => 'qr_payload is required']); } + if (strlen($qrPayload) < 8) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid QR payload']); + } + $abhaId = ''; if (preg_match('/\b(\d{14})\b/', $qrPayload, $match) === 1) { $abhaId = (string) ($match[1] ?? ''); @@ -259,18 +263,277 @@ class AbdmGateway extends BaseController ]; $queueId = null; + $result = []; try { $result = $this->connector->scanShareLookup($qrPayload, $abhaId, $payload); - $queueId = $result['queue_id'] ?? null; + $queueId = (int) ($result['queue_id'] ?? $result['id'] ?? (($result['data']['queue_id'] ?? 0))); } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error_text' => $e->getMessage(), + 'abha_id_hint' => $abhaId, + 'abha_address' => $abhaId, + ]); } + $scanStatus = [ + 'ok' => 1, + 'status' => 'queued', + 'queue_id' => $queueId > 0 ? $queueId : null, + 'abha_id_hint' => $abhaId, + 'abha_address' => $abhaId, + 'status_url' => $queueId > 0 ? base_url('AbdmGateway/scan_share_lookup_status/' . $queueId) : null, + 'message' => 'Scan & Share lookup queued to center server.', + ]; + + if (is_array($result) && ! empty($result)) { + $scanStatus['bridge_result'] = $result; + } + + return $this->response->setJSON($scanStatus); + } + + public function scanShareLookupStatus(int $queueId) + { + if ($queueId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid queue id']); + } + + if (! $this->db->tableExists('bridge_sync_queue')) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'bridge_sync_queue table not found']); + } + + $row = $this->db->table('bridge_sync_queue') + ->select('id, event_type, status, attempts, max_attempts, last_error, created_at, updated_at, sent_at, entity_type, entity_id') + ->where('id', $queueId) + ->get(1) + ->getRowArray(); + + if (! $row) { + return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Queue record not found']); + } + + if ((string) ($row['event_type'] ?? '') !== 'abdm.scan_share.lookup') { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Queue record is not a scan-share lookup event']); + } + + $status = strtolower((string) ($row['status'] ?? 'pending')); + $isDone = in_array($status, ['sent', 'failed'], true) ? 1 : 0; + + $responseLog = null; + if ($this->db->tableExists('abdm_api_logs')) { + $likeToken = '"queue_id":' . $queueId; + $responseLog = $this->db->table('abdm_api_logs') + ->select('id, response_code, response_json, status, error_message, created_at') + ->where('event_type', 'abdm.scan_share.lookup') + ->like('request_json', $likeToken) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + } + + $resultPayload = $this->getScanShareResultPayload($queueId); + $resolved = is_array($resultPayload) ? $this->extractScanShareIdentity($resultPayload) : []; + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => (int) ($row['id'] ?? 0), + 'event_type' => (string) ($row['event_type'] ?? ''), + 'status' => $status, + 'done' => $isDone, + 'attempts' => (int) ($row['attempts'] ?? 0), + 'max_attempts' => (int) ($row['max_attempts'] ?? 0), + 'last_error' => (string) ($row['last_error'] ?? ''), + 'entity_type' => (string) ($row['entity_type'] ?? ''), + 'entity_id' => (string) ($row['entity_id'] ?? ''), + 'created_at' => (string) ($row['created_at'] ?? ''), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + 'sent_at' => (string) ($row['sent_at'] ?? ''), + 'bridge_log' => $responseLog, + 'resolved_data' => $resolved, + ]); + } + + public function scanShareLookupResultCallback() + { + $signatureFailure = $this->validateWebhookSignature(); + if ($signatureFailure !== null) { + return $signatureFailure; + } + + $payload = $this->request->getJSON(true); + if (! is_array($payload) || empty($payload)) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid callback payload']); + } + + $queueId = (int) ($payload['queue_id'] ?? $payload['queueId'] ?? $payload['meta']['queue_id'] ?? 0); + if ($queueId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'queue_id is required']); + } + + $ok = (int) ($payload['ok'] ?? 1) === 1; + $status = strtolower(trim((string) ($payload['status'] ?? ($ok ? 'sent' : 'failed')))); + $errorText = trim((string) ($payload['error_text'] ?? $payload['error'] ?? '')); + + if ($this->db->tableExists('bridge_sync_queue')) { + $this->db->table('bridge_sync_queue') + ->where('id', $queueId) + ->where('event_type', 'abdm.scan_share.lookup') + ->update([ + 'status' => $status === 'failed' ? 'failed' : 'sent', + 'sent_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + 'last_error' => $errorText !== '' ? mb_substr($errorText, 0, 500) : null, + 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + + if ($this->db->tableExists('abdm_api_logs')) { + $this->db->table('abdm_api_logs')->insert([ + 'channel' => 'bridge', + 'event_type' => 'abdm.scan_share.lookup.result', + 'endpoint' => '/AbdmGateway/scan_share_lookup_result_callback', + 'http_method' => 'POST', + 'entity_type' => 'abha_scan', + 'entity_id' => (string) $queueId, + 'request_json' => (string) json_encode(['queue_id' => $queueId], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_code' => $ok ? 200 : 500, + 'response_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => $ok ? 'success' : 'error', + 'error_message' => $errorText !== '' ? mb_substr($errorText, 0, 1000) : null, + 'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + + return $this->response->setJSON(['ok' => 1, 'queue_id' => $queueId, 'status' => $status !== '' ? $status : 'sent']); + } + + public function scanShareResolvePatient(int $queueId) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $resultPayload = $this->getScanShareResultPayload($queueId); + if (! is_array($resultPayload)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'No scan-share result found for queue']); + } + + $identity = $this->extractScanShareIdentity($resultPayload); + $matches = $this->findScanSharePatientMatches($identity); + return $this->response->setJSON([ 'ok' => 1, - 'status' => 'queued', 'queue_id' => $queueId, - 'abha_id_hint' => $abhaId, - 'message' => 'Scan & Share lookup queued to center server.', + 'identity' => $identity, + 'matches' => $matches, + 'requires_confirmation' => count($matches) > 0 ? 1 : 0, + ]); + } + + public function scanShareLinkPatient(int $queueId) + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $action = strtolower(trim((string) ($this->request->getPost('action') ?? 'check'))); + if (! in_array($action, ['link_existing', 'create_new'], true)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid action']); + } + + $identity = [ + 'abha_number' => preg_replace('/\D/', '', (string) ($this->request->getPost('abha_number') ?? '')), + 'abha_address' => trim((string) ($this->request->getPost('abha_address') ?? '')), + 'patient_name' => trim((string) ($this->request->getPost('patient_name') ?? '')), + 'phone' => trim((string) ($this->request->getPost('phone') ?? '')), + 'gender' => strtoupper(trim((string) ($this->request->getPost('gender') ?? ''))), + 'dob' => trim((string) ($this->request->getPost('dob') ?? '')), + ]; + + $db = $this->db; + $fields = $db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveFirstExistingColumn($fields, ['abha_id', 'abha_no', 'abha', 'abha_address']); + + $patientId = 0; + $pCode = ''; + $isNew = false; + + if ($action === 'link_existing') { + $existingPatientId = (int) ($this->request->getPost('existing_patient_id') ?? 0); + if ($existingPatientId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'existing_patient_id is required']); + } + + $existing = $db->table('patient_master') + ->select('id,p_code,mphone1' . ($abhaField ? ',' . $abhaField . ' AS patient_abha' : '')) + ->where('id', $existingPatientId) + ->get(1) + ->getRowArray(); + if (! $existing) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Selected patient not found']); + } + + $patientId = (int) ($existing['id'] ?? 0); + $pCode = (string) ($existing['p_code'] ?? ''); + + $backfill = []; + if ($abhaField && trim((string) ($existing['patient_abha'] ?? '')) === '' && ($identity['abha_number'] !== '' || $identity['abha_address'] !== '')) { + $backfill[$abhaField] = $identity['abha_number'] !== '' ? $identity['abha_number'] : $identity['abha_address']; + } + if (trim((string) ($existing['mphone1'] ?? '')) === '' && $identity['phone'] !== '') { + $backfill['mphone1'] = $identity['phone']; + } + if (! empty($backfill)) { + $db->table('patient_master')->where('id', $patientId)->update($backfill); + } + } + + if ($action === 'create_new') { + if ($identity['patient_name'] === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_name is required to create patient']); + } + + $created = $this->createPatientFromScanIdentity($identity, $abhaField); + $patientId = (int) ($created['patient_id'] ?? 0); + $pCode = (string) ($created['p_code'] ?? ''); + $isNew = true; + } + + if ($patientId <= 0) { + return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Unable to resolve patient']); + } + + if ($db->tableExists('bridge_sync_queue')) { + $db->table('bridge_sync_queue') + ->where('id', $queueId) + ->where('event_type', 'abdm.scan_share.lookup') + ->update([ + 'entity_type' => 'patient', + 'entity_id' => (string) $patientId, + 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), + ]); + } + + $this->getAuditService()->log([ + 'action' => 'scan_share_patient_link', + 'entity_type' => 'abha_scan', + 'entity_id' => (string) $queueId, + 'patient_id' => $patientId, + 'abha_id' => (string) ($identity['abha_address'] !== '' ? $identity['abha_address'] : $identity['abha_number']), + 'request' => ['action' => $action, 'identity' => $identity], + 'response' => ['patient_id' => $patientId, 'p_code' => $pCode], + 'outcome' => 'success', + 'error_message' => null, + ]); + + return $this->response->setJSON([ + 'ok' => 1, + 'queue_id' => $queueId, + 'patient_id' => $patientId, + 'p_code' => $pCode, + 'is_new' => $isNew ? 1 : 0, + 'profile_url' => base_url('Patient/person_record/' . $patientId), + 'edit_url' => base_url('Patient/person_record/' . $patientId . '/1'), ]); } @@ -1290,6 +1553,195 @@ class AbdmGateway extends BaseController return $this->response->setJSON($result); } + private function getScanShareResultPayload(int $queueId): ?array + { + if (! $this->db->tableExists('abdm_api_logs')) { + return null; + } + + $likeToken = '"queue_id":' . $queueId; + + $resultLog = $this->db->table('abdm_api_logs') + ->select('response_json, request_json') + ->whereIn('event_type', ['abdm.scan_share.lookup.result', 'abdm.scan_share.lookup']) + ->groupStart() + ->like('request_json', $likeToken) + ->orLike('response_json', $likeToken) + ->groupEnd() + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (! $resultLog) { + return null; + } + + $responsePayload = json_decode((string) ($resultLog['response_json'] ?? ''), true); + if (is_array($responsePayload) && ! empty($responsePayload)) { + return $responsePayload; + } + + $requestPayload = json_decode((string) ($resultLog['request_json'] ?? ''), true); + return is_array($requestPayload) && ! empty($requestPayload) ? $requestPayload : null; + } + + private function extractScanShareIdentity(array $payload): array + { + $identity = [ + 'abha_number' => '', + 'abha_address' => '', + 'patient_name' => '', + 'phone' => '', + 'gender' => '', + 'dob' => '', + ]; + + $abhaNumber = (string) ($this->findFirstByKeys($payload, ['abha_number', 'abhaNumber', 'abha_id', 'abhaId', 'healthIdNumber']) ?? ''); + $abhaDigits = preg_replace('/\D/', '', $abhaNumber); + if (strlen($abhaDigits) === 14) { + $identity['abha_number'] = $abhaDigits; + } + + $abhaAddress = trim((string) ($this->findFirstByKeys($payload, ['abha_address', 'abhaAddress', 'phrAddress', 'healthId']) ?? '')); + if ($abhaAddress !== '') { + $identity['abha_address'] = $abhaAddress; + } + + $patientName = trim((string) ($this->findFirstByKeys($payload, ['patient_name', 'patientName', 'name', 'fullName']) ?? '')); + if ($patientName !== '') { + $identity['patient_name'] = strtoupper($patientName); + } + + $phone = preg_replace('/\D/', '', (string) ($this->findFirstByKeys($payload, ['phone', 'mobile', 'mobileNumber']) ?? '')); + if ($phone !== '') { + $identity['phone'] = $phone; + } + + $gender = strtoupper(substr(trim((string) ($this->findFirstByKeys($payload, ['gender', 'sex']) ?? '')), 0, 1)); + if (in_array($gender, ['M', 'F', 'O'], true)) { + $identity['gender'] = $gender; + } + + $dob = trim((string) ($this->findFirstByKeys($payload, ['dob', 'dateOfBirth', 'birthDate']) ?? '')); + if ($dob !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob)) { + $identity['dob'] = $dob; + } + + return $identity; + } + + private function findFirstByKeys(array $data, array $keys): ?string + { + $normalized = array_map(static fn ($k) => strtolower((string) $k), $keys); + foreach ($data as $key => $value) { + if (is_array($value)) { + $found = $this->findFirstByKeys($value, $keys); + if ($found !== null && trim($found) !== '') { + return $found; + } + continue; + } + + $k = strtolower((string) $key); + if (in_array($k, $normalized, true)) { + $v = trim((string) $value); + if ($v !== '') { + return $v; + } + } + } + + return null; + } + + private function findScanSharePatientMatches(array $identity): array + { + $fields = $this->db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveFirstExistingColumn($fields, ['abha_id', 'abha_no', 'abha', 'abha_address']); + + $select = 'id,p_code,p_fname,mphone1'; + if ($abhaField) { + $select .= ',' . $abhaField . ' AS patient_abha'; + } + + $bucket = []; + $append = static function (array $rows, string $reason) use (&$bucket): void { + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + continue; + } + if (! isset($bucket[$id])) { + $row['match_reasons'] = []; + $bucket[$id] = $row; + } + if (! in_array($reason, $bucket[$id]['match_reasons'], true)) { + $bucket[$id]['match_reasons'][] = $reason; + } + } + }; + + if ($abhaField && ($identity['abha_number'] !== '' || $identity['abha_address'] !== '')) { + $abha = $identity['abha_number'] !== '' ? $identity['abha_number'] : $identity['abha_address']; + $rows = $this->db->table('patient_master')->select($select)->where($abhaField, $abha)->limit(10)->get()->getResultArray(); + $append($rows, 'ABHA'); + } + + if ($identity['phone'] !== '') { + $rows = $this->db->table('patient_master')->select($select)->where('mphone1', $identity['phone'])->limit(10)->get()->getResultArray(); + $append($rows, 'Phone'); + } + + return array_values($bucket); + } + + private function resolveFirstExistingColumn(array $fields, array $candidates): ?string + { + foreach ($candidates as $candidate) { + if (in_array($candidate, $fields, true)) { + return $candidate; + } + } + return null; + } + + private function createPatientFromScanIdentity(array $identity, ?string $abhaField): array + { + $fields = $this->db->getFieldNames('patient_master') ?? []; + $genderDb = ($identity['gender'] ?? '') === 'F' ? 2 : 1; + $dob = (string) ($identity['dob'] ?? ''); + + $insertData = [ + 'p_fname' => strtoupper((string) ($identity['patient_name'] ?? 'ABHA PATIENT')), + 'mphone1' => (string) ($identity['phone'] ?? ''), + 'gender' => $genderDb, + 'blood_group' => 'Not Define', + 'estimate_dob' => $dob !== '' ? 0 : 1, + ]; + if ($dob !== '') { + $insertData['dob'] = $dob; + } else { + $insertData['age'] = 0; + $insertData['age_in_month'] = 0; + } + + if ($abhaField && (($identity['abha_number'] ?? '') !== '' || ($identity['abha_address'] ?? '') !== '')) { + $insertData[$abhaField] = (string) (($identity['abha_number'] ?? '') !== '' ? $identity['abha_number'] : $identity['abha_address']); + } + + $today = date('y') . date('m'); + $countRow = $this->db->query("SELECT COUNT(*) as cnt FROM patient_master WHERE p_code LIKE 'P{$today}%'")->getRow(); + $seq = str_pad(((int) ($countRow->cnt ?? 0)) + 1, 4, '0', STR_PAD_LEFT); + $insertData['p_code'] = 'P' . $today . $seq; + + $this->db->table('patient_master')->insert($insertData); + + return [ + 'patient_id' => (int) $this->db->insertID(), + 'p_code' => (string) $insertData['p_code'], + ]; + } + private function validateWebhookSignature() { $secret = $this->readRuntimeSetting('EKA_WEBHOOK_SECRET'); diff --git a/app/Controllers/Opd.php b/app/Controllers/Opd.php index ce85c17..96b610e 100644 --- a/app/Controllers/Opd.php +++ b/app/Controllers/Opd.php @@ -3,7 +3,9 @@ namespace App\Controllers; use App\Controllers\BaseController; +use App\Libraries\Abdm\AbdmConnectorFactory; use App\Libraries\HealthplixService; +use App\Models\ClinicalAuditTrailModel; use App\Models\OpdModel; use App\Models\PaymentModel; use CodeIgniter\I18n\Time; @@ -883,6 +885,10 @@ class Opd extends BaseController $docId = (int) $docId; $opdDateSql = $this->db->escape($opdDate); + $abdmSelect = '0 as is_abdm_source'; + if ($this->db->tableExists('abdm_opd_tokens')) { + $abdmSelect = "(case when exists(select 1 from abdm_opd_tokens t where t.opd_id = o.opd_id) then 1 else 0 end) as is_abdm_source"; + } $sql = "select d.id as doc_id, d.p_fname, group_concat(DISTINCT m.SpecName) as Spec @@ -926,6 +932,7 @@ class Opd extends BaseController 1,0 ) as has_vitals, coalesce(pr.queue_no,0) as queue_no + ," . $abdmSelect . " from opd_master o join patient_master p on o.p_id=p.id left join opd_prescription pr on o.opd_id=pr.opd_id @@ -1316,6 +1323,8 @@ class Opd extends BaseController 'opd_status_remark' => $statusRemark, ]); + $this->syncAbdmTokenAfterOpdStatus((int) $opdId, (int) $opdStatus, (int) $userId, (string) $userLabel); + return $this->response->setJSON([ 'update' => 1, 'opd_id' => (int) $opdId, @@ -1324,6 +1333,119 @@ class Opd extends BaseController ]); } + private function syncAbdmTokenAfterOpdStatus(int $opdId, int $opdStatus, int $actorId, string $actorLabel): void + { + if ($opdStatus !== 2 || ! $this->db->tableExists('abdm_opd_tokens')) { + return; + } + + $rows = $this->db->query( + "SELECT id, gateway_token_id, status, queue_date + FROM abdm_opd_tokens + WHERE opd_id = " . (int) $opdId . " + AND status <> 'COMPLETED'" + )->getResultArray(); + + if (empty($rows)) { + return; + } + + $connector = null; + try { + $connector = AbdmConnectorFactory::make(); + } catch (\Throwable $e) { + $connector = null; + } + + foreach ($rows as $row) { + $localId = (int) ($row['id'] ?? 0); + $gatewayTokenId = (int) ($row['gateway_token_id'] ?? 0); + $oldStatus = strtoupper(trim((string) ($row['status'] ?? ''))); + if ($localId <= 0 || $oldStatus === 'COMPLETED') { + continue; + } + + $gatewaySync = 'skipped'; + $gatewayError = ''; + if ($connector && $gatewayTokenId > 0) { + try { + $connector->opdTokenUpdateStatus($gatewayTokenId, 'COMPLETED'); + $gatewaySync = 'ok'; + } catch (\Throwable $e) { + $gatewaySync = 'failed'; + $gatewayError = $e->getMessage(); + } + } + + $this->db->table('abdm_opd_tokens') + ->where('id', $localId) + ->update([ + 'status' => 'COMPLETED', + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $meta = [ + 'source' => 'opd_status_visit_done', + 'opd_id' => $opdId, + 'queue_date' => (string) ($row['queue_date'] ?? ''), + 'gateway_token_id' => $gatewayTokenId, + 'gateway_sync' => $gatewaySync, + 'gateway_error' => $gatewayError, + 'actor' => $actorLabel, + ]; + + $this->writeClinicalAuditTrail( + 'abdm_opd_queue', + $gatewayTokenId > 0 ? (string) $gatewayTokenId : (string) $localId, + 'status', + $oldStatus, + 'COMPLETED | ' . json_encode($meta, JSON_UNESCAPED_SLASHES), + (string) $actorId + ); + } + } + + private function writeClinicalAuditTrail( + string $module, + string $recordId, + string $fieldName, + ?string $oldValue, + ?string $newValue, + string $userId + ): void { + try { + if (! $this->db->tableExists('clinical_audit_trail')) { + return; + } + + $actionAt = date('Y-m-d H:i:s'); + $hash = hash('sha256', implode('|', [ + $module, + $recordId, + $fieldName, + (string) $oldValue, + (string) $newValue, + $userId, + $actionAt, + microtime(true), + random_int(1000, 999999), + ])); + + (new ClinicalAuditTrailModel())->insert([ + 'module' => $module, + 'record_id' => $recordId, + 'field_name' => $fieldName, + 'old_value' => $oldValue, + 'new_value' => $newValue, + 'user_id' => $userId, + 'action_at' => $actionAt, + 'hash' => $hash, + ]); + } catch (\Throwable $e) { + // fail-open: audit write errors must not block OPD flow + } + } + public function addopd(int $pno, int $orgCaseId = 0) { $sql = "select *,if(gender=1,'Male','Female') as xgender diff --git a/app/Database/Migrations/2026-05-26-000603_CreateHmsGatewayCompanyMasterTable.php b/app/Database/Migrations/2026-05-26-000603_CreateHmsGatewayCompanyMasterTable.php new file mode 100644 index 0000000..6a69c94 --- /dev/null +++ b/app/Database/Migrations/2026-05-26-000603_CreateHmsGatewayCompanyMasterTable.php @@ -0,0 +1,42 @@ +db->tableExists('hms_gateway_company_master')) { + return; + } + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `hms_gateway_company_master` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `gateway_company_identifier` VARCHAR(100) NOT NULL, + `company_name` VARCHAR(255) NOT NULL, + `gateway_updated_at` DATETIME NULL DEFAULT NULL, + `first_synced_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_synced_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `sync_status` ENUM('active','deleted','error') NOT NULL DEFAULT 'active', + `name_normalized` VARCHAR(255) NULL DEFAULT NULL, + `payload_hash` CHAR(64) NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_gateway_company_identifier` (`gateway_company_identifier`), + KEY `idx_company_name` (`company_name`), + KEY `idx_name_normalized` (`name_normalized`), + KEY `idx_gateway_updated_at` (`gateway_updated_at`), + KEY `idx_last_synced_at` (`last_synced_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } + + public function down(): void + { + if ($this->db->tableExists('hms_gateway_company_master')) { + $this->forge->dropTable('hms_gateway_company_master', true); + } + } +} diff --git a/app/Database/Migrations/2026-05-26-000604_CreateMedShortFormulationTable.php b/app/Database/Migrations/2026-05-26-000604_CreateMedShortFormulationTable.php new file mode 100644 index 0000000..4930f14 --- /dev/null +++ b/app/Database/Migrations/2026-05-26-000604_CreateMedShortFormulationTable.php @@ -0,0 +1,34 @@ +db->tableExists('med_short_formulation')) { + return; + } + + $this->db->query( + "CREATE TABLE IF NOT EXISTS `med_short_formulation` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `short_formulation` VARCHAR(100) NOT NULL, + `gateway_identifier` VARCHAR(100) NULL DEFAULT NULL, + `last_synced_at` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_short_formulation` (`short_formulation`), + KEY `idx_gateway_identifier` (`gateway_identifier`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } + + public function down(): void + { + if ($this->db->tableExists('med_short_formulation')) { + $this->forge->dropTable('med_short_formulation', true); + } + } +} diff --git a/app/Views/abdm/opd_queue.php b/app/Views/abdm/opd_queue.php index f2cd6b0..d658593 100644 --- a/app/Views/abdm/opd_queue.php +++ b/app/Views/abdm/opd_queue.php @@ -169,6 +169,12 @@ const BASE = ''; const CSRF_NAME = ''; const REFRESH_SECS = 30; + const STATUS_TRANSITIONS = { + PENDING: ['CALLED', 'CANCELLED'], + CALLED: ['COMPLETED', 'CANCELLED', 'PENDING'], + COMPLETED: ['PENDING'], + CANCELLED: ['PENDING'] + }; let autoTimer = null; let opdModalCurrentUrl = ''; let originalLoadFormRef = null; @@ -331,6 +337,10 @@ function renderQueue(data) { const tokens = data.data ?? data.tokens ?? []; + const statusFilter = String(document.getElementById('queueStatus')?.value || '').toUpperCase(); + const viewTokens = statusFilter + ? tokens + : tokens.filter(t => ['PENDING', 'CALLED'].includes(String(t.status || '').toUpperCase())); const body = document.getElementById('abdmQueueBody'); const summary = document.getElementById('abdmQSummary'); @@ -350,12 +360,14 @@ `${tokens.length} Total`, ].join(''); - if (!tokens.length) { - body.innerHTML = 'No tokens found for this filter.'; + if (!viewTokens.length) { + body.innerHTML = statusFilter + ? 'No tokens found for this filter.' + : 'No active queue tokens. Completed/Cancelled are hidden from active queue.'; return; } - body.innerHTML = tokens.map(t => { + body.innerHTML = viewTokens.map(t => { const id = t.id ?? 0; const isPend = t.status === 'PENDING'; const isCalled = t.status === 'CALLED'; @@ -371,7 +383,9 @@ hmsCell = ` ✓ ${lbl} `; - if (t.hms_opd_url && !t.hms_opd_id) { + if (t.hms_opd_id && t.hms_opd_code) { + hmsCell += `
Booked OPD: ${esc(t.hms_opd_code)}`; + } else if (t.hms_opd_url) { hmsCell += `
+ Add OPD Visit`; } } @@ -379,11 +393,15 @@ let actions = ''; if (isPend) { actions += ``; - } - if (isPend || isCalled) { - actions += ``; actions += ``; } + if (isCalled) { + actions += ``; + actions += ``; + } + if (t.status === 'COMPLETED' || t.status === 'CANCELLED') { + actions += ``; + } if (!isLinked && (isPend || isCalled)) { const pl = encodePayload({ id: t.id, abha_number: t.abha_number ?? '', abha_address: t.abha_address ?? '', @@ -398,7 +416,7 @@ actions += ``; } - return ` + return ` #${t.token_number ?? id}
${esc(t.patient_name ?? '—')}
@@ -416,6 +434,16 @@ } function setStatus(tokenId, status) { + const row = document.getElementById('abdmqrow-' + tokenId); + const currentStatus = row ? String(row.getAttribute('data-status') || '').trim().toUpperCase() : ''; + if (currentStatus && currentStatus !== status) { + const next = STATUS_TRANSITIONS[currentStatus] || []; + if (next.indexOf(status) === -1) { + alert('Invalid transition: ' + currentStatus + ' -> ' + status); + return; + } + } + post(BASE + 'AbdmOpdQueue/token_status/' + tokenId, { status }) .then(r => { if (r.ok) { @@ -433,6 +461,7 @@ window.abdmQCall = id => setStatus(id, 'CALLED'); window.abdmQComplete = id => setStatus(id, 'COMPLETED'); + window.abdmQReopen = id => setStatus(id, 'PENDING'); window.abdmQCancel = id => { if (confirm('Cancel token #' + id + '?')) setStatus(id, 'CANCELLED'); }; window.abdmQRegister = function (payloadEncoded) { diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index 008986d..bf107f3 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -188,6 +188,7 @@
Ready
+
@@ -378,6 +379,7 @@ var sandboxCard = document.getElementById('abdmSandboxCard'); var sandboxContext = document.getElementById('abdmSandboxContext'); var sandboxStatus = document.getElementById('abdmSandboxStatus'); + var scanResolvePane = document.getElementById('abdmScanResolvePane'); var sandboxAbhaInput = document.getElementById('tb_abha_id'); var sandboxQrPayloadInput = document.getElementById('tb_abdm_qr_payload'); var sandboxPurposeCodeInput = document.getElementById('tb_abdm_purpose_code'); @@ -578,6 +580,110 @@ sandboxStatus.textContent = msg || 'Ready'; } + function clearScanResolvePane() { + if (!scanResolvePane) { + return; + } + scanResolvePane.classList.add('d-none'); + scanResolvePane.innerHTML = ''; + } + + function renderScanResolvePane(queueId, identity, matches) { + if (!scanResolvePane) { + return; + } + + identity = identity || {}; + matches = Array.isArray(matches) ? matches : []; + + var cardHtml = '
'; + cardHtml += '
Scan User ABHA QR Result
'; + cardHtml += '
Queue ID: ' + (queueId || '-') + '
'; + cardHtml += '
Name: ' + ((identity.patient_name || '-') + '') + '
'; + cardHtml += '
ABHA: ' + ((identity.abha_number || identity.abha_address || '-') + '') + '
'; + cardHtml += '
Phone: ' + ((identity.phone || '-') + '') + '
'; + + if (matches.length > 0) { + cardHtml += '
Matching HMS Patients
'; + matches.forEach(function (m) { + var mLabel = ((m.p_code || 'UHID') + ' - ' + (m.p_fname || 'Unnamed')); + cardHtml += '
'; + cardHtml += '
' + mLabel + '
'; + cardHtml += ''; + cardHtml += '
'; + }); + } else { + cardHtml += '
No existing patient matched from ABHA/phone.
'; + } + + cardHtml += '
'; + cardHtml += ''; + cardHtml += ''; + cardHtml += '
'; + cardHtml += '
'; + + scanResolvePane.innerHTML = cardHtml; + scanResolvePane.classList.remove('d-none'); + + scanResolvePane.querySelectorAll('[data-scan-use-existing="1"]').forEach(function (btn) { + btn.addEventListener('click', function () { + var patientId = parseInt(btn.getAttribute('data-patient-id') || '0', 10); + if (!patientId) { + return; + } + setSandboxStatus('Linking selected HMS patient...', 'muted'); + post('/' + queueId, { + action: 'link_existing', + existing_patient_id: patientId, + abha_number: (identity.abha_number || ''), + abha_address: (identity.abha_address || ''), + patient_name: (identity.patient_name || ''), + phone: (identity.phone || ''), + gender: (identity.gender || ''), + dob: (identity.dob || '') + }, function (resp) { + if (!resp || parseInt(resp.ok || 0, 10) !== 1) { + setSandboxStatus((resp && resp.error_text) ? resp.error_text : 'Unable to link patient.', 'danger'); + return; + } + clearScanResolvePane(); + setSandboxStatus('Patient linked: ' + (resp.p_code || resp.patient_id || '-') + '. You can open profile now.', 'success'); + }); + }); + }); + + var createBtn = document.getElementById('btnScanCreateNewPatient'); + if (createBtn) { + createBtn.addEventListener('click', function () { + setSandboxStatus('Creating new patient from scan result...', 'muted'); + post('/' + queueId, { + action: 'create_new', + abha_number: (identity.abha_number || ''), + abha_address: (identity.abha_address || ''), + patient_name: (identity.patient_name || ''), + phone: (identity.phone || ''), + gender: (identity.gender || ''), + dob: (identity.dob || '') + }, function (resp) { + if (!resp || parseInt(resp.ok || 0, 10) !== 1) { + setSandboxStatus((resp && resp.error_text) ? resp.error_text : 'Unable to create patient.', 'danger'); + return; + } + clearScanResolvePane(); + setSandboxStatus('New patient created: ' + (resp.p_code || resp.patient_id || '-') + '.', 'success'); + }); + }); + } + + var clearBtn = document.getElementById('btnScanClearPane'); + if (clearBtn) { + clearBtn.addEventListener('click', function () { + clearScanResolvePane(); + setSandboxStatus('Ready', 'muted'); + }); + } + } + function currentSandboxContext() { if (!sandboxRow) { return null; @@ -620,6 +726,7 @@ sandboxPurposeCodeInput.value = sandboxPurposeCodeInput.value || 'CAREMGT'; sandboxConsentHandleInput.value = ''; sandboxClaimDocumentInput.value = ''; + clearScanResolvePane(); var opdId = row.getAttribute('data-opd-id') || '0'; var opdSessionId = row.getAttribute('data-opd-session-id') || '0'; @@ -761,8 +868,61 @@ sandboxCard.classList.add('d-none'); } sandboxRow = null; + clearScanResolvePane(); }); + function pollScanLookupStatus(queueId, onTick, onDone) { + if (!queueId || queueId <= 0) { + if (typeof onDone === 'function') { + onDone({ ok: 0, status: 'unknown', error_text: 'Missing queue id' }); + } + return; + } + + var tries = 0; + var maxTries = 10; + var timer = setInterval(function () { + tries++; + $.getJSON('/' + queueId, function (resp) { + if (typeof onTick === 'function') { + onTick(resp, tries, maxTries); + } + if (!resp || parseInt(resp.ok || 0, 10) !== 1) { + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp || { ok: 0, status: 'failed', error_text: 'Status lookup failed' }); + } + } + return; + } + + var status = (resp.status || '').toString().toLowerCase(); + if (parseInt(resp.done || 0, 10) === 1 || status === 'sent' || status === 'failed') { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp); + } + return; + } + + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp); + } + } + }).fail(function () { + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone({ ok: 0, status: 'failed', error_text: 'Status polling failed' }); + } + } + }); + }, 3000); + } + document.getElementById('tb_btn_abdm_scan_lookup').addEventListener('click', function () { var qrPayload = (sandboxQrPayloadInput.value || '').trim(); if (!qrPayload) { @@ -780,7 +940,48 @@ if (res.abha_id_hint) { sandboxAbhaInput.value = (res.abha_id_hint || '').toString().trim(); } - setSandboxStatus('Scan lookup queued. Queue ID: ' + (res.queue_id || '-'), 'success'); + var queueId = parseInt(res.queue_id || '0', 10); + setSandboxStatus('Scan lookup queued. Queue ID: ' + (queueId || '-') + '. Checking status...', 'success'); + + pollScanLookupStatus(queueId, function (scanResp, currentTry, maxTry) { + if (!scanResp || parseInt(scanResp.ok || 0, 10) !== 1) { + setSandboxStatus('Scan lookup status check (' + currentTry + '/' + maxTry + ')...', 'warning'); + return; + } + var st = (scanResp.status || 'pending').toString(); + setSandboxStatus('Scan lookup status: ' + st.toUpperCase() + ' (' + currentTry + '/' + maxTry + ')', st === 'failed' ? 'danger' : 'muted'); + }, function (finalResp) { + if (!finalResp || parseInt(finalResp.ok || 0, 10) !== 1) { + setSandboxStatus((finalResp && finalResp.error_text) ? finalResp.error_text : 'Scan lookup status unresolved. Please check Bridge Log.', 'danger'); + return; + } + var fs = (finalResp.status || '').toString().toLowerCase(); + if (fs === 'sent') { + setSandboxStatus('Scan lookup sent to bridge successfully. Queue ID: ' + queueId + '. Resolving patient...', 'success'); + $.getJSON('/' + queueId, function (resolveResp) { + if (!resolveResp || parseInt(resolveResp.ok || 0, 10) !== 1) { + setSandboxStatus((resolveResp && resolveResp.error_text) ? resolveResp.error_text : 'Scan result received but patient data not available yet.', 'warning'); + clearScanResolvePane(); + return; + } + + if (resolveResp.identity && resolveResp.identity.abha_number) { + sandboxAbhaInput.value = (resolveResp.identity.abha_number || '').toString(); + } + + renderScanResolvePane(queueId, resolveResp.identity || {}, resolveResp.matches || []); + setSandboxStatus('Resolve patient using scan result below.', 'success'); + }).fail(function () { + setSandboxStatus('Scan lookup sent, but patient resolution fetch failed.', 'warning'); + }); + } else if (fs === 'failed') { + setSandboxStatus('Scan lookup failed: ' + (finalResp.last_error || 'Unknown bridge error'), 'danger'); + clearScanResolvePane(); + } else { + setSandboxStatus('Scan lookup is still ' + fs.toUpperCase() + '. You can recheck from Bridge Log.', 'warning'); + clearScanResolvePane(); + } + }); }); }); diff --git a/app/Views/billing/Patient_Search_V.php b/app/Views/billing/Patient_Search_V.php index 6599f4b..fbe6aa4 100644 --- a/app/Views/billing/Patient_Search_V.php +++ b/app/Views/billing/Patient_Search_V.php @@ -14,7 +14,6 @@ Age Last Visit Insurance - ABHA Patient History @@ -27,19 +26,6 @@ dob ?? null, $data[$i]->age ?? '', $data[$i]->age_in_month ?? '', $data[$i]->estimate_dob ?? '', $data[$i]->Last_Visit ?? null)) ?> Last_Visit ?> insurance_id==0 ? 'Self': 'Insuranced'); ?> - abha_id ?? '')); - $linkedAt = $data[$i]->abdm_linked_at ?? null; - if ($abhaId !== '') { - if (!empty($linkedAt)) { - echo ' Verified ABHA'; - } else { - echo ' Has ABHA'; - } - } else { - echo 'No ABHA'; - } - ?> Patient History @@ -56,7 +42,6 @@ Age Last Visit Insurance - ABHA Patient History diff --git a/app/Views/billing/ipd/discharge_create.php b/app/Views/billing/ipd/discharge_create.php index bde0a8b..09fcab8 100644 --- a/app/Views/billing/ipd/discharge_create.php +++ b/app/Views/billing/ipd/discharge_create.php @@ -3939,6 +3939,59 @@ $allergyStatusNoKnown = in_array($allergyStatusNormalized, ['no known drug aller }); } + function pollScanLookupStatus(queueId, onTick, onDone) { + if (!queueId || queueId <= 0) { + if (typeof onDone === 'function') { + onDone({ ok: 0, status: 'unknown', error_text: 'Missing queue id' }); + } + return; + } + + var tries = 0; + var maxTries = 10; + var timer = setInterval(function() { + tries++; + $.getJSON('/' + queueId, function(resp) { + if (typeof onTick === 'function') { + onTick(resp, tries, maxTries); + } + + if (!resp || parseInt(resp.ok || 0, 10) !== 1) { + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp || { ok: 0, status: 'failed', error_text: 'Status lookup failed' }); + } + } + return; + } + + var status = (resp.status || '').toString().toLowerCase(); + if (parseInt(resp.done || 0, 10) === 1 || status === 'sent' || status === 'failed') { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp); + } + return; + } + + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp); + } + } + }).fail(function() { + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone({ ok: 0, status: 'failed', error_text: 'Status polling failed' }); + } + } + }); + }, 3000); + } + // Scan & Share Lookup var btnScan = document.getElementById('btn_ipd_abdm_scan_lookup'); if (btnScan) { @@ -3953,7 +4006,36 @@ $allergyStatusNoKnown = in_array($allergyStatusNormalized, ['no known drug aller var addr = document.getElementById('ipd_abha_address'); if (addr) { addr.value = resp.abha_address; } } - setStatus(resp && resp.message ? resp.message : 'Lookup done.'); + + if (!resp || parseInt(resp.ok || 0, 10) !== 1) { + setStatus((resp && resp.error_text) ? resp.error_text : 'Scan lookup failed.', true); + return; + } + + var queueId = parseInt(resp.queue_id || '0', 10); + setStatus((resp && resp.message ? resp.message : 'Lookup queued.') + ' Queue ID: ' + (queueId || '-') + '. Checking status...', false); + + pollScanLookupStatus(queueId, function(scanResp, currentTry, maxTry) { + if (!scanResp || parseInt(scanResp.ok || 0, 10) !== 1) { + setStatus('Scan lookup status check (' + currentTry + '/' + maxTry + ')...', true); + return; + } + var st = (scanResp.status || 'pending').toString(); + setStatus('Scan lookup status: ' + st.toUpperCase() + ' (' + currentTry + '/' + maxTry + ')', st === 'failed'); + }, function(finalResp) { + if (!finalResp || parseInt(finalResp.ok || 0, 10) !== 1) { + setStatus((finalResp && finalResp.error_text) ? finalResp.error_text : 'Scan lookup status unresolved. Please check Bridge Log.', true); + return; + } + var fs = (finalResp.status || '').toString().toLowerCase(); + if (fs === 'sent') { + setStatus('Scan lookup sent successfully. Queue ID: ' + queueId, false); + } else if (fs === 'failed') { + setStatus('Scan lookup failed: ' + (finalResp.last_error || 'Unknown bridge error'), true); + } else { + setStatus('Scan lookup is still ' + fs.toUpperCase() + '. Please check Bridge Log.', true); + } + }); }); }); } diff --git a/app/Views/billing/opd_appointment_list.php b/app/Views/billing/opd_appointment_list.php index 1f60d83..0ed2d63 100644 --- a/app/Views/billing/opd_appointment_list.php +++ b/app/Views/billing/opd_appointment_list.php @@ -106,6 +106,7 @@ $mergedGroups = [ $patientGenderText = $patientGenderCode === 1 ? 'Male' : ($patientGenderCode === 2 ? 'Female' : ($patientGenderCode === 3 ? 'Other' : '')); $patientAgeGender = trim($patientAgeText . ($patientGenderText !== '' ? ' / ' . $patientGenderText : '')); $patientDisplayName = esc($patientRawName . ($patientAgeGender !== '' ? ' (' . $patientAgeGender . ')' : '')); + $isAbdmSource = (int) ($row->is_abdm_source ?? 0) === 1; $tabType = $group['tabType']; $status = $group['status']; ?> @@ -115,7 +116,12 @@ $mergedGroups = [ data-has-prescription=""> opd_code ?? '') ?> - + + + + ABDM + + p_code ?? '') ?> opd_type ?? '') ?> / Amt: opd_fee_amount ?? '') ?> diff --git a/app/Views/billing/opd_prescription_basic.php b/app/Views/billing/opd_prescription_basic.php index e02e192..d1a8684 100644 --- a/app/Views/billing/opd_prescription_basic.php +++ b/app/Views/billing/opd_prescription_basic.php @@ -5789,6 +5789,59 @@ scheduleAutoSave(); }); + function pollScanLookupStatus(queueId, onTick, onDone) { + if (!queueId || queueId <= 0) { + if (typeof onDone === 'function') { + onDone({ ok: 0, status: 'unknown', error_text: 'Missing queue id' }); + } + return; + } + + var tries = 0; + var maxTries = 10; + var timer = setInterval(function() { + tries++; + $.getJSON('/' + queueId, function(resp) { + if (typeof onTick === 'function') { + onTick(resp, tries, maxTries); + } + + if (!resp || parseInt(resp.ok || 0, 10) !== 1) { + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp || { ok: 0, status: 'failed', error_text: 'Status lookup failed' }); + } + } + return; + } + + var status = (resp.status || '').toString().toLowerCase(); + if (parseInt(resp.done || 0, 10) === 1 || status === 'sent' || status === 'failed') { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp); + } + return; + } + + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone(resp); + } + } + }).fail(function() { + if (tries >= maxTries) { + clearInterval(timer); + if (typeof onDone === 'function') { + onDone({ ok: 0, status: 'failed', error_text: 'Status polling failed' }); + } + } + }); + }, 3000); + } + $('#btn_abdm_scan_lookup').on('click', function() { var qrPayload = ($('#abdm_qr_payload').val() || '').toString().trim(); if (!qrPayload) { @@ -5806,7 +5859,30 @@ if (data.abha_id_hint) { $('#abha_address').val(data.abha_id_hint).trigger('input'); } - setAbdmStatus('Scan lookup queued. Queue ID: ' + (data.queue_id || '-'), 'success'); + var queueId = parseInt(data.queue_id || '0', 10); + setAbdmStatus('Scan lookup queued. Queue ID: ' + (queueId || '-') + '. Checking status...', 'success'); + + pollScanLookupStatus(queueId, function(scanResp, currentTry, maxTry) { + if (!scanResp || parseInt(scanResp.ok || 0, 10) !== 1) { + setAbdmStatus('Scan lookup status check (' + currentTry + '/' + maxTry + ')...', 'warning'); + return; + } + var st = (scanResp.status || 'pending').toString(); + setAbdmStatus('Scan lookup status: ' + st.toUpperCase() + ' (' + currentTry + '/' + maxTry + ')', st === 'failed' ? 'danger' : 'muted'); + }, function(finalResp) { + if (!finalResp || parseInt(finalResp.ok || 0, 10) !== 1) { + setAbdmStatus((finalResp && finalResp.error_text) ? finalResp.error_text : 'Scan lookup status unresolved. Please check Bridge Log.', 'danger'); + return; + } + var fs = (finalResp.status || '').toString().toLowerCase(); + if (fs === 'sent') { + setAbdmStatus('Scan lookup sent successfully. Queue ID: ' + queueId, 'success'); + } else if (fs === 'failed') { + setAbdmStatus('Scan lookup failed: ' + (finalResp.last_error || 'Unknown bridge error'), 'danger'); + } else { + setAbdmStatus('Scan lookup is still ' + fs.toUpperCase() + '. You can recheck from Bridge Log.', 'warning'); + } + }); }); }); diff --git a/app/Views/medical/company_master.php b/app/Views/medical/company_master.php index 7e6f993..5a29515 100644 --- a/app/Views/medical/company_master.php +++ b/app/Views/medical/company_master.php @@ -1,17 +1,78 @@
+
Company Master
- +
+ + +
-
- $med_company ?? []]) ?> +
+ + + + + + + + +
Company NamePerson / PhoneAction
+ + + diff --git a/app/Views/medical/company_master_sub.php b/app/Views/medical/company_master_sub.php index 4e92991..bb8acac 100644 --- a/app/Views/medical/company_master_sub.php +++ b/app/Views/medical/company_master_sub.php @@ -1,34 +1,6 @@ - - - - - - - - - - - - - - - - - -
Company NamePerson/PhoneAction
company_name ?? '') ?>contact_person_name ?? '') . '/' . ($row->contact_phone_no ?? '')) ?> - -
+ - diff --git a/app/Views/medical/formulation_edit.php b/app/Views/medical/formulation_edit.php new file mode 100644 index 0000000..af1bb04 --- /dev/null +++ b/app/Views/medical/formulation_edit.php @@ -0,0 +1,60 @@ +id ?? 0); + // support both field name variants + $name = (string) ($row->formulation_length ?? $row->formulation ?? ''); +} +?> + +
+
Formulation
+
+
+ +
+ + + +
+ + +
+ +
+ +
+
+
+
+ + diff --git a/app/Views/medical/formulation_master.php b/app/Views/medical/formulation_master.php new file mode 100644 index 0000000..9d08735 --- /dev/null +++ b/app/Views/medical/formulation_master.php @@ -0,0 +1,75 @@ +
+
+
+
+
Formulation Master
+
+ + +
+
+ +
+
+
+ + + + + + + +
Formulation NameAction
+
+
+
+
+
+
+ + diff --git a/app/Views/medical/main_store_dashboard.php b/app/Views/medical/main_store_dashboard.php index d32e480..15a6f49 100644 --- a/app/Views/medical/main_store_dashboard.php +++ b/app/Views/medical/main_store_dashboard.php @@ -24,6 +24,16 @@ Drug Company
+
  • + + Formulation + +
  • +
  • + + Short Formulation + +
  • Medicine Category diff --git a/app/Views/medical/short_formulation_edit.php b/app/Views/medical/short_formulation_edit.php new file mode 100644 index 0000000..875d5e4 --- /dev/null +++ b/app/Views/medical/short_formulation_edit.php @@ -0,0 +1,59 @@ +id ?? 0); + $name = (string) ($row->short_formulation ?? ''); +} +?> + +
    +
    Short Formulation
    +
    +
    + +
    + + + +
    + + +
    + +
    + +
    +
    +
    +
    + + diff --git a/app/Views/medical/short_formulation_master.php b/app/Views/medical/short_formulation_master.php new file mode 100644 index 0000000..cec2572 --- /dev/null +++ b/app/Views/medical/short_formulation_master.php @@ -0,0 +1,75 @@ +
    +
    +
    +
    +
    Short Formulation Master
    +
    + + +
    +
    + +
    +
    +
    + + + + + + + +
    Short FormulationAction
    +
    +
    +
    +
    +
    +
    + + diff --git a/middleware-gateway/.env.example b/middleware-gateway/.env.example index acfe9c6..492c3ca 100644 --- a/middleware-gateway/.env.example +++ b/middleware-gateway/.env.example @@ -31,7 +31,12 @@ ABDM_NPI_ID=your-npi-id SSL_CERT_PATH=/etc/letsencrypt/live/abdm-bridge.e-atria.in/fullchain.pem SSL_KEY_PATH=/etc/letsencrypt/live/abdm-bridge.e-atria.in/privkey.pem -# Rate Limiting +# CDCI Drug Database +# Path to the local CDCI SQLite database file used by drug endpoints. +# Default: ./data/cdci.db (relative to server.js) +CDCI_DB_PATH=./data/cdci.db + + RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 diff --git a/middleware-gateway/package.json b/middleware-gateway/package.json index dd901a0..ed31677 100644 --- a/middleware-gateway/package.json +++ b/middleware-gateway/package.json @@ -24,7 +24,8 @@ "bcryptjs": "^2.4.3", "uuid": "^9.0.1", "winston": "^3.11.0", - "compression": "^1.7.4" + "compression": "^1.7.4", + "better-sqlite3": "^9.4.3" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/middleware-gateway/server.js b/middleware-gateway/server.js index c4c24f9..3835797 100644 --- a/middleware-gateway/server.js +++ b/middleware-gateway/server.js @@ -1,6 +1,7 @@ import express from 'express'; import axios from 'axios'; import dotenv from 'dotenv'; +import Database from 'better-sqlite3'; import helmet from 'helmet'; import cors from 'cors'; import morgan from 'morgan'; @@ -450,6 +451,99 @@ app.get('/api/v3/gateway/status', authenticateToken, async (req, res) => { } }); +/** + * Drug Masters Endpoint + * GET /api/v3/drugs/masters + * Serves normalised HMS master lists (company, formulation, short_formulation, generic) + * from the local CDCI SQLite database imported into the gateway. + * Data is NOT proxied to ABDM M3 — it comes from the gateway's own CDCI dataset. + * + * Query params: + * master = company | formulation | short_formulation | generic (required) + * q = optional contains-filter on name + * limit = 1–500 (default 100) + * offset = pagination offset (default 0) + */ +app.get('/api/v3/drugs/masters', authenticateToken, (req, res) => { + try { + const { master = 'company', q = '', limit = '100', offset = '0' } = req.query; + + const validMasters = ['company', 'formulation', 'short_formulation', 'generic']; + if (!validMasters.includes(master)) { + return res.status(400).json({ + ok: 0, + error_code: 'INVALID_MASTER_TYPE', + message: `Invalid master type. Valid values: ${validMasters.join(', ')}`, + request_id: req.id, + }); + } + + const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500); + const parsedOffset = Math.max(parseInt(offset, 10) || 0, 0); + + logger.info('Drug masters requested', { requestId: req.id, master, limit: parsedLimit, offset: parsedOffset }); + + // Open the CDCI SQLite database (path set via CDCI_DB_PATH in .env) + const dbPath = process.env.CDCI_DB_PATH || path.join(__dirname, 'data', 'cdci.db'); + + let db; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + } catch (dbErr) { + logger.error('CDCI DB open failed', { requestId: req.id, error: dbErr.message, dbPath }); + return res.status(503).json({ + ok: 0, + error_code: 'CDCI_DB_UNAVAILABLE', + message: 'CDCI database not available. Contact gateway admin.', + request_id: req.id, + }); + } + + // Table mapping — each master type maps to a CDCI table + const tableMap = { + company: 'cdci_manufacturers', + formulation: 'cdci_formulations', + short_formulation: 'cdci_short_formulations', + generic: 'cdci_generics', + }; + const table = tableMap[master]; + + try { + const filterClause = q ? `WHERE name LIKE ?` : ''; + const filterParam = q ? [`%${q}%`] : []; + + const totalRow = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table} ${filterClause}`).get(...filterParam); + const total = totalRow?.cnt ?? 0; + + const rows = db.prepare( + `SELECT identifier, name, updated_at FROM ${table} ${filterClause} ORDER BY name ASC LIMIT ? OFFSET ?` + ).all(...filterParam, parsedLimit, parsedOffset); + + logger.info('Drug masters served', { requestId: req.id, master, count: rows.length, total }); + + res.json({ + ok: 1, + master, + count: rows.length, + total, + items: rows, + request_id: req.id, + }); + } finally { + db.close(); + } + + } catch (error) { + logger.error('Drug masters endpoint error', { requestId: req.id, error: error.message }); + res.status(500).json({ + ok: 0, + error_code: 'SERVER_ERROR', + message: NODE_ENV === 'production' ? 'Internal server error' : error.message, + request_id: req.id, + }); + } +}); + // ============================================ // Error Handlers // ============================================