feat: commit all pending page and ABDM gateway updates

This commit is contained in:
DevSoftTech Server 2026-05-26 06:04:34 +05:30
parent c97db078a9
commit 4ed366ccb2
21 changed files with 1540 additions and 69 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 @@
`<span class="badge rounded-pill bg-secondary px-3 py-2">${tokens.length} Total</span>`,
].join('');
if (!tokens.length) {
body.innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted small">No tokens found for this filter.</td></tr>';
if (!viewTokens.length) {
body.innerHTML = statusFilter
? '<tr><td colspan="9" class="text-center py-4 text-muted small">No tokens found for this filter.</td></tr>'
: '<tr><td colspan="9" class="text-center py-4 text-muted small">No active queue tokens. Completed/Cancelled are hidden from active queue.</td></tr>';
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 = `<a href="javascript:load_form('${esc(t.hms_profile_url ?? '')}','Patient Profile')" class="text-decoration-none">
<span class="badge bg-success-subtle text-success border border-success-subtle">✓ ${lbl}</span>
</a>`;
if (t.hms_opd_url && !t.hms_opd_id) {
if (t.hms_opd_id && t.hms_opd_code) {
hmsCell += `<br><span class="badge bg-primary-subtle text-primary border border-primary-subtle">Booked OPD: ${esc(t.hms_opd_code)}</span>`;
} else if (t.hms_opd_url) {
hmsCell += `<br><a href="javascript:void(0)" onclick="abdmQOpenOpdInModal('${esc(t.hms_opd_url)}')" class="small text-primary">+ Add OPD Visit</a>`;
}
}
@ -379,11 +393,15 @@
let actions = '';
if (isPend) {
actions += `<button class="btn btn-xs btn-sm btn-outline-primary me-1" onclick="abdmQCall(${id})">Call</button>`;
}
if (isPend || isCalled) {
actions += `<button class="btn btn-xs btn-sm btn-outline-success me-1" onclick="abdmQComplete(${id})">Complete</button>`;
actions += `<button class="btn btn-xs btn-sm btn-outline-danger me-1" onclick="abdmQCancel(${id})">Cancel</button>`;
}
if (isCalled) {
actions += `<button class="btn btn-xs btn-sm btn-outline-success me-1" onclick="abdmQComplete(${id})">Complete</button>`;
actions += `<button class="btn btn-xs btn-sm btn-outline-secondary me-1" onclick="abdmQReopen(${id})">Mark Pending</button>`;
}
if (t.status === 'COMPLETED' || t.status === 'CANCELLED') {
actions += `<button class="btn btn-xs btn-sm btn-outline-secondary me-1" onclick="abdmQReopen(${id})">Reopen</button>`;
}
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 += `<button class="btn btn-xs btn-sm btn-primary" onclick="abdmQRegister('${pl}')">Register OPD</button>`;
}
return `<tr id="abdmqrow-${id}" class="${isLinked ? 'table-success' : ''}">
return `<tr id="abdmqrow-${id}" data-status="${esc(String(t.status || 'PENDING').toUpperCase())}" class="${isLinked ? 'table-success' : ''}">
<td><span class="abdm-q-token-num">#${t.token_number ?? id}</span></td>
<td>
<div class="fw-semibold small">${esc(t.patient_name ?? '—')}</div>
@ -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) {

View File

@ -188,6 +188,7 @@
</div>
<div class="small text-muted" id="abdmSandboxStatus">Ready</div>
<div class="mt-2 d-none" id="abdmScanResolvePane"></div>
</div>
</div>
@ -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 = '<div class="border rounded p-2 bg-light">';
cardHtml += '<div class="small fw-semibold mb-1">Scan User ABHA QR Result</div>';
cardHtml += '<div class="small text-muted mb-2">Queue ID: ' + (queueId || '-') + '</div>';
cardHtml += '<div class="small mb-1"><strong>Name:</strong> ' + ((identity.patient_name || '-') + '') + '</div>';
cardHtml += '<div class="small mb-1"><strong>ABHA:</strong> ' + ((identity.abha_number || identity.abha_address || '-') + '') + '</div>';
cardHtml += '<div class="small mb-2"><strong>Phone:</strong> ' + ((identity.phone || '-') + '') + '</div>';
if (matches.length > 0) {
cardHtml += '<div class="small fw-semibold mb-1">Matching HMS Patients</div>';
matches.forEach(function (m) {
var mLabel = ((m.p_code || 'UHID') + ' - ' + (m.p_fname || 'Unnamed'));
cardHtml += '<div class="d-flex justify-content-between align-items-center border rounded p-1 mb-1 bg-white">';
cardHtml += '<div class="small">' + mLabel + '</div>';
cardHtml += '<button type="button" class="btn btn-sm btn-outline-primary" data-scan-use-existing="1" data-patient-id="' + (m.id || 0) + '">Use</button>';
cardHtml += '</div>';
});
} else {
cardHtml += '<div class="small text-muted mb-2">No existing patient matched from ABHA/phone.</div>';
}
cardHtml += '<div class="d-flex gap-2 mt-2">';
cardHtml += '<button type="button" class="btn btn-sm btn-success" id="btnScanCreateNewPatient">Create New Patient</button>';
cardHtml += '<button type="button" class="btn btn-sm btn-outline-secondary" id="btnScanClearPane">Clear</button>';
cardHtml += '</div>';
cardHtml += '</div>';
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();
}
});
});
});

