Add ABDM M3 HIU workflows, routes, UI, and runbook docs
This commit is contained in:
parent
14a9d851ad
commit
3d40642116
36
app/Commands/AbdmHiuRetry.php
Normal file
36
app/Commands/AbdmHiuRetry.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\Abdm\M3HiuWorkflowService;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class AbdmHiuRetry extends BaseCommand
|
||||
{
|
||||
protected $group = 'ABDM';
|
||||
protected $name = 'abdm:hiu-retry';
|
||||
protected $description = 'Retry failed transient ABDM M3 HIU workflow items.';
|
||||
protected $usage = 'abdm:hiu-retry [--limit 20]';
|
||||
protected $arguments = [];
|
||||
protected $options = [
|
||||
'--limit' => '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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
265
app/Controllers/AbdmHiu.php
Normal file
265
app/Controllers/AbdmHiu.php
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\Abdm\M3HiuWorkflowService;
|
||||
|
||||
class AbdmHiu extends BaseController
|
||||
{
|
||||
private M3HiuWorkflowService $service;
|
||||
|
||||
public function initController(
|
||||
\CodeIgniter\HTTP\RequestInterface $request,
|
||||
\CodeIgniter\HTTP\ResponseInterface $response,
|
||||
\Psr\Log\LoggerInterface $logger
|
||||
) {
|
||||
parent::initController($request, $response, $logger);
|
||||
$this->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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateAbdmM3HiuWorkflows extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->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);
|
||||
}
|
||||
}
|
||||
214
app/Libraries/Abdm/M3HiuGatewayClient.php
Normal file
214
app/Libraries/Abdm/M3HiuGatewayClient.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm;
|
||||
|
||||
class M3HiuGatewayClient
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private string $baseUrl;
|
||||
private int $timeoutSec = 45;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
332
app/Libraries/Abdm/M3HiuWorkflowService.php
Normal file
332
app/Libraries/Abdm/M3HiuWorkflowService.php
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm;
|
||||
|
||||
use CodeIgniter\I18n\Time;
|
||||
use App\Libraries\Abdm\M3HiuGatewayClient;
|
||||
|
||||
class M3HiuWorkflowService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private M3HiuGatewayClient $client;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
400
app/Views/abdm/hiu_m3.php
Normal file
400
app/Views/abdm/hiu_m3.php
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
<div class="container-fluid py-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">ABDM M3 HIU Console</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" id="btnRefreshTimeline">Refresh Timeline</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
Search patient first. Consent Request is enabled only when ABHA Number and ABHA Address are both present.
|
||||
</div>
|
||||
|
||||
<div class="card border-0 bg-light mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label mb-1">Search Patient</label>
|
||||
<input id="patient_search" class="form-control" placeholder="Name / UHID / Mobile / ABHA">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button id="btnPatientSearch" class="btn btn-outline-secondary w-100">Search</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div id="patient_status" class="small text-muted">No patient selected.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-hover mb-0" id="patient_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Patient</th>
|
||||
<th>UHID</th>
|
||||
<th>ABHA Number</th>
|
||||
<th>ABHA Address</th>
|
||||
<th>Last Consent State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-3"><input id="selected_patient" class="form-control" placeholder="Selected Patient" readonly></div>
|
||||
<div class="col-md-2"><input id="request_id" class="form-control" placeholder="requestId"></div>
|
||||
<div class="col-md-2"><input id="transaction_id" class="form-control" placeholder="transactionId"></div>
|
||||
<div class="col-md-2"><input id="consent_id" class="form-control" placeholder="consentId / consentRequestId"></div>
|
||||
<div class="col-md-3"><input id="abha_address" class="form-control" placeholder="abha_address" readonly></div>
|
||||
</div>
|
||||
|
||||
<div class="small mb-3" id="readiness_box"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<textarea id="payload_json" class="form-control" rows="8" placeholder='{"consent":{"patient":{"id":"someone@abdm"},"hiu":{"id":"YOUR_HIU_ID"},"hiTypes":["OPConsultation"]}}'></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary" id="btnConsentRequest" data-op="consent_request" disabled>Create Consent Request</button>
|
||||
<button class="btn btn-outline-primary" id="btnConsentStatus" data-op="consent_status" disabled>Check Consent Status</button>
|
||||
<button class="btn btn-outline-primary" id="btnConsentFetch" data-op="consent_fetch" disabled>Fetch Consent Artifact</button>
|
||||
<button class="btn btn-success" id="btnHiRequest" data-op="hi_request" disabled>Request Health Information</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<pre id="result_box" class="bg-dark text-light p-3 rounded" style="min-height:120px">{}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">Timeline / Logs</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-2"><input id="f_hfr_id" class="form-control" placeholder="Filter hfr_id"></div>
|
||||
<div class="col-md-2"><input id="f_consent_id" class="form-control" placeholder="Filter consent_id"></div>
|
||||
<div class="col-md-2"><input id="f_transaction_id" class="form-control" placeholder="Filter transaction_id"></div>
|
||||
<div class="col-md-2"><input id="f_abha_address" class="form-control" placeholder="Filter abha_address"></div>
|
||||
<div class="col-md-2"><input id="f_date_from" type="date" class="form-control"></div>
|
||||
<div class="col-md-2"><input id="f_date_to" type="date" class="form-control"></div>
|
||||
<div class="col-md-12"><button id="btnApplyFilter" class="btn btn-outline-secondary w-100">Apply Filters</button></div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle" id="timeline_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Time</th>
|
||||
<th>Operation</th>
|
||||
<th>State</th>
|
||||
<th>Status</th>
|
||||
<th>HFR</th>
|
||||
<th>ABHA Address</th>
|
||||
<th>Consent</th>
|
||||
<th>Txn</th>
|
||||
<th>Request</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var csrfName = '<?= csrf_token() ?>';
|
||||
var csrfHash = '<?= csrf_hash() ?>';
|
||||
var selectedPatient = null;
|
||||
var consentPollTimer = null;
|
||||
|
||||
var opRoute = {
|
||||
consent_request: '<?= base_url('AbdmHiu/consent_request') ?>',
|
||||
consent_status: '<?= base_url('AbdmHiu/consent_request_status') ?>',
|
||||
consent_fetch: '<?= base_url('AbdmHiu/consent_request_fetch') ?>',
|
||||
hi_request: '<?= base_url('AbdmHiu/health_information_request') ?>'
|
||||
};
|
||||
|
||||
var patientLookupUrl = '<?= base_url('AbdmHiu/patient_lookup') ?>';
|
||||
|
||||
function escHtml(v) {
|
||||
return (v || '').toString()
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function genReq(prefix) {
|
||||
var p = prefix || 'REQ-HIU';
|
||||
return p + '-' + Date.now();
|
||||
}
|
||||
|
||||
function updateReadinessBox(consentStateText) {
|
||||
var box = document.getElementById('readiness_box');
|
||||
if (!selectedPatient) {
|
||||
box.innerHTML = '<span class="text-muted">Select a patient to continue.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
var hasNum = selectedPatient.has_abha_number === 1;
|
||||
var hasAddr = selectedPatient.has_abha_address === 1;
|
||||
var state = (consentStateText || '').trim();
|
||||
|
||||
box.innerHTML = '' +
|
||||
'<span class="badge ' + (hasNum ? 'bg-success' : 'bg-secondary') + ' me-1">ABHA Number: ' + (hasNum ? 'Ready' : 'Missing') + '</span>' +
|
||||
'<span class="badge ' + (hasAddr ? 'bg-success' : 'bg-secondary') + ' me-1">ABHA Address: ' + (hasAddr ? 'Ready' : 'Missing') + '</span>' +
|
||||
'<span class="badge bg-info text-dark">Consent: ' + escHtml(state || 'Unknown') + '</span>';
|
||||
}
|
||||
|
||||
function isConsentApproved(statusText) {
|
||||
var s = (statusText || '').toString().trim().toLowerCase();
|
||||
return ['approved', 'granted', 'active', 'linked', 'completed'].indexOf(s) !== -1;
|
||||
}
|
||||
|
||||
function syncButtons(consentStateText) {
|
||||
var hasPatient = !!selectedPatient;
|
||||
var hasNum = hasPatient && selectedPatient.has_abha_number === 1;
|
||||
var hasAddr = hasPatient && selectedPatient.has_abha_address === 1;
|
||||
var hasConsentRef = (document.getElementById('consent_id').value.trim() !== '' || document.getElementById('request_id').value.trim() !== '');
|
||||
|
||||
document.getElementById('btnConsentRequest').disabled = !(hasNum && hasAddr);
|
||||
document.getElementById('btnConsentStatus').disabled = !hasConsentRef;
|
||||
document.getElementById('btnConsentFetch').disabled = !hasConsentRef;
|
||||
document.getElementById('btnHiRequest').disabled = !(hasConsentRef && isConsentApproved(consentStateText));
|
||||
updateReadinessBox(consentStateText);
|
||||
}
|
||||
|
||||
function setSelectedPatient(p) {
|
||||
selectedPatient = p;
|
||||
document.getElementById('selected_patient').value = (p.patient_name || '') + (p.patient_code ? ' (' + p.patient_code + ')' : '');
|
||||
document.getElementById('abha_address').value = p.abha_address || '';
|
||||
document.getElementById('f_abha_address').value = p.abha_address || '';
|
||||
document.getElementById('request_id').value = genReq('REQ-CONSENT');
|
||||
document.getElementById('transaction_id').value = genReq('TXN-HIU');
|
||||
|
||||
var latest = p.latest_consent || {};
|
||||
var consentRef = (latest.consent_id || latest.request_id || '').toString();
|
||||
if (consentRef) {
|
||||
document.getElementById('consent_id').value = consentRef;
|
||||
document.getElementById('f_consent_id').value = consentRef;
|
||||
}
|
||||
|
||||
var consentState = (latest.workflow_state || latest.status || 'Unknown').toString();
|
||||
document.getElementById('patient_status').textContent = 'Selected: ' + (p.patient_name || '-') + ' | Consent: ' + consentState;
|
||||
syncButtons(consentState);
|
||||
fetchTimeline();
|
||||
}
|
||||
|
||||
function renderPatientRows(items) {
|
||||
var tbody = document.querySelector('#patient_table tbody');
|
||||
tbody.innerHTML = '';
|
||||
items.forEach(function (p, idx) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '' +
|
||||
'<td><button class="btn btn-sm btn-outline-primary" data-select-idx="' + idx + '">Select</button></td>' +
|
||||
'<td>' + escHtml(p.patient_name || '') + '</td>' +
|
||||
'<td>' + escHtml(p.patient_code || '') + '</td>' +
|
||||
'<td>' + escHtml(p.abha_number || '') + '</td>' +
|
||||
'<td>' + escHtml(p.abha_address || '') + '</td>' +
|
||||
'<td>' + escHtml(((p.latest_consent || {}).workflow_state || (p.latest_consent || {}).status || '-')) + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('button[data-select-idx]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var idx = parseInt(btn.getAttribute('data-select-idx'), 10);
|
||||
if (!Number.isNaN(idx) && items[idx]) {
|
||||
setSelectedPatient(items[idx]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function searchPatients() {
|
||||
var q = document.getElementById('patient_search').value.trim();
|
||||
var url = patientLookupUrl + '?q=' + encodeURIComponent(q);
|
||||
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if ((res.ok || 0) !== 1) {
|
||||
document.getElementById('patient_status').textContent = res.error || 'Patient search failed';
|
||||
return;
|
||||
}
|
||||
renderPatientRows(res.items || []);
|
||||
document.getElementById('patient_status').textContent = (res.count || 0) + ' patient(s) found';
|
||||
})
|
||||
.catch(function (e) {
|
||||
document.getElementById('patient_status').textContent = e.message || 'Patient search failed';
|
||||
});
|
||||
}
|
||||
|
||||
function readPayload(op) {
|
||||
var custom = {};
|
||||
try {
|
||||
var raw = document.getElementById('payload_json').value.trim();
|
||||
custom = raw ? JSON.parse(raw) : {};
|
||||
} catch (e) {
|
||||
throw new Error('Invalid payload JSON');
|
||||
}
|
||||
|
||||
var payload = Object.assign({}, custom, {
|
||||
requestId: document.getElementById('request_id').value.trim(),
|
||||
transactionId: document.getElementById('transaction_id').value.trim(),
|
||||
abha_address: document.getElementById('abha_address').value.trim(),
|
||||
patient_id: selectedPatient ? selectedPatient.patient_id : null
|
||||
});
|
||||
|
||||
var consentVal = document.getElementById('consent_id').value.trim();
|
||||
if (consentVal !== '') {
|
||||
payload.consentId = consentVal;
|
||||
payload.consentRequestId = consentVal;
|
||||
}
|
||||
|
||||
if (!payload.timestamp) {
|
||||
payload.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
payload[csrfName] = csrfHash;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function setResult(obj) {
|
||||
document.getElementById('result_box').textContent = JSON.stringify(obj, null, 2);
|
||||
}
|
||||
|
||||
function postJson(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) {
|
||||
return r.json().catch(function () { return { ok: 0, message: 'Non-JSON response', http_code: r.status }; });
|
||||
});
|
||||
}
|
||||
|
||||
function run(op, options) {
|
||||
options = options || {};
|
||||
var url = opRoute[op];
|
||||
if (!url) {
|
||||
setResult({ ok: 0, message: 'Unsupported operation' });
|
||||
return;
|
||||
}
|
||||
var payload = readPayload(op);
|
||||
postJson(url, payload).then(function (res) {
|
||||
if (!options.silent) {
|
||||
setResult(res);
|
||||
}
|
||||
|
||||
if ((res.ok || 0) === 1) {
|
||||
var data = res.data || {};
|
||||
var consentRef = (data.consentId || data.consentRequestId || data.consent_id || data.requestId || data.request_id || '').toString();
|
||||
if (consentRef) {
|
||||
document.getElementById('consent_id').value = consentRef;
|
||||
document.getElementById('f_consent_id').value = consentRef;
|
||||
}
|
||||
|
||||
var state = (data.workflow_state || data.consent_status || data.status || res.workflow_state || '').toString();
|
||||
syncButtons(state);
|
||||
|
||||
if (op === 'consent_request') {
|
||||
startConsentPolling();
|
||||
}
|
||||
if (op === 'consent_status' && isConsentApproved(state)) {
|
||||
stopConsentPolling();
|
||||
}
|
||||
}
|
||||
|
||||
fetchTimeline();
|
||||
}).catch(function (e) {
|
||||
setResult({ ok: 0, message: e.message });
|
||||
});
|
||||
}
|
||||
|
||||
function startConsentPolling() {
|
||||
stopConsentPolling();
|
||||
consentPollTimer = setInterval(function () {
|
||||
run('consent_status', { silent: true });
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function stopConsentPolling() {
|
||||
if (consentPollTimer) {
|
||||
clearInterval(consentPollTimer);
|
||||
consentPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchTimeline() {
|
||||
var qs = new URLSearchParams({
|
||||
hfr_id: document.getElementById('f_hfr_id').value.trim(),
|
||||
consent_id: document.getElementById('f_consent_id').value.trim(),
|
||||
transaction_id: document.getElementById('f_transaction_id').value.trim(),
|
||||
abha_address: document.getElementById('f_abha_address').value.trim(),
|
||||
date_from: document.getElementById('f_date_from').value,
|
||||
date_to: document.getElementById('f_date_to').value
|
||||
});
|
||||
|
||||
fetch('<?= base_url('AbdmHiu/timeline') ?>?' + qs.toString(), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var tbody = document.querySelector('#timeline_table tbody');
|
||||
tbody.innerHTML = '';
|
||||
(res.items || []).forEach(function (row) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '' +
|
||||
'<td>' + (row.id || '') + '</td>' +
|
||||
'<td>' + (row.created_at || '') + '</td>' +
|
||||
'<td>' + (row.operation || '') + '</td>' +
|
||||
'<td>' + (row.workflow_state || '') + '</td>' +
|
||||
'<td>' + (row.status || '') + '</td>' +
|
||||
'<td>' + (row.hfr_id || '') + '</td>' +
|
||||
'<td>' + (row.abha_address || '') + '</td>' +
|
||||
'<td>' + (row.consent_id || '') + '</td>' +
|
||||
'<td>' + (row.transaction_id || '') + '</td>' +
|
||||
'<td>' + (row.request_id || '') + '</td>' +
|
||||
'<td>' + (row.last_error || '') + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btnPatientSearch').addEventListener('click', searchPatients);
|
||||
document.getElementById('patient_search').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
searchPatients();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('button[data-op]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
run(btn.getAttribute('data-op'));
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('consent_id').addEventListener('input', function () {
|
||||
syncButtons();
|
||||
});
|
||||
|
||||
document.getElementById('btnApplyFilter').addEventListener('click', fetchTimeline);
|
||||
document.getElementById('btnRefreshTimeline').addEventListener('click', fetchTimeline);
|
||||
|
||||
syncButtons();
|
||||
fetchTimeline();
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -16,6 +16,9 @@
|
|||
<?php
|
||||
$patientAbhaId = (string) ($data[0]->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;
|
||||
|
|
|
|||
|
|
@ -408,6 +408,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
|
|||
<span>ABDM Task Board</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('AbdmHiu') ?>','ABDM HIU M3')">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
<span>ABDM HIU M3</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link collapsed" href="javascript:load_form('<?= base_url('AbdmOpdQueue') ?>','ABDM OPD Queue')">
|
||||
<i class="bi bi-people"></i>
|
||||
|
|
|
|||
81
docs/ABDM_M3_HIU_QA_REPORT.md
Normal file
81
docs/ABDM_M3_HIU_QA_REPORT.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# ABDM M3 HIU QA Report
|
||||
|
||||
## Build Info
|
||||
- Date:
|
||||
- Branch/Commit:
|
||||
- Tester:
|
||||
|
||||
## Test Matrix
|
||||
|
||||
### 1. Happy path
|
||||
- request -> status -> fetch -> health-information request
|
||||
- Expected: workflow reaches COMPLETED
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 2. Missing token
|
||||
- Remove/blank EATRIA_BRIDGE_TOKEN
|
||||
- Expected: unauthorized/fail-fast error, no gateway call
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 3. Missing hfr_id
|
||||
- Remove/blank ABDM_HFR_ID
|
||||
- Expected: validation fail-fast
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 4. Wrong hfr_id/token mismatch
|
||||
- Send payload hfr_id that mismatches configured ABDM_HFR_ID
|
||||
- Expected: blocked with clear actionable message
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 5. Duplicate request_id replay
|
||||
- Re-send same request_id + operation
|
||||
- Expected: idempotent response, no duplicate row
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 6. Duplicate transaction_id replay
|
||||
- Re-send same transaction_id + operation
|
||||
- Expected: idempotent response, no duplicate row
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 7. Timeout/5xx retry behavior
|
||||
- Simulate transient gateway failure
|
||||
- Expected: retryable=1; queued for retry without duplicate workflow rows
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 8. Consent revoked flow
|
||||
- status returns revoked
|
||||
- Expected: workflow state REVOKED
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 9. Consent expired flow
|
||||
- status returns expired
|
||||
- Expected: workflow state EXPIRED
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
### 10. Audit completeness
|
||||
- Verify request/response/status/error/hospital context persisted
|
||||
- Result:
|
||||
- Evidence:
|
||||
|
||||
## Defects
|
||||
| ID | Severity | Scenario | Description | Status |
|
||||
|----|----------|----------|-------------|--------|
|
||||
|
||||
## Final Signoff
|
||||
- [ ] All 4 APIs integrated
|
||||
- [ ] Full audit trail verified
|
||||
- [ ] Hospital isolation by hfr_id verified
|
||||
- [ ] All QA scenarios passed
|
||||
- [ ] Runbook shared with support team
|
||||
|
||||
Signoff By:
|
||||
Date:
|
||||
93
docs/ABDM_M3_HIU_RUNBOOK.md
Normal file
93
docs/ABDM_M3_HIU_RUNBOOK.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# ABDM M3 HIU Runbook
|
||||
|
||||
## Scope
|
||||
This runbook covers HMS-side M3 HIU integration to gateway endpoints:
|
||||
- POST /api/v3/hiu/consent/request
|
||||
- POST /api/v3/hiu/consent/request/status
|
||||
- POST /api/v3/hiu/consent/request/fetch
|
||||
- POST /api/v3/hiu/health-information/request
|
||||
|
||||
## Prerequisites
|
||||
1. Hospital settings must include:
|
||||
- EATRIA_BRIDGE_TOKEN
|
||||
- ABDM_HFR_ID
|
||||
- ABDM_HOSPITAL_ID (optional but recommended)
|
||||
2. Permissions required:
|
||||
- abdm.access OR abdm.taskboard.access OR abdm.gateway.use
|
||||
|
||||
## Deploy Steps
|
||||
1. Pull latest code.
|
||||
2. Run migration:
|
||||
```bash
|
||||
php spark migrate --namespace App
|
||||
```
|
||||
3. Verify new table exists:
|
||||
- abdm_hiu_workflows
|
||||
4. Open HIU UI:
|
||||
- /AbdmHiu
|
||||
|
||||
## Operational Flow
|
||||
1. Create Consent Request -> state REQUESTED
|
||||
2. Check Consent Status -> state STATUS_CHECKED
|
||||
3. Fetch Consent Artifact -> state CONSENT_FETCHED
|
||||
4. Request Health Information -> state DATA_REQUESTED / COMPLETED
|
||||
|
||||
## Workflow States
|
||||
- CREATED
|
||||
- REQUESTED
|
||||
- STATUS_CHECKED
|
||||
- CONSENT_FETCHED
|
||||
- DATA_REQUESTED
|
||||
- COMPLETED
|
||||
- FAILED
|
||||
- REVOKED
|
||||
- EXPIRED
|
||||
|
||||
## Idempotency
|
||||
- request_id + operation: unique
|
||||
- transaction_id + operation: unique
|
||||
- Duplicate calls return previous result with duplicate=1
|
||||
|
||||
## Retry Policy
|
||||
Only transient errors are retried:
|
||||
- network/curl errors
|
||||
- timeout
|
||||
- HTTP 5xx
|
||||
|
||||
Command:
|
||||
```bash
|
||||
php spark abdm:hiu-retry --limit 20
|
||||
```
|
||||
|
||||
## Audit Trail
|
||||
All calls are logged in abdm_api_logs with:
|
||||
- request_json
|
||||
- response_json
|
||||
- response_code
|
||||
- status
|
||||
- error_message
|
||||
- endpoint
|
||||
- event_type prefixed with abdm.m3.hiu.*
|
||||
|
||||
Workflow rows are stored in abdm_hiu_workflows including:
|
||||
- request_id
|
||||
- transaction_id
|
||||
- consent_id
|
||||
- abha_address
|
||||
- hfr_id
|
||||
- state/status timestamps
|
||||
|
||||
## Failure Handling
|
||||
1. Missing token:
|
||||
- Error: Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN
|
||||
2. Missing hfr_id:
|
||||
- Error: Missing hfr_id: set hospital_setting.ABDM_HFR_ID
|
||||
3. Token/HFR mismatch:
|
||||
- Error: hfr_id mismatch with configured ABDM_HFR_ID
|
||||
|
||||
## Support Checklist
|
||||
1. Confirm token and hfr_id in hospital_setting.
|
||||
2. Reproduce in /AbdmHiu.
|
||||
3. Check timeline filters by hfr_id/consent_id/transaction_id/date.
|
||||
4. Check abdm_api_logs for request_id and endpoint error.
|
||||
5. Retry transient failures using spark command.
|
||||
Loading…
Reference in New Issue
Block a user