ABDM M2 care-context outbound sync and server cron automation

This commit is contained in:
DevSoftTech Server 2026-07-23 15:12:46 +05:30
parent 0176079e7d
commit 3bda6f5291
9 changed files with 612 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@ -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'] ?? []),

View File

@ -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<string,mixed> $payload
* @return array<string,mixed>
*/
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<string,mixed> $payload
* @return array<string,mixed>
*/
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<string,mixed> $payload
* @param array<string,mixed> $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<string,mixed> $body
* @return array<string,mixed>
*/
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);
}
}

View File

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

View File

@ -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<string,mixed> $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<string,mixed> $payload
* @return array<string,mixed>
*/
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;
}
}

View File

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

View File

@ -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" <<EOF
$BLOCK_START
* * * * * cd $PROJECT_DIR && $PHP_BIN spark abdm:push-sync --limit=20 --worker=cron-abdm-m2 >> $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'