View File

@ -14,7 +14,6 @@
<th>Age</th>
<th>Last Visit</th>
<th>Insurance</th>
<th>ABHA</th>
<th>Patient History</th>
</tr>
</thead>
@ -27,19 +26,6 @@
<td><?= 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)) ?></td>
<td><?=$data[$i]->Last_Visit ?></td>
<td><?php echo ($data[$i]->insurance_id==0 ? 'Self': 'Insuranced'); ?></td>
<td><?php
$abhaId = trim((string)($data[$i]->abha_id ?? ''));
$linkedAt = $data[$i]->abdm_linked_at ?? null;
if ($abhaId !== '') {
if (!empty($linkedAt)) {
echo '<span class="label label-success"><i class="fa fa-check-circle"></i> Verified ABHA</span>';
} else {
echo '<span class="label label-warning"><i class="fa fa-exclamation-triangle"></i> Has ABHA</span>';
}
} else {
echo '<span class="label label-default">No ABHA</span>';
}
?></td>
<td>
<a href="javascript:load_form('<?= base_url('billing/patient/show_profile_opd') ?>/<?=$data[$i]->id ?>/1');" class="btn btn-info btn-xs">
<span class="fa fa-history"></span> Patient History
@ -56,7 +42,6 @@
<th>Age</th>
<th>Last Visit</th>
<th>Insurance</th>
<th>ABHA</th>
<th>Patient History</th>
</tr>
</tfoot>

View File

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

View File

@ -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 ?>">
<td><span class="badge <?= esc($group['badge']) ?>"><?= esc($group['label']) ?></span></td>
<td><?= esc($row->opd_code ?? '') ?></td>
<td><?= $patientDisplayName ?></td>
<td>
<?= $patientDisplayName ?>
<?php if ($isAbdmSource) : ?>
<span class="badge bg-primary ms-1">ABDM</span>
<?php endif; ?>
</td>
<td data-order="<?= esc($queueDisplay !== '' ? $queueDisplay : 'ZZZZ') ?>"><?= esc($queueDisplay) ?></td>
<td><?= esc($row->p_code ?? '') ?></td>
<td><?= esc($row->opd_type ?? '') ?> / Amt: <?= esc($row->opd_fee_amount ?? '') ?></td>

View File

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

View File

