From 3d4064211668beb3c0f4b358411c803d794f9664 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Wed, 1 Jul 2026 17:37:53 +0530 Subject: [PATCH] Add ABDM M3 HIU workflows, routes, UI, and runbook docs --- app/Commands/AbdmHiuRetry.php | 36 ++ app/Config/Routes.php | 12 + app/Controllers/AbdmHiu.php | 265 ++++++++++++ app/Controllers/Patient.php | 2 +- ...-07-01-000701_CreateAbdmM3HiuWorkflows.php | 135 ++++++ app/Libraries/Abdm/M3HiuGatewayClient.php | 214 ++++++++++ app/Libraries/Abdm/M3HiuWorkflowService.php | 332 +++++++++++++++ app/Views/abdm/hiu_m3.php | 400 ++++++++++++++++++ app/Views/billing/Person_profile_V.php | 3 + app/Views/partials/sidebar.php | 6 + docs/ABDM_M3_HIU_QA_REPORT.md | 81 ++++ docs/ABDM_M3_HIU_RUNBOOK.md | 93 ++++ 12 files changed, 1578 insertions(+), 1 deletion(-) create mode 100644 app/Commands/AbdmHiuRetry.php create mode 100644 app/Controllers/AbdmHiu.php create mode 100644 app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php create mode 100644 app/Libraries/Abdm/M3HiuGatewayClient.php create mode 100644 app/Libraries/Abdm/M3HiuWorkflowService.php create mode 100644 app/Views/abdm/hiu_m3.php create mode 100644 docs/ABDM_M3_HIU_QA_REPORT.md create mode 100644 docs/ABDM_M3_HIU_RUNBOOK.md diff --git a/app/Commands/AbdmHiuRetry.php b/app/Commands/AbdmHiuRetry.php new file mode 100644 index 0000000..a36add2 --- /dev/null +++ b/app/Commands/AbdmHiuRetry.php @@ -0,0 +1,36 @@ + 'Maximum failed rows to retry in this run.', + ]; + + public function run(array $params) + { + $limit = (int) (CLI::getOption('limit') ?? 20); + if ($limit <= 0) { + $limit = 20; + } + + $service = new M3HiuWorkflowService(); + $summary = $service->retryDue($limit); + + CLI::write('ABDM M3 HIU Retry Summary', 'yellow'); + CLI::write('Processed: ' . (int) ($summary['processed'] ?? 0)); + CLI::write('Success: ' . (int) ($summary['success'] ?? 0), 'green'); + CLI::write('Failed: ' . (int) ($summary['failed'] ?? 0), ((int) ($summary['failed'] ?? 0) > 0 ? 'red' : 'green')); + CLI::write('Skipped: ' . (int) ($summary['skipped'] ?? 0)); + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 833abd0..48425d1 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -624,6 +624,18 @@ $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['fi $routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback'); $routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback'); $routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback'); +// ABDM M3 HIU (HMS -> Gateway) +$routes->get('AbdmHiu', 'AbdmHiu::index', ['filter' => $abdmPermFilter]); +$routes->get('AbdmHiu/patient_lookup', 'AbdmHiu::patientLookup', ['filter' => $abdmPermFilter]); +$routes->get('AbdmHiu/timeline', 'AbdmHiu::timeline', ['filter' => $abdmPermFilter]); +$routes->post('AbdmHiu/consent_request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]); +$routes->post('AbdmHiu/consent_request_status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]); +$routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]); +$routes->post('AbdmHiu/health_information_request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]); +$routes->post('api/v3/hiu/consent/request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]); +$routes->post('api/v3/hiu/consent/request/status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]); +$routes->post('api/v3/hiu/consent/request/fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]); +$routes->post('api/v3/hiu/health-information/request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]); // HMS M2 adapter endpoints (Gateway -> HMS) - keep lowercase v1 path $routes->get('api/v1/abdm/gateway/health', 'AbdmGateway::m2GatewayHealth'); $routes->post('api/v1/abdm/gateway/discovery/care-contexts', 'AbdmGateway::m2DiscoveryCareContexts'); diff --git a/app/Controllers/AbdmHiu.php b/app/Controllers/AbdmHiu.php new file mode 100644 index 0000000..c04210e --- /dev/null +++ b/app/Controllers/AbdmHiu.php @@ -0,0 +1,265 @@ +db = \Config\Database::connect(); + $this->service = new M3HiuWorkflowService(); + } + + public function index() + { + return view('abdm/hiu_m3'); + } + + public function consentRequest() + { + return $this->handleOperation('consent_request', 'REQUESTED'); + } + + public function consentRequestStatus() + { + return $this->handleOperation('consent_status', 'STATUS_CHECKED'); + } + + public function consentRequestFetch() + { + return $this->handleOperation('consent_fetch', 'CONSENT_FETCHED'); + } + + public function healthInformationRequest() + { + return $this->handleOperation('hi_request', 'DATA_REQUESTED'); + } + + public function timeline() + { + $filters = [ + 'hfr_id' => trim((string) ($this->request->getGet('hfr_id') ?? '')), + 'consent_id' => trim((string) ($this->request->getGet('consent_id') ?? '')), + 'transaction_id' => trim((string) ($this->request->getGet('transaction_id') ?? '')), + 'abha_address' => trim((string) ($this->request->getGet('abha_address') ?? '')), + 'date_from' => trim((string) ($this->request->getGet('date_from') ?? '')), + 'date_to' => trim((string) ($this->request->getGet('date_to') ?? '')), + ]; + + $rows = $this->service->listTimeline($filters, 300); + return $this->response->setJSON([ + 'ok' => 1, + 'count' => count($rows), + 'items' => $rows, + ]); + } + + public function patientLookup() + { + if (! $this->db->tableExists('patient_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master table not found', + ]); + } + + $q = trim((string) ($this->request->getGet('q') ?? '')); + if ($q !== '' && mb_strlen($q) < 2 && ! ctype_digit($q)) { + return $this->response->setJSON([ + 'ok' => 1, + 'count' => 0, + 'items' => [], + 'note' => 'Type at least 2 characters to search.', + ]); + } + + $fields = $this->db->getFieldNames('patient_master') ?? []; + $idCol = $this->resolveExistingColumn($fields, ['id']); + $uhidCol = $this->resolveExistingColumn($fields, ['p_code', 'uhid', 'uhid_no', 'patient_code', 'patient_id']); + $nameCol = $this->resolveExistingColumn($fields, ['p_fname', 'patient_name', 'name']); + $mobileCol = $this->resolveExistingColumn($fields, ['p_mobile', 'mobile', 'phone', 'contact_no']); + $abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']); + $abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']); + + if ($idCol === null) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master.id column not found', + ]); + } + + $builder = $this->db->table('patient_master p') + ->select('p.' . $idCol . ' AS patient_id', false) + ->orderBy('p.' . $idCol, 'DESC') + ->limit(30); + + if ($uhidCol !== null) { + $builder->select('p.' . $uhidCol . ' AS patient_code', false); + } else { + $builder->select("'' AS patient_code", false); + } + if ($nameCol !== null) { + $builder->select('p.' . $nameCol . ' AS patient_name', false); + } else { + $builder->select("'' AS patient_name", false); + } + if ($mobileCol !== null) { + $builder->select('p.' . $mobileCol . ' AS mobile', false); + } else { + $builder->select("'' AS mobile", false); + } + if ($abhaNumberCol !== null) { + $builder->select('p.' . $abhaNumberCol . ' AS abha_number', false); + } else { + $builder->select("'' AS abha_number", false); + } + if ($abhaAddressCol !== null) { + $builder->select('p.' . $abhaAddressCol . ' AS abha_address', false); + } else { + $builder->select("'' AS abha_address", false); + } + if (in_array('log', $fields, true)) { + $builder->select('p.log AS patient_log', false); + } else { + $builder->select("'' AS patient_log", false); + } + + if ($q !== '') { + $builder->groupStart(); + if ($nameCol !== null) { + $builder->like('p.' . $nameCol, $q); + } + if ($uhidCol !== null) { + $builder->orLike('p.' . $uhidCol, $q); + } + if ($mobileCol !== null) { + $builder->orLike('p.' . $mobileCol, $q); + } + if ($abhaNumberCol !== null) { + $builder->orLike('p.' . $abhaNumberCol, $q); + } + if ($abhaAddressCol !== null) { + $builder->orLike('p.' . $abhaAddressCol, $q); + } + if (ctype_digit($q)) { + $builder->orWhere('p.' . $idCol, (int) $q); + } + $builder->groupEnd(); + } + + $rows = $builder->get()->getResultArray(); + $items = []; + foreach ($rows as $row) { + $abhaNumber = trim((string) ($row['abha_number'] ?? '')); + $abhaAddress = trim((string) ($row['abha_address'] ?? '')); + if ($abhaAddress === '') { + $abhaAddress = $this->extractAbhaAddressFromLog((string) ($row['patient_log'] ?? '')); + } + $latest = $this->findLatestConsentByAbhaAddress($abhaAddress); + + $items[] = [ + 'patient_id' => (int) ($row['patient_id'] ?? 0), + 'patient_code' => trim((string) ($row['patient_code'] ?? '')), + 'patient_name' => trim((string) ($row['patient_name'] ?? '')), + 'mobile' => trim((string) ($row['mobile'] ?? '')), + 'abha_number' => $abhaNumber, + 'abha_address' => $abhaAddress, + 'has_abha_number' => preg_match('/^\d{14}$/', $abhaNumber) === 1 ? 1 : 0, + 'has_abha_address' => $abhaAddress !== '' ? 1 : 0, + 'latest_consent' => $latest, + ]; + } + + return $this->response->setJSON([ + 'ok' => 1, + 'count' => count($items), + 'items' => $items, + ]); + } + + private function handleOperation(string $operation, string $successState) + { + $payload = $this->request->getJSON(true); + if (! is_array($payload)) { + $payload = $this->request->getPost() ?? []; + } + + $result = $this->service->runOperation($operation, $payload); + $ok = (int) ($result['ok'] ?? 0) === 1; + $httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 400)); + if ($httpCode < 100 || $httpCode > 599) { + $httpCode = $ok ? 200 : 400; + } + + $requestId = (string) ($result['request_id'] ?? $result['requestId'] ?? $payload['request_id'] ?? $payload['requestId'] ?? ''); + if ($ok) { + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 1, + 'request_id' => $requestId, + 'http_code' => $httpCode, + 'data' => $result, + 'workflow_state' => $successState, + 'duplicate' => (int) ($result['duplicate'] ?? 0), + ]); + } + + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'error' => (string) ($result['error_text'] ?? $result['message'] ?? 'HIU operation failed'), + 'request_id' => $requestId, + 'http_code' => $httpCode, + 'retryable' => (int) ($result['retryable'] ?? 0), + 'workflow_state' => 'FAILED', + 'data' => $result, + ]); + } + + private function findLatestConsentByAbhaAddress(string $abhaAddress): ?array + { + if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) { + return null; + } + + $row = $this->db->table('abdm_hiu_workflows') + ->select('id, operation, workflow_state, status, consent_id, request_id, transaction_id, created_at, updated_at') + ->where('abha_address', $abhaAddress) + ->whereIn('operation', ['consent_request', 'consent_status', 'consent_fetch']) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + return ! empty($row) ? $row : null; + } + + private function resolveExistingColumn(array $fields, array $candidates): ?string + { + foreach ($candidates as $candidate) { + if (in_array($candidate, $fields, true)) { + return $candidate; + } + } + + return null; + } + + private function extractAbhaAddressFromLog(string $logText): string + { + if ($logText === '') { + return ''; + } + + if (preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', $logText, $m) === 1) { + return trim((string) ($m[1] ?? '')); + } + + return ''; + } +} diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index a29e9d9..9df1f65 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -2063,7 +2063,7 @@ class Patient extends BaseController } } - if (in_array('abha_address', $fields, true)) { + if (in_array('abha_address', $fields, true) && $abhaAddress !== '') { $data['abha_address'] = $abhaAddress; } } diff --git a/app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php b/app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php new file mode 100644 index 0000000..55845ab --- /dev/null +++ b/app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php @@ -0,0 +1,135 @@ +db->tableExists('abdm_hiu_workflows')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'hospital_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => true, + ], + 'hfr_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'null' => false, + ], + 'abha_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + ], + 'consent_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'request_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'transaction_id' => [ + 'type' => 'VARCHAR', + 'constraint' => 160, + 'null' => true, + ], + 'operation' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => false, + 'comment' => 'consent_request|consent_status|consent_fetch|hi_request', + ], + 'workflow_state' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'default' => 'CREATED', + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'default' => 'created', + ], + 'http_code' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'is_retryable' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'retry_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'next_retry_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'last_error' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'request_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'response_json' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'completed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'expired_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'revoked_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['request_id', 'operation'], 'uniq_abdm_hiu_req_op'); + $this->forge->addUniqueKey(['transaction_id', 'operation'], 'uniq_abdm_hiu_txn_op'); + $this->forge->addKey(['hfr_id', 'created_at'], false, false, 'idx_abdm_hiu_hfr_created'); + $this->forge->addKey(['consent_id', 'created_at'], false, false, 'idx_abdm_hiu_consent_created'); + $this->forge->addKey(['status', 'next_retry_at'], false, false, 'idx_abdm_hiu_retry'); + $this->forge->addKey(['workflow_state', 'created_at'], false, false, 'idx_abdm_hiu_state_created'); + $this->forge->createTable('abdm_hiu_workflows', true); + } + + public function down(): void + { + $this->forge->dropTable('abdm_hiu_workflows', true); + } +} diff --git a/app/Libraries/Abdm/M3HiuGatewayClient.php b/app/Libraries/Abdm/M3HiuGatewayClient.php new file mode 100644 index 0000000..b821567 --- /dev/null +++ b/app/Libraries/Abdm/M3HiuGatewayClient.php @@ -0,0 +1,214 @@ +db = \Config\Database::connect(); + $this->baseUrl = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/'); + } + + public function consentRequest(array $payload): array + { + return $this->call('/v3/hiu/consent/request', $payload, 'consent.request'); + } + + public function consentRequestStatus(array $payload): array + { + return $this->call('/v3/hiu/consent/request/status', $payload, 'consent.request.status'); + } + + public function consentRequestFetch(array $payload): array + { + return $this->call('/v3/hiu/consent/request/fetch', $payload, 'consent.request.fetch'); + } + + public function healthInformationRequest(array $payload): array + { + return $this->call('/v3/hiu/health-information/request', $payload, 'health-information.request'); + } + + private function call(string $path, array $payload, string $eventKey): array + { + $settings = $this->resolveRuntimeSettings(); + if (($settings['ok'] ?? 0) !== 1) { + return $settings; + } + + $token = (string) ($settings['token'] ?? ''); + $hfrId = (string) ($settings['hfr_id'] ?? ''); + $hospitalId = (string) ($settings['hospital_id'] ?? ''); + + $payloadHfrId = trim((string) ($payload['hfr_id'] ?? '')); + if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) { + return [ + 'ok' => 0, + 'http_code' => 400, + 'error_text' => 'hfr_id mismatch: payload hfr_id does not match hospital_setting.ABDM_HFR_ID', + 'retryable' => 0, + ]; + } + $payload['hfr_id'] = $hfrId; + + $url = $this->baseUrl . $path; + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + 'Authorization: Bearer ' . $token, + ]; + if ($hospitalId !== '') { + $headers[] = 'X-Hospital-Id: ' . $hospitalId; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeoutSec, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + $raw = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + + $decoded = json_decode((string) $raw, true); + $isJson = is_array($decoded); + $ok = $isJson ? (int) ($decoded['ok'] ?? ($httpCode >= 200 && $httpCode < 300 ? 1 : 0)) : (int) ($httpCode >= 200 && $httpCode < 300); + + $errorText = ''; + if ($curlErr !== '') { + $errorText = $curlErr; + } elseif ($ok !== 1) { + $errorText = $isJson + ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode)) + : ('HTTP ' . $httpCode . ' non-JSON response'); + } + + $retryable = 0; + if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) { + $retryable = 1; + } + + $response = $isJson ? $decoded : ['raw' => (string) $raw]; + $response['ok'] = $ok; + $response['http_code'] = $httpCode; + $response['error_text'] = $errorText; + $response['retryable'] = $retryable; + if (! isset($response['request_id'])) { + $response['request_id'] = (string) ($response['requestId'] ?? $payload['requestId'] ?? $payload['request_id'] ?? ''); + } + if (! isset($response['transaction_id'])) { + $response['transaction_id'] = (string) ($response['transactionId'] ?? $payload['transactionId'] ?? $payload['transaction_id'] ?? ''); + } + if (! isset($response['consent_id'])) { + $response['consent_id'] = (string) ($response['consentId'] ?? $response['consentRequestId'] ?? $payload['consentId'] ?? $payload['consentRequestId'] ?? $payload['consent_id'] ?? ''); + } + + $this->writeApiLog($eventKey, $path, $payload, $response, $httpCode, $ok, $errorText, $hospitalId); + + return $response; + } + + private function resolveRuntimeSettings(): array + { + if (! $this->db->tableExists('hospital_setting')) { + return ['ok' => 0, 'http_code' => 500, 'error_text' => 'hospital_setting table not found', 'retryable' => 0]; + } + + $rows = $this->db->table('hospital_setting') + ->select('s_name, s_value') + ->whereIn('s_name', ['EATRIA_BRIDGE_TOKEN', 'ABDM_HFR_ID', 'ABDM_HOSPITAL_ID']) + ->get() + ->getResultArray(); + + $kv = []; + foreach ($rows as $row) { + $key = trim((string) ($row['s_name'] ?? '')); + if ($key === '') { + continue; + } + $kv[$key] = trim((string) ($row['s_value'] ?? '')); + } + + $token = (string) ($kv['EATRIA_BRIDGE_TOKEN'] ?? ''); + $hfrId = (string) ($kv['ABDM_HFR_ID'] ?? ''); + $hospitalId = (string) ($kv['ABDM_HOSPITAL_ID'] ?? ''); + + if ($token === '') { + return [ + 'ok' => 0, + 'http_code' => 401, + 'error_text' => 'Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN', + 'retryable' => 0, + ]; + } + if ($hfrId === '') { + return [ + 'ok' => 0, + 'http_code' => 400, + 'error_text' => 'Missing hfr_id: set hospital_setting.ABDM_HFR_ID', + 'retryable' => 0, + ]; + } + + return [ + 'ok' => 1, + 'token' => $token, + 'hfr_id' => $hfrId, + 'hospital_id' => $hospitalId, + ]; + } + + private function writeApiLog( + string $eventKey, + string $path, + array $request, + array $response, + int $httpCode, + int $ok, + string $errorText, + string $hospitalId + ): void { + if (! $this->db->tableExists('abdm_api_logs')) { + return; + } + + $status = $ok === 1 ? 'success' : 'error'; + $entityId = trim((string) ( + $request['consent_id'] + ?? $request['consentId'] + ?? $request['consentRequestId'] + ?? $request['request_id'] + ?? $request['requestId'] + ?? $request['transaction_id'] + ?? $request['transactionId'] + ?? '' + )); + + $this->db->table('abdm_api_logs')->insert([ + 'channel' => 'eatria_bridge', + 'event_type' => 'abdm.m3.hiu.' . $eventKey, + 'endpoint' => $this->baseUrl . $path, + 'http_method' => 'POST', + 'entity_type' => 'abdm_hiu', + 'entity_id' => $entityId, + 'request_json' => (string) json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_code' => $httpCode, + 'response_json' => (string) json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => $status, + 'error_message' => $errorText !== '' ? mb_substr($errorText, 0, 1000) : null, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } +} diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php new file mode 100644 index 0000000..f935034 --- /dev/null +++ b/app/Libraries/Abdm/M3HiuWorkflowService.php @@ -0,0 +1,332 @@ +db = \Config\Database::connect(); + $this->client = new M3HiuGatewayClient(); + } + + public function runOperation(string $operation, array $payload): array + { + $hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows'); + + $normalized = $this->normalizePayload($payload); + if ($hasWorkflowTable && $operation !== 'consent_status') { + $duplicate = $this->findDuplicate($operation, $normalized); + if ($duplicate !== null) { + $dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true); + if (is_array($dupResponse) && ! empty($dupResponse)) { + $dupResponse['duplicate'] = 1; + $dupResponse['workflow_state'] = (string) ($duplicate['workflow_state'] ?? ''); + return $dupResponse; + } + } + } + + $result = $this->dispatch($operation, $normalized); + $httpCode = (int) ($result['http_code'] ?? 0); + $ok = (int) ($result['ok'] ?? 0) === 1; + + $state = $this->resolveState($operation, $result); + $status = $ok ? 'success' : 'failed'; + $retryable = (int) ($result['retryable'] ?? 0) === 1; + + if ($hasWorkflowTable) { + $this->upsertWorkflow($operation, $normalized, $result, $state, $status, $httpCode, $retryable); + $result['workflow_persisted'] = 1; + } else { + $result['workflow_persisted'] = 0; + $result['workflow_warning'] = 'abdm_hiu_workflows table not found; operation executed without timeline persistence'; + } + + return $result; + } + + public function retryDue(int $limit = 20): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0]; + } + + $rows = $this->db->table('abdm_hiu_workflows') + ->select('*') + ->where('status', 'failed') + ->where('is_retryable', 1) + ->where('retry_count <', 3) + ->groupStart() + ->where('next_retry_at IS NULL', null, false) + ->orWhere('next_retry_at <=', Time::now('Asia/Kolkata')->toDateTimeString()) + ->groupEnd() + ->orderBy('id', 'ASC') + ->get($limit) + ->getResultArray(); + + $summary = ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0]; + + foreach ($rows as $row) { + $summary['processed']++; + $operation = trim((string) ($row['operation'] ?? '')); + $payload = json_decode((string) ($row['request_json'] ?? ''), true); + if (! is_array($payload) || $operation === '') { + $summary['skipped']++; + continue; + } + + $result = $this->dispatch($operation, $payload); + $ok = (int) ($result['ok'] ?? 0) === 1; + $state = $this->resolveState($operation, $result); + $status = $ok ? 'success' : 'failed'; + $retryable = (int) ($result['retryable'] ?? 0) === 1; + + $this->upsertWorkflow($operation, $payload, $result, $state, $status, (int) ($result['http_code'] ?? 0), $retryable, (int) ($row['id'] ?? 0)); + + if ($ok) { + $summary['success']++; + } else { + $summary['failed']++; + } + } + + return $summary; + } + + public function listTimeline(array $filters, int $limit = 200): array + { + if (! $this->db->tableExists('abdm_hiu_workflows')) { + return []; + } + + $builder = $this->db->table('abdm_hiu_workflows')->select('*')->orderBy('id', 'DESC'); + + $hfrId = trim((string) ($filters['hfr_id'] ?? '')); + if ($hfrId !== '') { + $builder->where('hfr_id', $hfrId); + } + $consentId = trim((string) ($filters['consent_id'] ?? '')); + if ($consentId !== '') { + $builder->where('consent_id', $consentId); + } + $transactionId = trim((string) ($filters['transaction_id'] ?? '')); + if ($transactionId !== '') { + $builder->where('transaction_id', $transactionId); + } + $abhaAddress = trim((string) ($filters['abha_address'] ?? '')); + if ($abhaAddress !== '') { + $builder->where('abha_address', $abhaAddress); + } + $from = trim((string) ($filters['date_from'] ?? '')); + if ($from !== '') { + $builder->where('created_at >=', $from . ' 00:00:00'); + } + $to = trim((string) ($filters['date_to'] ?? '')); + if ($to !== '') { + $builder->where('created_at <=', $to . ' 23:59:59'); + } + + return $builder->get($limit)->getResultArray(); + } + + private function dispatch(string $operation, array $payload): array + { + return match ($operation) { + 'consent_request' => $this->client->consentRequest($payload), + 'consent_status' => $this->client->consentRequestStatus($payload), + 'consent_fetch' => $this->client->consentRequestFetch($payload), + 'hi_request' => $this->client->healthInformationRequest($payload), + default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0], + }; + } + + private function normalizePayload(array $payload): array + { + $normalized = $payload; + + // Accept both legacy snake_case and guide-defined camelCase keys. + if (! isset($normalized['request_id']) && isset($normalized['requestId'])) { + $normalized['request_id'] = (string) $normalized['requestId']; + } + if (! isset($normalized['transaction_id']) && isset($normalized['transactionId'])) { + $normalized['transaction_id'] = (string) $normalized['transactionId']; + } + if (! isset($normalized['consent_id']) && isset($normalized['consentId'])) { + $normalized['consent_id'] = (string) $normalized['consentId']; + } + if (! isset($normalized['consent_id']) && isset($normalized['consentRequestId'])) { + $normalized['consent_id'] = (string) $normalized['consentRequestId']; + } + + if (isset($normalized['abha_id']) && ! isset($normalized['abha_address'])) { + $normalized['abha_address'] = (string) $normalized['abha_id']; + } + + foreach (['request_id', 'transaction_id', 'consent_id', 'abha_address', 'hfr_id'] as $k) { + if (! isset($normalized[$k])) { + $normalized[$k] = ''; + } + $normalized[$k] = trim((string) $normalized[$k]); + } + + if ($normalized['request_id'] === '') { + $normalized['request_id'] = 'REQ-HIU-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3))); + } + + if (! isset($normalized['requestId']) || trim((string) $normalized['requestId']) === '') { + $normalized['requestId'] = $normalized['request_id']; + } + if (! isset($normalized['timestamp']) || trim((string) $normalized['timestamp']) === '') { + $normalized['timestamp'] = gmdate('Y-m-d\TH:i:s.000\Z'); + } + if ($normalized['transaction_id'] !== '' && (! isset($normalized['transactionId']) || trim((string) $normalized['transactionId']) === '')) { + $normalized['transactionId'] = $normalized['transaction_id']; + } + if ($normalized['consent_id'] !== '' && (! isset($normalized['consentId']) || trim((string) $normalized['consentId']) === '')) { + $normalized['consentId'] = $normalized['consent_id']; + } + + return $normalized; + } + + private function resolveState(string $operation, array $result): string + { + $ok = (int) ($result['ok'] ?? 0) === 1; + if (! $ok) { + return 'FAILED'; + } + + $statusStr = strtolower(trim((string) ($result['status'] ?? $result['consent_status'] ?? ''))); + if (in_array($statusStr, ['revoked', 'denied'], true)) { + return 'REVOKED'; + } + if (in_array($statusStr, ['expired'], true)) { + return 'EXPIRED'; + } + + return match ($operation) { + 'consent_request' => 'REQUESTED', + 'consent_status' => 'STATUS_CHECKED', + 'consent_fetch' => 'CONSENT_FETCHED', + 'hi_request' => ($statusStr === 'completed' ? 'COMPLETED' : 'DATA_REQUESTED'), + default => 'COMPLETED', + }; + } + + private function findDuplicate(string $operation, array $payload): ?array + { + $requestId = trim((string) ($payload['request_id'] ?? '')); + $transactionId = trim((string) ($payload['transaction_id'] ?? '')); + + $builder = $this->db->table('abdm_hiu_workflows')->select('*')->where('operation', $operation); + if ($requestId !== '') { + $row = $builder->where('request_id', $requestId)->orderBy('id', 'DESC')->get(1)->getRowArray(); + if (! empty($row)) { + return $row; + } + } + + if ($transactionId !== '') { + $row = $this->db->table('abdm_hiu_workflows') + ->select('*') + ->where('operation', $operation) + ->where('transaction_id', $transactionId) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($row)) { + return $row; + } + } + + return null; + } + + private function upsertWorkflow( + string $operation, + array $payload, + array $result, + string $state, + string $status, + int $httpCode, + bool $retryable, + int $forceId = 0 + ): void { + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $requestId = trim((string) ($payload['request_id'] ?? $result['request_id'] ?? '')); + $transactionId = trim((string) ($payload['transaction_id'] ?? $result['transaction_id'] ?? '')); + $consentId = trim((string) ($payload['consent_id'] ?? $result['consent_id'] ?? '')); + $abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? $result['abha_address'] ?? '')); + $hfrId = trim((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? '')); + $hospitalId = trim((string) ($payload['hospital_id'] ?? $result['hospital_id'] ?? '')); + $errorText = trim((string) ($result['error_text'] ?? $result['message'] ?? '')); + + $retryCount = 0; + $id = $forceId; + if ($id <= 0) { + $existing = $this->findDuplicate($operation, ['request_id' => $requestId, 'transaction_id' => $transactionId]); + if (! empty($existing)) { + $id = (int) ($existing['id'] ?? 0); + $retryCount = (int) ($existing['retry_count'] ?? 0); + } + } else { + $existing = $this->db->table('abdm_hiu_workflows')->where('id', $id)->get(1)->getRowArray(); + $retryCount = (int) ($existing['retry_count'] ?? 0); + } + + if ($status === 'failed' && $retryable) { + $retryCount++; + } + + $nextRetryAt = null; + if ($status === 'failed' && $retryable && $retryCount < 4) { + $backoffMinutes = min(30, 1 * (2 ** max(0, $retryCount - 1))); + $nextRetryAt = date('Y-m-d H:i:s', strtotime('+' . $backoffMinutes . ' minutes')); + } + + $row = [ + 'hospital_id' => $hospitalId !== '' ? $hospitalId : null, + 'hfr_id' => $hfrId !== '' ? $hfrId : 'UNKNOWN', + 'abha_address' => $abhaAddress !== '' ? $abhaAddress : null, + 'consent_id' => $consentId !== '' ? $consentId : null, + 'request_id' => $requestId !== '' ? $requestId : null, + 'transaction_id' => $transactionId !== '' ? $transactionId : null, + 'operation' => $operation, + 'workflow_state' => $state, + 'status' => $status, + 'http_code' => $httpCode > 0 ? $httpCode : null, + 'is_retryable' => $retryable ? 1 : 0, + 'retry_count' => $retryCount, + 'next_retry_at' => $nextRetryAt, + 'last_error' => $errorText !== '' ? mb_substr($errorText, 0, 2000) : null, + 'request_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'response_json' => (string) json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => $now, + ]; + + if (in_array($state, ['COMPLETED', 'CONSENT_FETCHED'], true)) { + $row['completed_at'] = $now; + } + if ($state === 'EXPIRED') { + $row['expired_at'] = $now; + } + if ($state === 'REVOKED') { + $row['revoked_at'] = $now; + } + + if ($id > 0) { + $this->db->table('abdm_hiu_workflows')->where('id', $id)->update($row); + return; + } + + $row['created_at'] = $now; + $this->db->table('abdm_hiu_workflows')->insert($row); + } +} diff --git a/app/Views/abdm/hiu_m3.php b/app/Views/abdm/hiu_m3.php new file mode 100644 index 0000000..eec48c6 --- /dev/null +++ b/app/Views/abdm/hiu_m3.php @@ -0,0 +1,400 @@ +
+
+
+
+
+
ABDM M3 HIU Console
+ +
+
+
+ Search patient first. Consent Request is enabled only when ABHA Number and ABHA Address are both present. +
+ +
+
+
+
+ + +
+
+ +
+
+
No patient selected.
+
+
+
+ + + + + + + + + + + + +
ActionPatientUHIDABHA NumberABHA AddressLast Consent State
+
+
+
+ +
+
+
+
+
+
+
+ +
+ +
+ +
+ +
+ + + + +
+ +
+
{}
+
+
+
+ +
+
+
Timeline / Logs
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
IDTimeOperationStateStatusHFRABHA AddressConsentTxnRequestError
+
+
+
+
+
+
+ + diff --git a/app/Views/billing/Person_profile_V.php b/app/Views/billing/Person_profile_V.php index f93a7bb..46b9943 100644 --- a/app/Views/billing/Person_profile_V.php +++ b/app/Views/billing/Person_profile_V.php @@ -16,6 +16,9 @@ abha_id ?? $data[0]->abha_no ?? $data[0]->abha ?? $data[0]->abha_address ?? ''); $abhaAddress = trim((string) ($data[0]->abha_address ?? '')); + if ($abhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($data[0]->log ?? ''), $abhaLogMatch) === 1) { + $abhaAddress = trim((string) ($abhaLogMatch[1] ?? '')); + } $abhaVerifiedStatus = trim((string) ($data[0]->abha_verified_status ?? '')); $abhaVerificationType = trim((string) ($data[0]->abha_verification_type ?? '')); $abhaKycVerified = (int) ($data[0]->abha_kyc_verified ?? 0) === 1; diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index 022460f..9ad389e 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -408,6 +408,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) { ABDM Task Board +