devsofttech_hms_ci4/app/Commands/ImportSnomedSnapshot.php
DevSoftTech Server 0628f8f813 feat: ABDM OPD Queue + ABHA Register flow + patient ABHA search fix
ABDM OPD Queue (today):
- New EAtriaBridgeConnector: opdQueueFetch/opdTokenCreate/opdTokenUpdateStatus + patch() helper
- New AbdmOpdQueue controller: index/list/fetchQueue/createToken/updateTokenStatus/processScannedToken
  - fetchQueue syncs gateway tokens into abdm_opd_tokens (local DB) and enriches with HMS patient data
  - processScannedToken auto-finds/creates patient_master record from ABHA scan data, marks token CALLED
- New migration: abdm_opd_tokens table (gateway_token_id, source, status, patient_id, opd_id, processed_at)
- New view: abdm/opd_queue.php - reception screen with auto-refresh 30s, HMS Patient column (green row when OPD registered)
- Sidebar: ABDM OPD Queue nav link added
- Routes: AbdmOpdQueue with abdmPermFilter

ABHA Register tab (earlier this session):
- New Abha controller: initiate/verifyOtp/communication/verifyCommOtp/validateAbha/card + autoCreateOrFindPatient
- New views: abha/card.php, partials/abha_create_panel.php, partials/abha_otp_modal.php
- Patient_V.php: ABHA Register tab with 4-method wizard (ABHA number, QR scan, facility QR, mobile OTP)

Patient ABHA search fix:
- Patient::search() now detects ABHA column (abha_id/abha_no/abha/abha_address) and searches by it

ABDM Bridge infrastructure:
- EAtriaBridgeConnector: new connector for e-atria.in ABDM bridge gateway
- AbdmConnectorFactory/Interface updated
- AbdmGatewaySettings controller + view
- SNOMED/CSNOTK terminology service skeleton
- Finance payout OPD consult views
- Migration: AddAbdmPushFieldsToOpdAndPatient, CreateSnomedCoreTables
- Docs: abdm/ API specs, architecture, gateway guides
2026-05-16 04:21:31 +05:30

379 lines
17 KiB
PHP

