feat(pharmacy): implement abdm sync-pack command and local-first hybrid drug search
This commit is contained in:
parent
0d9e0b1ccf
commit
1880ec60ae
195
app/Commands/AbdmSyncDrugs.php
Normal file
195
app/Commands/AbdmSyncDrugs.php
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class AbdmSyncDrugs extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'ABDM';
|
||||||
|
protected $name = 'abdm:sync-drugs';
|
||||||
|
protected $description = 'Sync standard essential medicines from ABDM Bridge sync-pack to local med_product_master';
|
||||||
|
protected $usage = 'abdm:sync-drugs [--pages=6] [--limit=500] [--dry-run]';
|
||||||
|
protected $options = [
|
||||||
|
'--pages' => 'Number of pages to sync (default: 6)',
|
||||||
|
'--limit' => 'Items per page (default: 500, max: 1000)',
|
||||||
|
'--dry-run' => 'Simulate sync without writing to database',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$pages = (int) ($params['pages'] ?? CLI::getOption('pages') ?? 6);
|
||||||
|
$limit = (int) ($params['limit'] ?? CLI::getOption('limit') ?? 500);
|
||||||
|
$dryRun = (bool) (CLI::getOption('dry-run') ?? false);
|
||||||
|
|
||||||
|
if ($pages <= 0) {
|
||||||
|
$pages = 6;
|
||||||
|
}
|
||||||
|
if ($limit <= 0 || $limit > 1000) {
|
||||||
|
$limit = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write("==================================================", 'cyan');
|
||||||
|
CLI::write(" ABDM Pharmacy Drug Master Sync (Sync-Pack)", 'yellow');
|
||||||
|
CLI::write(" Pages: {$pages} | Limit per page: {$limit} | Mode: " . ($dryRun ? 'DRY-RUN' : 'LIVE'), 'cyan');
|
||||||
|
CLI::write("==================================================\n", 'cyan');
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
if (! $db->tableExists('med_product_master')) {
|
||||||
|
CLI::error("Table 'med_product_master' does not exist in database.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connector = new \App\Libraries\Abdm\EAtriaBridgeConnector();
|
||||||
|
|
||||||
|
$totalFetched = 0;
|
||||||
|
$totalInserted = 0;
|
||||||
|
$totalUpdated = 0;
|
||||||
|
$totalSkipped = 0;
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
for ($p = 1; $p <= $pages; $p++) {
|
||||||
|
CLI::print("Fetching page {$p} of {$pages}... ", 'yellow');
|
||||||
|
try {
|
||||||
|
$response = $connector->drugsSyncPack($p, $limit);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
CLI::write("FAILED: " . $e->getMessage(), 'red');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response['ok'] ?? 0) !== 1) {
|
||||||
|
CLI::write("FAILED: " . ($response['error_text'] ?? $response['message'] ?? 'API error'), 'red');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $response['items'] ?? [];
|
||||||
|
$count = count($items);
|
||||||
|
CLI::write("OK ({$count} items)", 'green');
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
CLI::write("No more items returned. Finishing sync.", 'yellow');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalFetched += $count;
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$identifier = trim((string) ($item['identifier'] ?? ''));
|
||||||
|
$displayName = trim((string) ($item['display_name'] ?? ''));
|
||||||
|
$brandName = trim((string) ($item['brand_name'] ?? ''));
|
||||||
|
$genericName = trim((string) ($item['generic_name'] ?? ''));
|
||||||
|
$dosageForm = trim((string) ($item['short_formulation'] ?? $item['formulation'] ?? $item['dosage_form'] ?? ''));
|
||||||
|
$manufacturer = trim((string) ($item['manufacturer_name'] ?? $item['company_name'] ?? ''));
|
||||||
|
$type = trim((string) ($item['type'] ?? 'generic'));
|
||||||
|
|
||||||
|
if ($identifier === '' && $displayName === '') {
|
||||||
|
$totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean Product Name
|
||||||
|
$itemName = $this->deriveCleanItemName($displayName, $brandName, $dosageForm);
|
||||||
|
if ($itemName === '') {
|
||||||
|
$itemName = $displayName ?: $genericName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists by abdm_drug_identifier
|
||||||
|
$existing = null;
|
||||||
|
if ($identifier !== '') {
|
||||||
|
$existing = $db->table('med_product_master')
|
||||||
|
->where('abdm_drug_identifier', $identifier)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found by identifier, check by exact item_name
|
||||||
|
if (! $existing && $itemName !== '') {
|
||||||
|
$existing = $db->table('med_product_master')
|
||||||
|
->where('item_name', $itemName)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordData = [
|
||||||
|
'item_name' => $itemName,
|
||||||
|
'genericname' => $genericName ?: $displayName,
|
||||||
|
'formulation' => $dosageForm ?: 'Tablet',
|
||||||
|
'company_name' => $manufacturer,
|
||||||
|
'abdm_drug_type' => $type ?: 'generic',
|
||||||
|
'abdm_drug_identifier' => $identifier,
|
||||||
|
'abdm_drug_display' => $displayName,
|
||||||
|
'abdm_drug_generic' => $genericName,
|
||||||
|
'abdm_drug_payload_json' => json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
'abdm_drug_last_synced_at' => $now,
|
||||||
|
'is_continue' => 1,
|
||||||
|
'batch_applicable' => 1,
|
||||||
|
'exp_date_applicable' => 1,
|
||||||
|
'high_risk' => ! empty($item['high_risk']) ? 1 : 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! empty($item['hsn_code'])) {
|
||||||
|
$recordData['HSNCODE'] = (string) $item['hsn_code'];
|
||||||
|
}
|
||||||
|
if (isset($item['cgst_percent']) && is_numeric($item['cgst_percent'])) {
|
||||||
|
$recordData['CGST_per'] = (float) $item['cgst_percent'];
|
||||||
|
}
|
||||||
|
if (isset($item['sgst_percent']) && is_numeric($item['sgst_percent'])) {
|
||||||
|
$recordData['SGST_per'] = (float) $item['sgst_percent'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$db->table('med_product_master')->where('id', $existing['id'])->update($recordData);
|
||||||
|
$totalUpdated++;
|
||||||
|
} else {
|
||||||
|
$recordData['insert_by'] = 'abdm_sync';
|
||||||
|
$db->table('med_product_master')->insert($recordData);
|
||||||
|
$totalInserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pagination = $response['pagination'] ?? [];
|
||||||
|
if (! empty($pagination) && empty($pagination['has_next'])) {
|
||||||
|
CLI::write("Reached last page.", 'yellow');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write("\n--------------------------------------------------", 'cyan');
|
||||||
|
CLI::write(" Sync Finished Successfully!", 'green');
|
||||||
|
CLI::write(" Total Fetched: {$totalFetched}", 'cyan');
|
||||||
|
CLI::write(" Total Inserted: {$totalInserted}", 'green');
|
||||||
|
CLI::write(" Total Updated: {$totalUpdated}", 'yellow');
|
||||||
|
CLI::write(" Total Skipped: {$totalSkipped}", 'white');
|
||||||
|
CLI::write("--------------------------------------------------\n", 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deriveCleanItemName(string $displayName, string $brandName, string $formulation): string
|
||||||
|
{
|
||||||
|
$label = trim($displayName);
|
||||||
|
if ($label === '') {
|
||||||
|
return $brandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^([^()]+?)\s*\(([^)]+)\)\s*(.*)$/i', $label, $m)) {
|
||||||
|
$before = trim($m[1]);
|
||||||
|
$after = trim($m[3]);
|
||||||
|
$cleanAfter = preg_replace('/\b(oral|route|for\s+oral\s+use)\b/i', '', $after);
|
||||||
|
$cleanAfter = preg_replace('/\s+/', ' ', trim($cleanAfter));
|
||||||
|
if ($formulation !== '' && $cleanAfter !== '') {
|
||||||
|
$re = '/' . preg_quote($formulation, '/') . '/i';
|
||||||
|
$cleanAfter = preg_replace($re, ucfirst($formulation), $cleanAfter);
|
||||||
|
}
|
||||||
|
if ($cleanAfter !== '') {
|
||||||
|
return trim($before . ' ' . $cleanAfter);
|
||||||
|
}
|
||||||
|
return $before;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6252,25 +6252,80 @@ class Medical extends BaseController
|
||||||
$limit = 10;
|
$limit = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = 'abdm_drug_ac_v2_' . md5($type . '_' . strtolower($q) . '_' . $limit);
|
// -------------------------------------------------------------
|
||||||
$cached = cache($cacheKey);
|
// Step B: Hybrid Model - 1. Local-First Search (HMS Database)
|
||||||
if (is_array($cached) && ! empty($cached) && ($cached['ok'] ?? 0) === 1) {
|
// -------------------------------------------------------------
|
||||||
return $this->response->setJSON($cached);
|
$localSuggestions = [];
|
||||||
|
$seenIdentifiers = [];
|
||||||
|
|
||||||
|
if ($this->db->tableExists('med_product_master')) {
|
||||||
|
$localBuilder = $this->db->table('med_product_master');
|
||||||
|
$localBuilder->groupStart();
|
||||||
|
$localBuilder->like('item_name', $q, 'after');
|
||||||
|
$localBuilder->orLike('item_name', $q);
|
||||||
|
$localBuilder->orLike('genericname', $q);
|
||||||
|
$localBuilder->orLike('abdm_drug_display', $q);
|
||||||
|
$localBuilder->groupEnd();
|
||||||
|
$localBuilder->where('is_continue', 1);
|
||||||
|
$localBuilder->limit($limit);
|
||||||
|
|
||||||
|
$localRows = $localBuilder->get()->getResultArray();
|
||||||
|
foreach ($localRows as $lr) {
|
||||||
|
$id = trim((string) ($lr['abdm_drug_identifier'] ?? ''));
|
||||||
|
$name = trim((string) ($lr['item_name'] ?? ''));
|
||||||
|
$disp = trim((string) ($lr['abdm_drug_display'] ?? '')) ?: $name;
|
||||||
|
$gen = trim((string) ($lr['genericname'] ?? ''));
|
||||||
|
$form = trim((string) ($lr['formulation'] ?? ''));
|
||||||
|
$comp = trim((string) ($lr['company_name'] ?? ''));
|
||||||
|
|
||||||
|
if ($id !== '') {
|
||||||
|
$seenIdentifiers[$id] = true;
|
||||||
|
}
|
||||||
|
if ($name !== '') {
|
||||||
|
$seenIdentifiers[strtolower($name)] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$localSuggestions[] = [
|
||||||
$connector = new \App\Libraries\Abdm\EAtriaBridgeConnector();
|
'label' => $disp,
|
||||||
$result = $connector->drugsAutocomplete($q, $type, $limit);
|
'display_name' => $disp,
|
||||||
} catch (\Throwable $e) {
|
'brand_name' => $name,
|
||||||
|
'generic_name' => $gen,
|
||||||
|
'identifier' => $id ?: ('HMS-' . $lr['id']),
|
||||||
|
'type' => (string) ($lr['abdm_drug_type'] ?? 'brand'),
|
||||||
|
'dosage_form' => $form,
|
||||||
|
'route' => '',
|
||||||
|
'manufacturer_name' => $comp,
|
||||||
|
'strength_text' => '',
|
||||||
|
'pack_size_text' => (string) ($lr['packing'] ?? ''),
|
||||||
|
'source' => 'local',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If local database has full quota of results, return immediately (1-2ms!)
|
||||||
|
if (count($localSuggestions) >= $limit) {
|
||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
'ok' => 0,
|
'ok' => 1,
|
||||||
'suggestions' => [],
|
'suggestions' => $localSuggestions,
|
||||||
'error_text' => $e->getMessage(),
|
'source' => 'local',
|
||||||
|
'error_text' => '',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// Step B: Hybrid Model - 2. Gateway Fallback / Complement
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
$cacheKey = 'abdm_drug_ac_v2_' . md5($type . '_' . strtolower($q) . '_' . $limit);
|
||||||
|
$cached = cache($cacheKey);
|
||||||
|
$gatewaySuggestions = [];
|
||||||
|
|
||||||
|
if (is_array($cached) && ! empty($cached) && ($cached['ok'] ?? 0) === 1) {
|
||||||
|
$gatewaySuggestions = $cached['suggestions'] ?? [];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$connector = new \App\Libraries\Abdm\EAtriaBridgeConnector();
|
||||||
|
$result = $connector->drugsAutocomplete($q, $type, $limit);
|
||||||
$items = $this->extractDrugResultList($result);
|
$items = $this->extractDrugResultList($result);
|
||||||
$suggestions = [];
|
|
||||||
foreach ($items as $row) {
|
foreach ($items as $row) {
|
||||||
if (! is_array($row)) {
|
if (! is_array($row)) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -6310,7 +6365,7 @@ class Medical extends BaseController
|
||||||
$strength = $this->extractDrugText($row, ['strength_text', 'strength']);
|
$strength = $this->extractDrugText($row, ['strength_text', 'strength']);
|
||||||
$packSize = $this->extractDrugText($row, ['pack_size_text', 'pack_size', 'package', 'packing']);
|
$packSize = $this->extractDrugText($row, ['pack_size_text', 'pack_size', 'package', 'packing']);
|
||||||
|
|
||||||
$suggestions[] = [
|
$gatewaySuggestions[] = [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'display_name' => $displayName,
|
'display_name' => $displayName,
|
||||||
'brand_name' => $brandName,
|
'brand_name' => $brandName,
|
||||||
|
|
@ -6322,20 +6377,55 @@ class Medical extends BaseController
|
||||||
'manufacturer_name' => $manufacturer,
|
'manufacturer_name' => $manufacturer,
|
||||||
'strength_text' => $strength,
|
'strength_text' => $strength,
|
||||||
'pack_size_text' => $packSize,
|
'pack_size_text' => $packSize,
|
||||||
|
'source' => 'gateway',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$responsePayload = [
|
if (! empty($gatewaySuggestions)) {
|
||||||
'ok' => (int) ($result['ok'] ?? 0),
|
cache()->save($cacheKey, [
|
||||||
'suggestions' => $suggestions,
|
'ok' => 1,
|
||||||
'error_text' => (string) ($result['error_text'] ?? $result['message'] ?? ''),
|
'suggestions' => $gatewaySuggestions,
|
||||||
];
|
], 86400);
|
||||||
|
}
|
||||||
if (($responsePayload['ok'] ?? 0) === 1 && ! empty($suggestions)) {
|
} catch (\Throwable $e) {
|
||||||
cache()->save($cacheKey, $responsePayload, 86400);
|
if (! empty($localSuggestions)) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => 1,
|
||||||
|
'suggestions' => $localSuggestions,
|
||||||
|
'source' => 'local',
|
||||||
|
'error_text' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => 0,
|
||||||
|
'suggestions' => [],
|
||||||
|
'error_text' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->response->setJSON($responsePayload);
|
// Merge local and gateway suggestions, deduplicating by identifier / name
|
||||||
|
$merged = $localSuggestions;
|
||||||
|
foreach ($gatewaySuggestions as $gs) {
|
||||||
|
$gid = $gs['identifier'] ?? '';
|
||||||
|
$gname = strtolower($gs['brand_name'] ?? '');
|
||||||
|
if ($gid !== '' && isset($seenIdentifiers[$gid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($gname !== '' && isset($seenIdentifiers[$gname])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$merged[] = $gs;
|
||||||
|
if (count($merged) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => 1,
|
||||||
|
'suggestions' => $merged,
|
||||||
|
'error_text' => '',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function drug_terminology_detail()
|
public function drug_terminology_detail()
|
||||||
|
|
@ -6451,6 +6541,38 @@ class Medical extends BaseController
|
||||||
|
|
||||||
if (($detailPayload['ok'] ?? 0) === 1 && ! empty($detailPayload['selected'])) {
|
if (($detailPayload['ok'] ?? 0) === 1 && ! empty($detailPayload['selected'])) {
|
||||||
cache()->save($cacheKey, $detailPayload, 86400);
|
cache()->save($cacheKey, $detailPayload, 86400);
|
||||||
|
|
||||||
|
// Step B: Auto-save / link in med_product_master on selection
|
||||||
|
if ($canonicalId !== '' && $this->db->tableExists('med_product_master')) {
|
||||||
|
try {
|
||||||
|
$existingProd = $this->db->table('med_product_master')
|
||||||
|
->where('abdm_drug_identifier', $canonicalId)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (! $existingProd) {
|
||||||
|
$cleanName = $displayName ?: ($brandName ?: $label);
|
||||||
|
$this->db->table('med_product_master')->insert([
|
||||||
|
'item_name' => $cleanName,
|
||||||
|
'genericname' => $generic ?: $displayName,
|
||||||
|
'formulation' => $dosageForm ?: ($formulation ?: 'Tablet'),
|
||||||
|
'company_name' => $manufacturerName,
|
||||||
|
'abdm_drug_type' => $itemType ?: 'brand',
|
||||||
|
'abdm_drug_identifier' => $canonicalId,
|
||||||
|
'abdm_drug_display' => $displayName ?: $label,
|
||||||
|
'abdm_drug_generic' => $generic,
|
||||||
|
'abdm_drug_payload_json' => $payloadJson,
|
||||||
|
'abdm_drug_last_synced_at' => date('Y-m-d H:i:s'),
|
||||||
|
'is_continue' => 1,
|
||||||
|
'batch_applicable' => 1,
|
||||||
|
'exp_date_applicable' => 1,
|
||||||
|
'insert_by' => 'abdm_selection',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Non-blocking auto-save
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->response->setJSON($detailPayload);
|
return $this->response->setJSON($detailPayload);
|
||||||
|
|
|
||||||
|
|
@ -2019,6 +2019,19 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
|
||||||
return $this->get('/v3/drugs/version', $query);
|
return $this->get('/v3/drugs/version', $query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function drugsSyncPack(int $page = 1, int $limit = 500): array
|
||||||
|
{
|
||||||
|
$query = [
|
||||||
|
'page' => max(1, $page),
|
||||||
|
'limit' => max(1, min(1000, $limit)),
|
||||||
|
];
|
||||||
|
if ($this->hfrId !== '') {
|
||||||
|
$query['hfr_id'] = $this->hfrId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->get('/v3/drugs/sync-pack', $query);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Pathology Terminology (LOINC)
|
// Pathology Terminology (LOINC)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user