@ -1,17 +1,78 @@
<div class="card">
<div class="card-body pt-3">
<div id="company_master_msg" class="mb-2"></div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Company Master</h5>
<button onclick="load_form_div('<?= base_url('Product_master/CompanyEdit/0') ?>','test_div','Company : New Company :Pharmacy');" type="button" class="btn btn-primary btn-sm">Add New</button>
<div class="d-flex gap-2">
<button onclick="syncCompanyMastersFromApi();" type="button" class="btn btn-outline-success btn-sm">Sync From API</button>
<button onclick="load_form_div('<?= base_url('Product_master/CompanyEdit/0') ?>','test_div','Company : New Company :Pharmacy');" type="button" class="btn btn-primary btn-sm">Add New</button>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div id="company_list" class="table-responsive" style="max-height:500px;overflow-y:auto;">
<?= view('medical/company_master_sub', ['med_company' => $med_company ?? []]) ?>
<div class="table-responsive">
<table id="company_report_list" class="table table-bordered table-striped table-sm align-middle w-100">
<thead>
<tr>
<th>Company Name</th>
<th>Person / Phone</th>
<th style="width:90px;">Action</th>
</tr>
</thead>
</table>
</div>
</div>
<div class="col-lg-6" id="test_div"></div>
</div>
</div>
</div>
<script>
(function () {
if (!window.jQuery || !$.fn.DataTable) return;
$('#company_report_list').DataTable({
processing : true,
serverSide : true,
destroy : true,
ajax: {
url : '<?= base_url('product_master/CompanyListData') ?>',
type: 'GET'
},
columns: [
{ data: 0, title: 'Company Name' },
{ data: 1, title: 'Person / Phone' },
{ data: 2, title: 'Action', orderable: false, searchable: false }
],
pageLength: 25,
order: [[0, 'asc']],
language: {
processing: '<span class="spinner-border spinner-border-sm me-2"></span>Loading…'
}
});
})();
function refreshCompanyMasterList() {
if (window.jQuery && $.fn.DataTable && $.fn.DataTable.isDataTable('#company_report_list')) {
$('#company_report_list').DataTable().ajax.reload(null, false);
}
}
function syncCompanyMastersFromApi() {
$('#company_master_msg').html('<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Syncing company and formulation masters from gateway API…</div>');
$.ajax({
url : '<?= base_url('product_master/SyncDrugMasters') ?>',
type : 'POST',
dataType: 'json',
success : function (response) {
$('#company_master_msg').html(response.show_text || '');
refreshCompanyMasterList();
},
error: function () {
$('#company_master_msg').html('<div class="alert alert-danger mb-0">Unable to sync masters.</div>');
}
});
}
</script>

View File

@ -1,34 +1,6 @@
<table id="company_report_list" class="table table-bordered table-striped table-sm align-middle">
<thead>
<tr>
<th>Company Name</th>
<th>Person/Phone</th>
<th style="width:90px;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach (($med_company ?? []) as $row): ?>
<tr>
<td><?= esc($row->company_name ?? '') ?></td>
<td><?= esc(($row->contact_person_name ?? '') . '/' . ($row->contact_phone_no ?? '')) ?></td>
<td>
<button onclick="load_form_div('<?= base_url('Product_master/CompanyEdit/' . (int) ($row->id ?? 0)) ?>','test_div','Company :<?= esc(($row->company_name ?? '')) ?>:Pharmacy');" type="button" class="btn btn-primary btn-sm">Edit</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
// This sub-view is kept for backward compatibility but is no longer used
// to render list data. The company list is now served via server-side
// DataTables AJAX (product_master/CompanyListData).
?>
<script>
(function () {
if (window.jQuery && $.fn.DataTable) {
if ($.fn.DataTable.isDataTable('#company_report_list')) {
$('#company_report_list').DataTable().destroy();
}
$('#company_report_list').DataTable({
pageLength: 25,
order: [[0, 'asc']]
});
}
})();
</script>

View File

@ -0,0 +1,60 @@
<?php
$fid = 0;
$name = '';
if (!empty($med_formulation ?? [])) {
$row = $med_formulation[0];
$fid = (int) ($row->id ?? 0);
// support both field name variants
$name = (string) ($row->formulation_length ?? $row->formulation ?? '');
}
?>
<div class="card">
<div class="card-header">Formulation</div>
<div class="card-body pt-3">
<div id="formulation_msg"></div>
<form id="formulation-form" class="row g-2" method="post" action="javascript:void(0)">
<?= csrf_field() ?>
<input type="hidden" id="hid_fid" name="hid_fid" value="<?= esc((string) $fid) ?>">
<div class="col-md-8">
<label class="form-label">Formulation Name</label>
<input class="form-control" name="input_formulation_name" id="input_formulation_name"
placeholder="e.g. Tablet, Syrup, Capsule…" type="text" value="<?= esc($name) ?>">
</div>
<div class="col-12">
<button type="button" class="btn btn-primary" id="btn_formulation_update" accesskey="A">Add &amp; Update Formulation</button>
</div>
</form>
</div>
</div>
<script>
(function () {
function showMessage(html) {
$('#formulation_msg').html(html || '');
}
$('#btn_formulation_update').off('click').on('click', function () {
$.post('<?= base_url('Product_master/FormulationUpdate') ?>', $('#formulation-form').serialize(), function (data) {
if (!data || typeof data !== 'object') {
showMessage('<div class="alert alert-danger mb-0">Unexpected response.</div>');
return;
}
showMessage(data.show_text || '');
if ((data.insertid || 0) > 0) {
$('#hid_fid').val(data.insertid);
if (typeof refreshFormulationMasterList === 'function') {
refreshFormulationMasterList();
}
load_form_div('<?= base_url('Product_master/FormulationEdit') ?>/' + data.insertid, 'test_div_form');
}
}, 'json').fail(function () {
showMessage('<div class="alert alert-danger mb-0">Unable to update formulation.</div>');
});
});
})();
</script>