<?php
namespace App\Commands;
use Config\Database;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class ImportSnomedSnapshot extends BaseCommand
{
protected $group = 'Import';
protected $name = 'snomed:import-snapshot';
protected $description = 'Import SNOMED CT RF2 Snapshot files (Concept, Description, Language, Map) into HMS SNOMED tables.';
protected $usage = 'snomed:import-snapshot [--package path] [--release YYYYMMDD] [--rf2-md5 hash] [--notes-md5 hash] [--batch 1000] [--keep-existing] [--skip-map]';
protected $options = [
'--package' => 'Path to SNOMED package root folder. Defaults to ROOTPATH/SnomedCT_InternationalRF2_PRODUCTION_YYYYMMDDT120000Z when resolvable.',
'--release' => 'Release effectiveTime (YYYYMMDD). If omitted, taken from release_package_information.json.',
'--rf2-md5' => 'RF2 package MD5 for audit logging.',
'--notes-md5' => 'Release notes MD5 for audit logging.',
'--batch' => 'Batch size for insertBatch (default: 1000).',
'--keep-existing' => 'Do not truncate existing SNOMED tables before import.',
'--skip-map' => 'Skip importing simple/extended map files.',
];
public function run(array $params)
{
$db = Database::connect();
$this->ensureRequiredTables($db);
$batchSize = max(100, (int) (CLI::getOption('batch') ?: 1000));
$packagePath = $this->resolvePackagePath((string) (CLI::getOption('package') ?? ''));
if ($packagePath === '' || ! is_dir($packagePath)) {
CLI::error('SNOMED package folder not found. Pass --package with full folder path.');
return;
}
$meta = $this->readReleaseMetadata($packagePath);
$release = trim((string) (CLI::getOption('release') ?: ($meta['effectiveTime'] ?? '')));
if (! preg_match('/^[0-9]{8}$/', $release)) {
CLI::error('Release effectiveTime is missing/invalid. Use --release YYYYMMDD.');
return;
}
$rf2Md5 = trim((string) (CLI::getOption('rf2-md5') ?: ''));
$notesMd5 = trim((string) (CLI::getOption('notes-md5') ?: ''));
$keepExisting = (bool) CLI::getOption('keep-existing');
$skipMap = (bool) CLI::getOption('skip-map');
$packageName = basename($packagePath);
$now = date('Y-m-d H:i:s');
$logId = $this->startReleaseLog($db, $packageName, $release, $rf2Md5, $notesMd5, $now);
$counts = [
'concept' => 0,
'description' => 0,
'language_refset' => 0,
'simple_map' => 0,
'extended_map' => 0,
];
try {
if (! $keepExisting) {
$this->truncateTargetTables($db, ! $skipMap);
}
$counts['concept'] = $this->importConceptSnapshot($db, $packagePath, $release, $batchSize, $now);
$counts['description'] = $this->importDescriptionSnapshot($db, $packagePath, $release, $batchSize, $now);
$counts['language_refset'] = $this->importLanguageSnapshot($db, $packagePath, $release, $batchSize, $now);
if (! $skipMap) {
$counts['simple_map'] = $this->importSimpleMapSnapshot($db, $packagePath, $release, $batchSize, $now);
$counts['extended_map'] = $this->importExtendedMapSnapshot($db, $packagePath, $release, $batchSize, $now);
}
$this->finishReleaseLog($db, $logId, 'completed', $counts, '', date('Y-m-d H:i:s'));
CLI::write('SNOMED snapshot import completed', 'green');
CLI::write('Package: ' . $packagePath);
CLI::write('Release: ' . $release);
CLI::write('Concept rows: ' . $counts['concept'], 'green');
CLI::write('Description rows: ' . $counts['description'], 'green');
CLI::write('Language rows: ' . $counts['language_refset'], 'green');
CLI::write('Simple map rows: ' . $counts['simple_map'], 'yellow');
CLI::write('Extended map rows: ' . $counts['extended_map'], 'yellow');
} catch (\Throwable $e) {
$this->finishReleaseLog($db, $logId, 'failed', $counts, $e->getMessage(), date('Y-m-d H:i:s'));
CLI::error('Import failed: ' . $e->getMessage());
}
}
private function ensureRequiredTables($db): void
{
$required = [
'snomed_release_log',
'snomed_concept',
'snomed_description',
'snomed_language_refset',
'snomed_map_simple',
'snomed_map_extended',
];
foreach ($required as $table) {
if (! $db->tableExists($table)) {
throw new \RuntimeException('Missing table ' . $table . '. Run php spark migrate first.');
}
}
}
private function resolvePackagePath(string $requestedPath): string
{
$path = trim($requestedPath);
if ($path !== '') {
return rtrim($path, "\\/");
}
$candidates = glob(ROOTPATH . 'SnomedCT_InternationalRF2_PRODUCTION_*', GLOB_ONLYDIR) ?: [];
if (empty($candidates)) {
return '';
}
rsort($candidates);
return rtrim((string) $candidates[0], "\\/");
}
private function readReleaseMetadata(string $packagePath): array
{
$jsonPath = $packagePath . DIRECTORY_SEPARATOR . 'release_package_information.json';
if (! is_file($jsonPath)) {
return [];
}
$raw = file_get_contents($jsonPath);
if (! is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
private function startReleaseLog($db, string $packageName, string $release, string $rf2Md5, string $notesMd5, string $timestamp): int
{
$db->table('snomed_release_log')->insert([
'package_name' => $packageName,
'release_effective_time' => $release,
'rf2_md5' => $rf2Md5 !== '' ? $rf2Md5 : null,
'release_notes_md5' => $notesMd5 !== '' ? $notesMd5 : null,
'status' => 'started',
'row_counts_json' => null,
'error_text' => null,
'imported_at' => null,
'created_at' => $timestamp,
'updated_at' => $timestamp,
]);
return (int) $db->insertID();
}
private function finishReleaseLog($db, int $id, string $status, array $counts, string $errorText, string $timestamp): void
{
$db->table('snomed_release_log')
->where('id', $id)
->update([
'status' => $status,
'row_counts_json' => json_encode($counts, JSON_UNESCAPED_SLASHES),
'error_text' => $errorText !== '' ? substr($errorText, 0, 1000) : null,
'imported_at' => $status === 'completed' ? $timestamp : null,
'updated_at' => $timestamp,
]);
}
private function truncateTargetTables($db, bool $includeMaps): void
{
$tables = ['snomed_concept', 'snomed_description', 'snomed_language_refset'];
if ($includeMaps) {
$tables[] = 'snomed_map_simple';
$tables[] = 'snomed_map_extended';
}
foreach ($tables as $table) {
$db->table($table)->truncate();
CLI::write('Truncated ' . $table, 'yellow');
}
}
private function importConceptSnapshot($db, string $packagePath, string $release, int $batchSize, string $timestamp): int
{
$path = $packagePath . DIRECTORY_SEPARATOR . 'Snapshot' . DIRECTORY_SEPARATOR . 'Terminology' . DIRECTORY_SEPARATOR . 'sct2_Concept_Snapshot_INT_' . $release . '.txt';
return $this->importTsv($path, $batchSize, function (array $row) use ($timestamp) {
return [
'concept_id' => trim((string) ($row['id'] ?? '')),
'effective_time' => trim((string) ($row['effectiveTime'] ?? '')),
'active' => (int) ($row['active'] ?? 0),
'module_id' => trim((string) ($row['moduleId'] ?? '')),
'definition_status_id' => trim((string) ($row['definitionStatusId'] ?? '')),
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
}, function (array $mapped): bool {
return $mapped['concept_id'] !== '';
}, function (array $batch) use ($db) {
$db->table('snomed_concept')->ignore(true)->insertBatch($batch);
}, 'Concept');
}
private function importDescriptionSnapshot($db, string $packagePath, string $release, int $batchSize, string $timestamp): int
{
$path = $packagePath . DIRECTORY_SEPARATOR . 'Snapshot' . DIRECTORY_SEPARATOR . 'Terminology' . DIRECTORY_SEPARATOR . 'sct2_Description_Snapshot-en_INT_' . $release . '.txt';
return $this->importTsv($path, $batchSize, function (array $row) use ($timestamp) {
$term = trim((string) ($row['term'] ?? ''));
$termNormalized = mb_strtolower(trim(preg_replace('/\s+/', ' ', $term) ?? $term));
return [
'description_id' => trim((string) ($row['id'] ?? '')),
'effective_time' => trim((string) ($row['effectiveTime'] ?? '')),
'active' => (int) ($row['active'] ?? 0),
'module_id' => trim((string) ($row['moduleId'] ?? '')),
'concept_id' => trim((string) ($row['conceptId'] ?? '')),
'language_code' => trim((string) ($row['languageCode'] ?? '')),
'type_id' => trim((string) ($row['typeId'] ?? '')),
'term' => mb_substr($term, 0, 512),
'term_normalized' => mb_substr($termNormalized, 0, 255),
'case_significance_id' => trim((string) ($row['caseSignificanceId'] ?? '')),
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
}, function (array $mapped): bool {
return $mapped['description_id'] !== '' && $mapped['term'] !== '';
}, function (array $batch) use ($db) {
$db->table('snomed_description')->ignore(true)->insertBatch($batch);
}, 'Description');
}
private function importLanguageSnapshot($db, string $packagePath, string $release, int $batchSize, string $timestamp): int
{
$path = $packagePath . DIRECTORY_SEPARATOR . 'Snapshot' . DIRECTORY_SEPARATOR . 'Refset' . DIRECTORY_SEPARATOR . 'Language' . DIRECTORY_SEPARATOR . 'der2_cRefset_LanguageSnapshot-en_INT_' . $release . '.txt';
return $this->importTsv($path, $batchSize, function (array $row) use ($timestamp) {
return [
'member_id' => trim((string) ($row['id'] ?? '')),
'effective_time' => trim((string) ($row['effectiveTime'] ?? '')),
'active' => (int) ($row['active'] ?? 0),
'module_id' => trim((string) ($row['moduleId'] ?? '')),
'refset_id' => trim((string) ($row['refsetId'] ?? '')),
'referenced_component_id' => trim((string) ($row['referencedComponentId'] ?? '')),
'acceptability_id' => trim((string) ($row['acceptabilityId'] ?? '')),
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
}, function (array $mapped): bool {
return $mapped['member_id'] !== '';
}, function (array $batch) use ($db) {
$db->table('snomed_language_refset')->ignore(true)->insertBatch($batch);
}, 'Language refset');
}
private function importSimpleMapSnapshot($db, string $packagePath, string $release, int $batchSize, string $timestamp): int
{
$path = $packagePath . DIRECTORY_SEPARATOR . 'Snapshot' . DIRECTORY_SEPARATOR . 'Refset' . DIRECTORY_SEPARATOR . 'Map' . DIRECTORY_SEPARATOR . 'der2_sRefset_SimpleMapSnapshot_INT_' . $release . '.txt';
return $this->importTsv($path, $batchSize, function (array $row) use ($timestamp) {
return [
'member_id' => trim((string) ($row['id'] ?? '')),
'effective_time' => trim((string) ($row['effectiveTime'] ?? '')),
'active' => (int) ($row['active'] ?? 0),
'module_id' => trim((string) ($row['moduleId'] ?? '')),
'refset_id' => trim((string) ($row['refsetId'] ?? '')),
'referenced_component_id' => trim((string) ($row['referencedComponentId'] ?? '')),
'map_target' => trim((string) ($row['mapTarget'] ?? '')),
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
}, function (array $mapped): bool {
return $mapped['member_id'] !== '';
}, function (array $batch) use ($db) {
$db->table('snomed_map_simple')->ignore(true)->insertBatch($batch);
}, 'Simple map');
}
private function importExtendedMapSnapshot($db, string $packagePath, string $release, int $batchSize, string $timestamp): int
{
$path = $packagePath . DIRECTORY_SEPARATOR . 'Snapshot' . DIRECTORY_SEPARATOR . 'Refset' . DIRECTORY_SEPARATOR . 'Map' . DIRECTORY_SEPARATOR . 'der2_iisssccRefset_ExtendedMapSnapshot_INT_' . $release . '.txt';
return $this->importTsv($path, $batchSize, function (array $row) use ($timestamp) {
return [
'member_id' => trim((string) ($row['id'] ?? '')),
'effective_time' => trim((string) ($row['effectiveTime'] ?? '')),
'active' => (int) ($row['active'] ?? 0),
'module_id' => trim((string) ($row['moduleId'] ?? '')),
'refset_id' => trim((string) ($row['refsetId'] ?? '')),
'referenced_component_id' => trim((string) ($row['referencedComponentId'] ?? '')),
'map_group' => is_numeric($row['mapGroup'] ?? null) ? (int) $row['mapGroup'] : null,
'map_priority' => is_numeric($row['mapPriority'] ?? null) ? (int) $row['mapPriority'] : null,
'map_rule' => mb_substr(trim((string) ($row['mapRule'] ?? '')), 0, 512),
'map_advice' => mb_substr(trim((string) ($row['mapAdvice'] ?? '')), 0, 512),
'map_target' => trim((string) ($row['mapTarget'] ?? '')),
'correlation_id' => trim((string) ($row['correlationId'] ?? '')),
'map_category_id' => trim((string) ($row['mapCategoryId'] ?? '')),
'created_at' => $timestamp,
'updated_at' => $timestamp,
];
}, function (array $mapped): bool {
return $mapped['member_id'] !== '';
}, function (array $batch) use ($db) {
$db->table('snomed_map_extended')->ignore(true)->insertBatch($batch);
}, 'Extended map');
}
private function importTsv(string $path, int $batchSize, callable $mapRow, callable $isValid, callable $flushBatch, string $label): int
{
if (! is_file($path)) {
throw new \RuntimeException($label . ' file not found: ' . $path);
}
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new \RuntimeException('Unable to open ' . $label . ' file: ' . $path);
}
$header = null;
$batch = [];
$imported = 0;
$line = 0;
try {
while (($cols = fgetcsv($handle, 0, "\t")) !== false) {
$line++;
if ($line === 1) {
$header = $cols;
continue;
}
if (! is_array($header)) {
continue;
}
$assoc = $this->combineHeaderRow($header, $cols);
$mapped = $mapRow($assoc);
if (! $isValid($mapped)) {
continue;
}
$batch[] = $mapped;
if (count($batch) >= $batchSize) {
$flushBatch($batch);
$imported += count($batch);
$batch = [];
if ($imported % ($batchSize * 10) === 0) {
CLI::write($label . ' imported: ' . $imported, 'cyan');
}
}
}
if (! empty($batch)) {
$flushBatch($batch);
$imported += count($batch);
}
} finally {
fclose($handle);
}
CLI::write($label . ' imported total: ' . $imported, 'green');
return $imported;
}
private function combineHeaderRow(array $header, array $row): array
{
$output = [];
$max = min(count($header), count($row));
for ($i = 0; $i < $max; $i++) {
$key = trim((string) ($header[$i] ?? ''));
if ($key === '') {
continue;
}
$output[$key] = $row[$i] ?? null;
}
return $output;
}
}