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 = '= base_url() ?>';
const CSRF_NAME = '= csrf_token() ?>';
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('= base_url('AbdmGateway/scan_share_link_patient') ?>/' + 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('= base_url('AbdmGateway/scan_share_link_patient') ?>/' + 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('= base_url('AbdmGateway/scan_share_lookup_status') ?>/' + 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('= base_url('AbdmGateway/scan_share_resolve_patient') ?>/' + 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 @@
= esc(get_age_1($data[$i]->dob ?? null, $data[$i]->age ?? '', $data[$i]->age_in_month ?? '', $data[$i]->estimate_dob ?? '', $data[$i]->Last_Visit ?? null)) ?> |
=$data[$i]->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('= base_url('AbdmGateway/scan_share_lookup_status') ?>/' + 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="= $showFullActions ? 1 : 0 ?>">
= esc($group['label']) ?> |
= esc($row->opd_code ?? '') ?> |
- = $patientDisplayName ?> |
+
+ = $patientDisplayName ?>
+
+ ABDM
+
+ |
= esc($queueDisplay) ?> |
= esc($row->p_code ?? '') ?> |
= esc($row->opd_type ?? '') ?> / Amt: = esc($row->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('= base_url('AbdmGateway/scan_share_lookup_status') ?>/' + 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
-
+
+
+
+
-
- = view('medical/company_master_sub', ['med_company' => $med_company ?? []]) ?>
+
+
+
+
+ | Company Name |
+ Person / Phone |
+ Action |
+
+
+
+
+
+
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 Name |
- Person/Phone |
- Action |
-
-
-
-
-
- | = esc($row->company_name ?? '') ?> |
- = esc(($row->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 ?? '');
+}
+?>
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
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 ?? '');
+}
+?>
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
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
// ============================================