View File

@ -0,0 +1,75 @@
<div class="card">
<div class="card-body pt-3">
<div id="formulation_master_msg" class="mb-2"></div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Formulation Master</h5>
<div class="d-flex gap-2">
<button onclick="syncFormulationMastersFromApi();" type="button" class="btn btn-outline-success btn-sm">Sync From API</button>
<button onclick="load_form_div('<?= base_url('Product_master/FormulationEdit/0') ?>','test_div_form','Formulation : New Formulation :Pharmacy');" type="button" class="btn btn-primary btn-sm">Add New</button>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="table-responsive">
<table id="formulation_report_list" class="table table-bordered table-striped table-sm align-middle w-100">
<thead>
<tr>
<th>Formulation Name</th>
<th style="width:90px;">Action</th>
</tr>
</thead>
</table>
</div>
</div>
<div class="col-lg-6" id="test_div_form"></div>
</div>
</div>
</div>
<script>
(function () {
if (!window.jQuery || !$.fn.DataTable) return;
$('#formulation_report_list').DataTable({
processing : true,
serverSide : true,
destroy : true,
ajax: {
url : '<?= base_url('product_master/FormulationListData') ?>',
type: 'GET'
},
columns: [
{ data: 0, title: 'Formulation Name' },
{ data: 1, title: 'Action', orderable: false, searchable: false }
],
pageLength: 25,
order: [[0, 'asc']],
language: {
processing: '<span class="spinner-border spinner-border-sm me-2"></span>Loading…'
}
});
})();
function refreshFormulationMasterList() {
if (window.jQuery && $.fn.DataTable && $.fn.DataTable.isDataTable('#formulation_report_list')) {
$('#formulation_report_list').DataTable().ajax.reload(null, false);
}
}
function syncFormulationMastersFromApi() {
$('#formulation_master_msg').html('<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Syncing formulation masters from gateway API…</div>');
$.ajax({
url : '<?= base_url('product_master/SyncDrugMasters') ?>',
type : 'POST',
dataType: 'json',
success : function (response) {
$('#formulation_master_msg').html(response.show_text || '');
refreshFormulationMasterList();
},
error: function () {
$('#formulation_master_msg').html('<div class="alert alert-danger mb-0">Unable to sync masters.</div>');
}
});
}
</script>

View File

@ -24,6 +24,16 @@
<i class="bi bi-hand-index-thumb"></i> Drug Company
</a>
</li>
<li class="mb-2">
<a href="javascript:load_form_div('<?= base_url('product_master/formulation_master_list') ?>','medical-main','Formulation Master :Pharmacy');">
<i class="bi bi-hand-index-thumb"></i> Formulation
</a>
</li>
<li class="mb-2">
<a href="javascript:load_form_div('<?= base_url('product_master/short_formulation_master_list') ?>','medical-main','Short Formulation Master :Pharmacy');">
<i class="bi bi-hand-index-thumb"></i> Short Formulation
</a>
</li>
<li class="mb-2">
<a href="javascript:load_form_div('<?= base_url('product_master/medicine_category') ?>','medical-main','Medicine Category :Pharmacy');">
<i class="bi bi-hand-index-thumb"></i> Medicine Category

View File

