diff --git a/ABDM_GATEWAY_PHP_DEPLOYMENT.md b/ABDM_GATEWAY_PHP_DEPLOYMENT.md index ebb487c..f1df8d4 100644 --- a/ABDM_GATEWAY_PHP_DEPLOYMENT.md +++ b/ABDM_GATEWAY_PHP_DEPLOYMENT.md @@ -247,17 +247,34 @@ curl -i -H "Authorization: Bearer $TOKEN" https://abdm-bridge.e-atria.in/api/v3/ ssh user@hms-server cd /opt/hms-ci4 -# Process up to 5 queued events +# Install/update cron jobs for HMS background workers (idempotent) +bash scripts/install_hms_cron.sh /opt/hms-ci4 + +# Manual one-time run checks +php spark abdm:push-sync --limit=20 php spark bridge:sync --limit 5 ``` **Expected Output:** ``` +ABDM Push Sync +Processed: N +Success: N +Failed: 0 + Bridge sync completed: -- Processed: 5 -- Sent: 5 +- Processed: N +- Sent: N - Failed: 0 -- Message: queue processing completed +``` + +### 4. Recommended Cron Block (Installed by Script) +```cron +* * * * * cd /opt/hms-ci4 && php spark abdm:push-sync --limit=20 --worker=cron-abdm-m2 >> /opt/hms-ci4/writable/logs/cron/abdm_push_sync.log 2>&1 +* * * * * cd /opt/hms-ci4 && php spark bridge:sync --limit 10 >> /opt/hms-ci4/writable/logs/cron/bridge_sync.log 2>&1 +* * * * * cd /opt/hms-ci4 && php spark snomed:process-coding >> /opt/hms-ci4/writable/logs/cron/snomed_process.log 2>&1 +*/2 * * * * cd /opt/hms-ci4 && php spark abdm:hiu-poll --limit=30 >> /opt/hms-ci4/writable/logs/cron/abdm_hiu_poll.log 2>&1 +*/5 * * * * cd /opt/hms-ci4 && php spark abdm:hiu-retry --limit=20 >> /opt/hms-ci4/writable/logs/cron/abdm_hiu_retry.log 2>&1 ``` --- diff --git a/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md b/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md index f13e48b..d3f27cb 100644 --- a/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md +++ b/ABDM_M2_PUSH_SYNC_IMPLEMENTATION.md @@ -3,7 +3,7 @@ ## Architecture Summary - Added dedicated sync tables: `abdm_sync_patient`, `abdm_sync_record`, `abdm_sync_outbox`. - Added queue/outbox service and worker command for reliable push delivery. -- Added Gateway client for `/api/v3/records/push` with response mapping (201/409/4xx/5xx). +- Added Gateway client for `/api/v1/abdm/gateway/care-context/link` with validation and response mapping. - Added FHIR generator framework with module-specific generators. - Added advanced coding resolver with LOINC/SNOMED/UCUM fallback logic and validation scoring. - Added observability endpoints: @@ -32,8 +32,8 @@ - Score < 70: mark review-needed (policy hook available) ## Outbox Retry Policy -- 8 retries: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 8h -- After retry 8 -> `dead` +- Care-context outbound default retries: 10s, 30s, 60s +- Legacy schedule fallback still supported for older outbox rows ## Security Notes - Bearer token never logged. @@ -63,6 +63,16 @@ - Worker: `php spark abdm:push-sync --limit=20` - Existing bridge worker remains separate: `php spark bridge:sync` +## Cron Setup (HMS Server) +- Use `bash scripts/install_hms_cron.sh /var/www/html/hms_etria` +- Deploy script now runs this installer automatically after migrate. +- Managed cron block includes: + - `abdm:push-sync` every minute + - `bridge:sync` every minute + - `snomed:process-coding` every minute + - `abdm:hiu-poll` every 2 minutes + - `abdm:hiu-retry` every 5 minutes + ## Manual Replay - Endpoint: `POST /AbdmSync/replay_dead/{id}` - Resets dead row to `pending` for reprocessing. diff --git a/app/Controllers/AbdmSync.php b/app/Controllers/AbdmSync.php index 207d78f..dbe0637 100644 --- a/app/Controllers/AbdmSync.php +++ b/app/Controllers/AbdmSync.php @@ -49,6 +49,7 @@ class AbdmSync extends BaseController $payload = [ 'local_record_id' => (string) ($this->request->getPost('local_record_id') ?? ''), 'local_patient_id' => (int) $this->request->getPost('local_patient_id'), + 'hospital_id' => (int) ($this->request->getPost('hospital_id') ?? 0), 'hi_type' => (string) ($this->request->getPost('hi_type') ?? ''), 'care_context_reference' => (string) ($this->request->getPost('care_context_reference') ?? ''), 'care_context_display' => (string) ($this->request->getPost('care_context_display') ?? ''), @@ -59,6 +60,9 @@ class AbdmSync extends BaseController 'hfr_id' => (string) ($this->request->getPost('hfr_id') ?? ''), 'source_updated_at' => (string) ($this->request->getPost('source_updated_at') ?? date('Y-m-d H:i:s')), 'patient_name' => (string) ($this->request->getPost('patient_name') ?? ''), + 'mobile' => (string) ($this->request->getPost('mobile') ?? ''), + 'gender' => (string) ($this->request->getPost('gender') ?? ''), + 'dob' => (string) ($this->request->getPost('dob') ?? ''), 'abha_id' => (string) ($this->request->getPost('abha_id') ?? ''), 'abha_address' => (string) ($this->request->getPost('abha_address') ?? ''), 'fhir_bundle' => $bundle, diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index f1c0d81..fd2c414 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -7765,6 +7765,9 @@ class Ipd_discharge extends BaseController 'hfr_id' => $hfrId, 'source_updated_at' => $sourceUpdatedAt, 'patient_name' => (string) ($gatewayPayload['patient_name'] ?? $patientName), + 'mobile' => (string) ($patientRow['mphone1'] ?? ''), + 'gender' => (string) ($patientRow['gender'] ?? $patientRow['xgender'] ?? ''), + 'dob' => (string) ($patientRow['dob'] ?? ''), 'abha_id' => (string) ($gatewayPayload['abha_id'] ?? $abhaDigits), 'abha_address' => (string) ($gatewayPayload['abha_address'] ?? $abhaAddress), 'fhir_bundle' => (array) ($gatewayPayload['fhir_bundle'] ?? []), diff --git a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php index 503442a..d1bfc5d 100644 --- a/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php +++ b/app/Libraries/Abdm/Sync/AbdmGatewayPushClient.php @@ -20,6 +20,16 @@ class AbdmGatewayPushClient 'WellnessRecord', ]; + /** @var string[] */ + private array $allowedRecordTypes = [ + 'PrescriptionRecord', + 'DiagnosticReportRecord', + 'DischargeSummaryRecord', + 'OPConsultationRecord', + 'WellnessRecord', + 'HealthDocumentRecord', + ]; + public function __construct(?BaseConnection $db = null) { $this->db = $db ?? db_connect(); @@ -104,7 +114,7 @@ class AbdmGatewayPushClient $mapped['latency_ms'] = (int) round((microtime(true) - $startedAt) * 1000); $mapped['gateway_request_id'] = (string) ($response->getHeaderLine('X-Request-Id') ?: ($body['request_id'] ?? '')); - $this->logPush($payload, $mapped, $requestId); + $this->logPush($payload, $mapped, $requestId, '/api/v3/records/push', 'abdm.sync.outbox.records_push', 'record'); return $mapped; } catch (\Throwable $e) { $result = [ @@ -118,7 +128,105 @@ class AbdmGatewayPushClient 'gateway_request_id' => '', 'latency_ms' => (int) round((microtime(true) - $startedAt) * 1000), ]; - $this->logPush($payload, $result, $requestId); + $this->logPush($payload, $result, $requestId, '/api/v3/records/push', 'abdm.sync.outbox.records_push', 'record'); + return $result; + } + } + + /** + * @param array $payload + * @return array + */ + public function pushCareContextLink(array $payload, string $idempotencyKey = ''): array + { + $requestId = $this->createRequestId(); + $validation = $this->validateCareContextPayload($payload); + if (($validation['valid'] ?? false) !== true) { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => 400, + 'request_id' => $requestId, + 'error' => 'validation_failed', + 'message' => implode('; ', $validation['errors'] ?? ['Invalid payload']), + 'retryable' => false, + ]; + } + + $baseUrl = rtrim($this->readSetting('ABDM_BRIDGE_URL', 'EATRIA_BRIDGE_URL'), '/'); + if ($baseUrl === '') { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => 500, + 'request_id' => $requestId, + 'error' => 'bridge_url_missing', + 'message' => 'ABDM bridge URL is not configured.', + 'retryable' => false, + ]; + } + + $token = trim($this->readSetting('EATRIA_BRIDGE_TOKEN', 'ABDM_BRIDGE_TOKEN')); + if ($token === '') { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => 500, + 'request_id' => $requestId, + 'error' => 'token_missing', + 'message' => 'ABDM bridge token is not configured.', + 'retryable' => false, + ]; + } + + $url = $this->buildGatewayUrl($baseUrl, '/api/v1/abdm/gateway/care-context/link'); + $headers = [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + 'X-Request-Id' => $requestId, + ]; + if ($idempotencyKey !== '') { + $headers['X-Idempotency-Key'] = $idempotencyKey; + } + + $startedAt = microtime(true); + try { + $client = service('curlrequest', [ + 'timeout' => 30, + 'connect_timeout' => 10, + 'http_errors' => false, + ]); + + $response = $client->post($url, [ + 'headers' => $headers, + 'json' => $payload, + ]); + + $httpStatus = (int) $response->getStatusCode(); + $body = json_decode((string) $response->getBody(), true); + if (! is_array($body)) { + $body = ['raw' => (string) $response->getBody()]; + } + + $mapped = $this->mapCareContextResponse($httpStatus, $body, $requestId); + $mapped['latency_ms'] = (int) round((microtime(true) - $startedAt) * 1000); + $mapped['gateway_request_id'] = (string) ($response->getHeaderLine('X-Request-Id') ?: ($body['request_id'] ?? '')); + + $this->logPush($payload, $mapped, $requestId, '/api/v1/abdm/gateway/care-context/link', 'abdm.sync.outbox.care_context_link', 'care_context'); + return $mapped; + } catch (\Throwable $e) { + $result = [ + 'ok' => false, + 'status' => 'retry', + 'http_status' => 0, + 'request_id' => $requestId, + 'error' => 'network_error', + 'message' => $e->getMessage(), + 'retryable' => true, + 'gateway_request_id' => '', + 'latency_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ]; + $this->logPush($payload, $result, $requestId, '/api/v1/abdm/gateway/care-context/link', 'abdm.sync.outbox.care_context_link', 'care_context'); return $result; } } @@ -164,11 +272,91 @@ class AbdmGatewayPushClient ]; } + /** + * @param array $payload + * @return array + */ + public function validateCareContextPayload(array $payload): array + { + $errors = []; + + if ((int) ($payload['hospital_id'] ?? 0) <= 0) { + $errors[] = 'hospital_id is required'; + } + + $hfrId = trim((string) ($payload['hfr_id'] ?? '')); + if ($hfrId === '') { + $errors[] = 'hfr_id is required'; + } + + $patient = $payload['patient'] ?? null; + if (! is_array($patient)) { + $errors[] = 'patient object is required'; + } else { + if (trim((string) ($patient['patient_id'] ?? '')) === '') { + $errors[] = 'patient.patient_id is required'; + } + if (trim((string) ($patient['name'] ?? '')) === '') { + $errors[] = 'patient.name is required'; + } + + $mobileDigits = preg_replace('/\D/', '', (string) ($patient['mobile'] ?? '')); + if (! is_string($mobileDigits) || strlen($mobileDigits) !== 10) { + $errors[] = 'patient.mobile must be a 10-digit number'; + } + + $gender = strtoupper(trim((string) ($patient['gender'] ?? ''))); + if (! in_array($gender, ['M', 'F', 'O'], true)) { + $errors[] = 'patient.gender must be M, F, or O'; + } + + $yob = (int) ($patient['year_of_birth'] ?? 0); + $currentYear = (int) date('Y'); + if ($yob < 1900 || $yob > $currentYear) { + $errors[] = 'patient.year_of_birth must be a valid 4-digit year'; + } + } + + $contexts = $payload['care_contexts'] ?? null; + if (! is_array($contexts) || $contexts === []) { + $errors[] = 'care_contexts must contain at least one item'; + } else { + foreach ($contexts as $index => $context) { + if (! is_array($context)) { + $errors[] = 'care_contexts[' . $index . '] must be an object'; + continue; + } + + if (trim((string) ($context['reference_number'] ?? '')) === '') { + $errors[] = 'care_contexts[' . $index . '].reference_number is required'; + } + if (trim((string) ($context['display'] ?? '')) === '') { + $errors[] = 'care_contexts[' . $index . '].display is required'; + } + + $recordType = trim((string) ($context['record_type'] ?? '')); + if (! in_array($recordType, $this->allowedRecordTypes, true)) { + $errors[] = 'care_contexts[' . $index . '].record_type is invalid'; + } + + $visitDate = trim((string) ($context['visit_date'] ?? '')); + if ($visitDate === '' || ! preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $visitDate)) { + $errors[] = 'care_contexts[' . $index . '].visit_date must be YYYY-MM-DD HH:MM:SS'; + } + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + /** * @param array $payload * @param array $result */ - private function logPush(array $payload, array $result, string $requestId): void + private function logPush(array $payload, array $result, string $requestId, string $endpoint, string $eventType, string $entityType): void { if (! $this->db->tableExists('abdm_api_logs')) { return; @@ -185,10 +373,10 @@ class AbdmGatewayPushClient $this->db->table('abdm_api_logs')->insert([ 'channel' => 'eatria_bridge', - 'event_type' => 'abdm.sync.outbox.records_push', - 'endpoint' => '/api/v3/records/push', + 'event_type' => $eventType, + 'endpoint' => $endpoint, 'http_method' => 'POST', - 'entity_type' => 'record', + 'entity_type' => $entityType, 'entity_id' => (string) ($payload['local_record_id'] ?? ''), 'request_json' => (string) json_encode($maskedPayload + ['request_id' => $requestId], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'response_code' => (int) ($result['http_status'] ?? 0), @@ -325,6 +513,77 @@ class AbdmGatewayPushClient ]; } + /** + * @param array $body + * @return array + */ + private function mapCareContextResponse(int $httpStatus, array $body, string $requestId): array + { + $message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus)); + $ok = (int) ($body['ok'] ?? 0) === 1; + + if (($httpStatus >= 200 && $httpStatus < 300) && $ok) { + return [ + 'ok' => true, + 'status' => 'done', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + 'submitted' => 1, + 'duplicate' => 0, + 'message' => $message !== '' ? $message : 'Care context linked successfully', + 'retryable' => false, + ]; + } + + if ($httpStatus >= 500 || $httpStatus === 0) { + return [ + 'ok' => false, + 'status' => 'retry', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'error' => 'gateway_unavailable', + 'message' => $message, + 'retryable' => true, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + 'submitted' => 0, + 'duplicate' => 0, + ]; + } + + if (in_array($httpStatus, [400, 401, 403, 422], true)) { + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'error' => 'validation_or_auth_error', + 'message' => $message, + 'retryable' => false, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + 'submitted' => 0, + 'duplicate' => 0, + ]; + } + + return [ + 'ok' => false, + 'status' => 'failed', + 'http_status' => $httpStatus, + 'request_id' => $requestId, + 'error' => 'unknown_error', + 'message' => $message, + 'retryable' => false, + 'gateway_record_id' => null, + 'gateway_queue_id' => null, + 'submitted' => 0, + 'duplicate' => 0, + ]; + } + private function createRequestId(): string { $bytes = random_bytes(16); @@ -394,4 +653,10 @@ class AbdmGatewayPushClient return str_repeat('*', strlen($left) - 4) . substr($left, -4) . ($right !== '' ? '@' . $right : ''); } + + private function buildGatewayUrl(string $baseUrl, string $path): string + { + $url = rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); + return (string) preg_replace('#/api/api/#', '/api/', $url); + } } diff --git a/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php b/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php index 27835d8..39eab1b 100644 --- a/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php +++ b/app/Libraries/Abdm/Sync/AbdmSyncOutboxService.php @@ -94,6 +94,13 @@ class AbdmSyncOutboxService return null; } + if (empty($payload['outbound_event'])) { + $payload['outbound_event'] = 'care_context_link'; + } + if (empty($payload['retry_schedule_seconds']) || ! is_array($payload['retry_schedule_seconds'])) { + $payload['retry_schedule_seconds'] = [10, 30, 60]; + } + $sourceUpdatedAt = $this->normalizeSourceUpdatedAt((string) ($payload['source_updated_at'] ?? '')); $idempotencyKey = $this->buildRecordIdempotencyKey($hfrId, $careContextReference, $sourceUpdatedAt); @@ -170,9 +177,9 @@ class AbdmSyncOutboxService ]); } - public function markRetryOrDead(int $id, int $retryCount, string $error): void + public function markRetryOrDead(int $id, int $retryCount, string $error, ?array $retryScheduleSeconds = null): void { - $next = $this->getNextRetryAt($retryCount); + $next = $this->getNextRetryAt($retryCount, $retryScheduleSeconds); $status = $next === null ? self::STATUS_DEAD : self::STATUS_FAILED; $this->outboxModel->update($id, [ @@ -260,17 +267,19 @@ class AbdmSyncOutboxService return $hfrId . '|' . $careContextReference . '|' . $epoch; } - public function getNextRetryAt(int $retryCount): ?string + public function getNextRetryAt(int $retryCount, ?array $retryScheduleSeconds = null): ?string { + $schedule = $this->normalizeRetrySchedule($retryScheduleSeconds); + if ($retryCount <= 0) { return Time::now('Asia/Kolkata')->toDateTimeString(); } - if ($retryCount > count($this->retryScheduleSeconds)) { + if ($retryCount > count($schedule)) { return null; } - $delay = $this->retryScheduleSeconds[$retryCount - 1]; + $delay = $schedule[$retryCount - 1]; return date('Y-m-d H:i:s', time() + $delay); } @@ -329,4 +338,27 @@ class AbdmSyncOutboxService $digits = preg_replace('/\D/', '', $abha); return is_string($digits) ? $digits : ''; } + + /** + * @param array|null $retryScheduleSeconds + * @return int[] + */ + private function normalizeRetrySchedule(?array $retryScheduleSeconds): array + { + $schedule = []; + if (is_array($retryScheduleSeconds)) { + foreach ($retryScheduleSeconds as $seconds) { + $value = (int) $seconds; + if ($value > 0) { + $schedule[] = $value; + } + } + } + + if (empty($schedule)) { + return $this->retryScheduleSeconds; + } + + return array_values($schedule); + } } diff --git a/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php b/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php index 91f0e29..cbe3030 100644 --- a/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php +++ b/app/Libraries/Abdm/Sync/AbdmSyncWorkerService.php @@ -81,8 +81,9 @@ class AbdmSyncWorkerService } $retryCount = ((int) ($row['retry_count'] ?? 0)) + 1; - $this->outbox->markRetryOrDead($id, $retryCount, (string) ($result['message'] ?? 'Sync failed')); - if ($this->outbox->getNextRetryAt($retryCount) === null) { + $retrySchedule = $this->resolveRetryScheduleSeconds($payload); + $this->outbox->markRetryOrDead($id, $retryCount, (string) ($result['message'] ?? 'Sync failed'), $retrySchedule); + if ($this->outbox->getNextRetryAt($retryCount, $retrySchedule) === null) { $summary['dead']++; } else { $summary['failed']++; @@ -103,28 +104,45 @@ class AbdmSyncWorkerService return ['ok' => false, 'retryable' => false, 'message' => 'Sync record not found']; } - $fhirBundle = $payload['fhir_bundle'] ?? json_decode((string) ($record['fhir_bundle_json'] ?? '{}'), true); - if (! is_array($fhirBundle)) { - $fhirBundle = []; + $patientProfile = $this->resolvePatientProfile((int) ($record['local_patient_id'] ?? 0), $payload); + + $hospitalId = (int) ($payload['hospital_id'] ?? 0); + if ($hospitalId <= 0) { + $hospitalId = $this->resolveHospitalIdFromSettings(); } - $gatewayPayload = [ + $recordType = $this->mapHiTypeToRecordType((string) ($record['hi_type'] ?? '')); + $careContextPayload = [ + 'hospital_id' => $hospitalId, 'hfr_id' => (string) ($record['hfr_id'] ?? ''), - 'hi_type' => (string) ($record['hi_type'] ?? ''), - 'record_type' => (string) ($record['hi_type'] ?? ''), - 'abha_id' => $this->normalizeAbhaDigits((string) ($payload['abha_id'] ?? '')), - 'abha_address' => trim((string) ($payload['abha_address'] ?? '')), - 'patient_name' => trim((string) ($payload['patient_name'] ?? '')), - 'local_patient_id' => (string) ($record['local_patient_id'] ?? ''), - 'care_context_reference' => (string) ($record['care_context_reference'] ?? ''), - 'care_context_display' => (string) ($record['care_context_display'] ?? ''), - 'visit_date' => (string) ($record['visit_date'] ?? ''), - 'department' => (string) ($record['department'] ?? ''), - 'doctor_name' => (string) ($record['doctor_name'] ?? ''), - 'fhir_bundle' => $fhirBundle, + 'patient' => [ + 'patient_id' => $patientProfile['patient_id'], + 'name' => $patientProfile['name'], + 'mobile' => $patientProfile['mobile'], + 'gender' => $patientProfile['gender'], + 'year_of_birth' => $patientProfile['year_of_birth'], + ], + 'care_contexts' => [[ + 'reference_number' => (string) ($record['care_context_reference'] ?? ''), + 'display' => (string) ($record['care_context_display'] ?? $recordType), + 'record_type' => $recordType, + 'visit_date' => $this->normalizeVisitDateTime((string) ($record['visit_date'] ?? '')), + 'doctor_name' => (string) ($record['doctor_name'] ?? ''), + 'department' => (string) ($record['department'] ?? ''), + ]], ]; - $result = $this->gatewayClient->pushRecord($gatewayPayload, $idempotencyKey); + $abhaAddress = trim((string) ($patientProfile['abha_address'] ?? '')); + if ($abhaAddress !== '') { + $careContextPayload['patient']['abha_address'] = $abhaAddress; + } + + $abhaNumber = trim((string) ($patientProfile['abha_number'] ?? '')); + if ($abhaNumber !== '') { + $careContextPayload['patient']['abha_number'] = $abhaNumber; + } + + $result = $this->gatewayClient->pushCareContextLink($careContextPayload, $idempotencyKey); $this->recordModel->update($syncRecordId, [ 'sync_status' => (bool) ($result['ok'] ?? false) ? 'done' : ((bool) ($result['retryable'] ?? false) ? 'failed' : 'dead'), @@ -243,4 +261,186 @@ class AbdmSyncWorkerService $digits = preg_replace('/\D/', '', $abha); return is_string($digits) ? $digits : ''; } + + /** + * @param array $payload + * @return int[]|null + */ + private function resolveRetryScheduleSeconds(array $payload): ?array + { + $raw = $payload['retry_schedule_seconds'] ?? null; + if (! is_array($raw)) { + return null; + } + + $schedule = []; + foreach ($raw as $seconds) { + $value = (int) $seconds; + if ($value > 0) { + $schedule[] = $value; + } + } + + return $schedule !== [] ? array_values($schedule) : null; + } + + /** + * @param array $payload + * @return array + */ + private function resolvePatientProfile(int $localPatientId, array $payload): array + { + $row = []; + if ($localPatientId > 0 && $this->db->tableExists('patient_master')) { + $row = $this->db->table('patient_master')->where('id', $localPatientId)->get(1)->getRowArray() ?? []; + } + + $name = trim((string) ($payload['patient_name'] ?? '')); + if ($name === '') { + $name = trim(trim((string) ($row['p_fname'] ?? '')) . ' ' . trim((string) ($row['p_lname'] ?? ''))); + } + if ($name === '') { + $name = 'PATIENT-' . $localPatientId; + } + + $mobile = trim((string) ($payload['mobile'] ?? $payload['patient_mobile'] ?? '')); + if ($mobile === '') { + foreach (['mphone1', 'mobile', 'phone', 'p_mobile'] as $field) { + $candidate = trim((string) ($row[$field] ?? '')); + if ($candidate !== '') { + $mobile = $candidate; + break; + } + } + } + $mobileDigits = preg_replace('/\D/', '', $mobile); + $mobile = is_string($mobileDigits) ? $mobileDigits : ''; + if (strlen($mobile) > 10) { + $mobile = substr($mobile, -10); + } + + $genderRaw = trim((string) ($payload['gender'] ?? $payload['patient_gender'] ?? '')); + if ($genderRaw === '') { + $genderRaw = trim((string) ($row['gender'] ?? $row['xgender'] ?? '')); + } + + $dobRaw = trim((string) ($payload['dob'] ?? $payload['patient_dob'] ?? '')); + if ($dobRaw === '') { + foreach (['dob', 'p_dob', 'birth_date', 'date_of_birth'] as $field) { + $candidate = trim((string) ($row[$field] ?? '')); + if ($candidate !== '') { + $dobRaw = $candidate; + break; + } + } + } + + $abhaAddress = trim((string) ($payload['abha_address'] ?? '')); + if ($abhaAddress === '') { + $abhaAddress = trim((string) ($row['abha_address'] ?? '')); + } + + $abhaDigits = $this->normalizeAbhaDigits((string) ($payload['abha_id'] ?? '')); + if ($abhaDigits === '') { + foreach (['abha_id', 'abha_no', 'abha_number', 'abha'] as $field) { + $candidate = $this->normalizeAbhaDigits((string) ($row[$field] ?? '')); + if ($candidate !== '') { + $abhaDigits = $candidate; + break; + } + } + } + + return [ + 'patient_id' => (string) ($localPatientId > 0 ? $localPatientId : ($payload['local_patient_id'] ?? '')), + 'name' => $name, + 'mobile' => $mobile, + 'gender' => $this->normalizeBridgeGenderCode($genderRaw), + 'year_of_birth' => $this->extractBirthYear($dobRaw), + 'abha_address' => $abhaAddress, + 'abha_number' => $abhaDigits, + ]; + } + + private function normalizeBridgeGenderCode(string $gender): string + { + $value = strtoupper(trim($gender)); + if ($value === 'M' || $value === 'MALE' || $value === '1') { + return 'M'; + } + if ($value === 'F' || $value === 'FEMALE' || $value === '2') { + return 'F'; + } + if ($value === 'O' || $value === 'OTHER' || $value === 'OTHERS' || $value === '3') { + return 'O'; + } + + return 'O'; + } + + private function extractBirthYear(string $dob): int + { + $dob = trim($dob); + if ($dob !== '') { + $ts = strtotime($dob); + if ($ts !== false) { + return (int) date('Y', $ts); + } + } + + return (int) date('Y'); + } + + private function mapHiTypeToRecordType(string $hiType): string + { + $type = trim($hiType); + return match ($type) { + 'PrescriptionRecord' => 'PrescriptionRecord', + 'DiagnosticReportRecord' => 'DiagnosticReportRecord', + 'DischargeSummaryRecord' => 'DischargeSummaryRecord', + 'OPConsultRecord' => 'OPConsultationRecord', + 'OPConsultationRecord' => 'OPConsultationRecord', + 'WellnessRecord' => 'WellnessRecord', + 'HealthDocumentRecord' => 'HealthDocumentRecord', + default => 'HealthDocumentRecord', + }; + } + + private function normalizeVisitDateTime(string $visitDate): string + { + $visitDate = trim($visitDate); + if ($visitDate === '') { + return date('Y-m-d H:i:s'); + } + + $ts = strtotime($visitDate); + if ($ts === false) { + return date('Y-m-d H:i:s'); + } + + return date('Y-m-d H:i:s', $ts); + } + + private function resolveHospitalIdFromSettings(): int + { + if (! $this->db->tableExists('hospital_setting')) { + return 0; + } + + foreach (['ABDM_BRIDGE_HOSPITAL_ID', 'EATRIA_BRIDGE_HOSPITAL_ID', 'HOSPITAL_ID'] as $settingName) { + $row = $this->db->table('hospital_setting') + ->select('s_value') + ->where('s_name', $settingName) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $value = (int) trim((string) ($row['s_value'] ?? '0')); + if ($value > 0) { + return $value; + } + } + + return 0; + } } diff --git a/deploy.sh b/deploy.sh index 25b3108..f47d0f9 100644 --- a/deploy.sh +++ b/deploy.sh @@ -22,7 +22,7 @@ echo "=== Pushing to GitHub ===" git push origin main echo "=== Deploying to server ===" -REMOTE_CMDS="cd $PROJECT && git config core.fileMode false && git pull origin main && php spark migrate --namespace App && echo DEPLOY_OK" +REMOTE_CMDS="cd $PROJECT && git config core.fileMode false && git pull origin main && php spark migrate --namespace App && bash scripts/install_hms_cron.sh $PROJECT && echo DEPLOY_OK" if command -v plink &>/dev/null; then plink -ssh -batch -pw "$HMS_SSH_PASS" $SERVER "$REMOTE_CMDS" diff --git a/scripts/install_hms_cron.sh b/scripts/install_hms_cron.sh new file mode 100644 index 0000000..d326d7c --- /dev/null +++ b/scripts/install_hms_cron.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_DIR="${1:-/var/www/html/hms_etria}" +PHP_BIN="${PHP_BIN:-php}" +LOG_DIR="${PROJECT_DIR}/writable/logs/cron" + +BLOCK_START="# BEGIN HMS_CI4_AUTOGENERATED_CRON" +BLOCK_END="# END HMS_CI4_AUTOGENERATED_CRON" + +if [ ! -d "$PROJECT_DIR" ]; then + echo "ERROR: Project directory not found: $PROJECT_DIR" + exit 1 +fi + +mkdir -p "$LOG_DIR" + +TMP_CRON="$(mktemp)" +trap 'rm -f "$TMP_CRON"' EXIT + +# Keep existing non-HMS cron entries, replace only this managed block. +crontab -l 2>/dev/null | awk -v start="$BLOCK_START" -v end="$BLOCK_END" ' + $0 == start {in_block=1; next} + $0 == end {in_block=0; next} + !in_block {print} +' > "$TMP_CRON" + +cat >> "$TMP_CRON" <> $LOG_DIR/abdm_push_sync.log 2>&1 +* * * * * cd $PROJECT_DIR && $PHP_BIN spark bridge:sync --limit 10 >> $LOG_DIR/bridge_sync.log 2>&1 +* * * * * cd $PROJECT_DIR && $PHP_BIN spark snomed:process-coding >> $LOG_DIR/snomed_process.log 2>&1 +*/2 * * * * cd $PROJECT_DIR && $PHP_BIN spark abdm:hiu-poll --limit=30 >> $LOG_DIR/abdm_hiu_poll.log 2>&1 +*/5 * * * * cd $PROJECT_DIR && $PHP_BIN spark abdm:hiu-retry --limit=20 >> $LOG_DIR/abdm_hiu_retry.log 2>&1 +$BLOCK_END +EOF + +crontab "$TMP_CRON" + +echo "Installed HMS cron jobs for project: $PROJECT_DIR" +echo "Managed block markers: $BLOCK_START ... $BLOCK_END" +crontab -l | sed -n '/^# BEGIN HMS_CI4_AUTOGENERATED_CRON$/,/^# END HMS_CI4_AUTOGENERATED_CRON$/p'