@ -0,0 +1,59 @@
<?php
$sfid = 0;
$name = '';
if (!empty($med_short_formulation ?? [])) {
$row = $med_short_formulation[0];
$sfid = (int) ($row->id ?? 0);
$name = (string) ($row->short_formulation ?? '');
}
?>
<div class="card">
<div class="card-header">Short Formulation</div>
<div class="card-body pt-3">
<div id="short_formulation_msg"></div>
<form id="short-formulation-form" class="row g-2" method="post" action="javascript:void(0)">
<?= csrf_field() ?>
<input type="hidden" id="hid_sfid" name="hid_sfid" value="<?= esc((string) $sfid) ?>">
<div class="col-md-8">
<label class="form-label">Short Formulation Name</label>
<input class="form-control" name="input_short_formulation_name" id="input_short_formulation_name"
placeholder="e.g. Tab, Cap, Syr, Inj…" type="text" value="<?= esc($name) ?>">
</div>
<div class="col-12">
<button type="button" class="btn btn-primary" id="btn_short_formulation_update" accesskey="A">Add &amp; Update Short Formulation</button>
</div>
</form>
</div>
</div>
<script>
(function () {
function showMessage(html) {
$('#short_formulation_msg').html(html || '');
}
$('#btn_short_formulation_update').off('click').on('click', function () {
$.post('<?= base_url('Product_master/ShortFormulationUpdate') ?>', $('#short-formulation-form').serialize(), function (data) {
if (!data || typeof data !== 'object') {
showMessage('<div class="alert alert-danger mb-0">Unexpected response.</div>');
return;
}
showMessage(data.show_text || '');
if ((data.insertid || 0) > 0) {
$('#hid_sfid').val(data.insertid);
if (typeof refreshShortFormulationMasterList === 'function') {
refreshShortFormulationMasterList();
}
load_form_div('<?= base_url('Product_master/ShortFormulationEdit') ?>/' + data.insertid, 'test_div_sform');
}
}, 'json').fail(function () {
showMessage('<div class="alert alert-danger mb-0">Unable to update short formulation.</div>');
});
});
})();
</script>

View File

@ -0,0 +1,75 @@
<div class="card">
<div class="card-body pt-3">
<div id="short_formulation_master_msg" class="mb-2"></div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0">Short Formulation Master</h5>
<div class="d-flex gap-2">
<button onclick="syncShortFormulationMastersFromApi();" type="button" class="btn btn-outline-success btn-sm">Sync From API</button>
<button onclick="load_form_div('<?= base_url('Product_master/ShortFormulationEdit/0') ?>','test_div_sform','Short Formulation : New :Pharmacy');" type="button" class="btn btn-primary btn-sm">Add New</button>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="table-responsive">
<table id="short_formulation_report_list" class="table table-bordered table-striped table-sm align-middle w-100">
<thead>
<tr>
<th>Short Formulation</th>
<th style="width:90px;">Action</th>
</tr>
</thead>
</table>
</div>
</div>
<div class="col-lg-6" id="test_div_sform"></div>
</div>
</div>
</div>
<script>
(function () {
if (!window.jQuery || !$.fn.DataTable) return;
$('#short_formulation_report_list').DataTable({
processing : true,
serverSide : true,
destroy : true,
ajax: {
url : '<?= base_url('product_master/ShortFormulationListData') ?>',
type: 'GET'
},
columns: [
{ data: 0, title: 'Short Formulation' },
{ data: 1, title: 'Action', orderable: false, searchable: false }
],
pageLength: 25,
order: [[0, 'asc']],
language: {
processing: '<span class="spinner-border spinner-border-sm me-2"></span>Loading…'
}
});
})();
function refreshShortFormulationMasterList() {
if (window.jQuery && $.fn.DataTable && $.fn.DataTable.isDataTable('#short_formulation_report_list')) {
$('#short_formulation_report_list').DataTable().ajax.reload(null, false);
}
}
function syncShortFormulationMastersFromApi() {
$('#short_formulation_master_msg').html('<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Syncing short formulation masters from gateway API…</div>');
$.ajax({
url : '<?= base_url('product_master/SyncDrugMasters') ?>',
type : 'POST',
dataType: 'json',
success : function (response) {
$('#short_formulation_master_msg').html(response.show_text || '');
refreshShortFormulationMasterList();
},
error: function () {
$('#short_formulation_master_msg').html('<div class="alert alert-danger mb-0">Unable to sync masters.</div>');
}
});
}
</script>

View File

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

View File

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

View File

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