Pharmacy: add ABDM drug terminology autocomplete + canonical mapping

This commit is contained in:
DevSoftTech Server 2026-05-25 13:57:56 +05:30
parent 12cacc3a9c
commit 0558ac5a2e
5 changed files with 580 additions and 1 deletions

View File

@ -85,10 +85,14 @@ $routes->get('Product_master/medicine_category_Sub', 'Medical::medicine_category
$routes->post('Product_master/medicine_category_Update', 'Medical::medicine_category_update');
$routes->get('product_master/drug_master_list', 'Medical::drug_master_list');
$routes->post('product_master/Product_search', 'Medical::product_search');
$routes->get('product_master/drug_terminology_autocomplete', 'Medical::drug_terminology_autocomplete');
$routes->get('product_master/drug_terminology_detail', 'Medical::drug_terminology_detail');
$routes->get('product_master/Product_edit/(:num)', 'Medical::product_edit/$1');
$routes->post('product_master/product_master_update/(:num)', 'Medical::product_master_update/$1');
$routes->get('Product_master/drug_master_list', 'Medical::drug_master_list');
$routes->post('Product_master/Product_search', 'Medical::product_search');
$routes->get('Product_master/drug_terminology_autocomplete', 'Medical::drug_terminology_autocomplete');
$routes->get('Product_master/drug_terminology_detail', 'Medical::drug_terminology_detail');
$routes->get('Product_master/Product_edit/(:num)', 'Medical::product_edit/$1');
$routes->post('Product_master/product_master_update/(:num)', 'Medical::product_master_update/$1');
$routes->get('Medical/Purchase', 'Medical::purchase');

View File

@ -5071,6 +5071,226 @@ class Medical extends BaseController
]);
}
public function drug_terminology_autocomplete()
{
if ($deny = $this->ensurePharmacyAccess()) {
return $deny;
}
$q = trim((string) ($this->request->getGet('q') ?? $this->request->getPost('q') ?? ''));
$q = preg_replace('/[^A-Za-z0-9_ \-]/', '', $q);
if (mb_strlen($q) < 2) {
return $this->response->setJSON([
'ok' => 1,
'suggestions' => [],
'error_text' => '',
]);
}
$type = strtolower(trim((string) ($this->request->getGet('type') ?? $this->request->getPost('type') ?? 'generic')));
$allowedTypes = ['generic', 'brand', 'product', 'substance'];
if (! in_array($type, $allowedTypes, true)) {
$type = 'generic';
}
$limit = (int) ($this->request->getGet('limit') ?? $this->request->getPost('limit') ?? 10);
if ($limit <= 0 || $limit > 50) {
$limit = 10;
}
try {
$connector = new \App\Libraries\Abdm\EAtriaBridgeConnector();
$result = $connector->drugsAutocomplete($q, $type, $limit);
} catch (\Throwable $e) {
return $this->response->setJSON([
'ok' => 0,
'suggestions' => [],
'error_text' => $e->getMessage(),
]);
}
$items = $this->extractDrugResultList($result);
$suggestions = [];
foreach ($items as $row) {
if (! is_array($row)) {
continue;
}
$label = $this->extractDrugText($row, ['label', 'name', 'display', 'term', 'title', 'drug_name']);
$identifier = $this->extractDrugText($row, ['identifier', 'id', 'code', 'concept_id', 'value']);
$itemType = strtolower($this->extractDrugText($row, ['type', 'entity_type', 'category']));
if ($itemType === '') {
$itemType = $type;
}
if ($label === '' && $identifier === '') {
continue;
}
$suggestions[] = [
'label' => $label !== '' ? $label : $identifier,
'identifier' => $identifier,
'type' => $itemType,
];
}
return $this->response->setJSON([
'ok' => (int) ($result['ok'] ?? 0),
'suggestions' => $suggestions,
'error_text' => (string) ($result['error_text'] ?? $result['message'] ?? ''),
]);
}
public function drug_terminology_detail()
{
if ($deny = $this->ensurePharmacyAccess()) {
return $deny;
}
$type = strtolower(trim((string) ($this->request->getGet('type') ?? $this->request->getPost('type') ?? 'generic')));
$identifier = trim((string) ($this->request->getGet('identifier') ?? $this->request->getPost('identifier') ?? ''));
if ($identifier === '') {
return $this->response->setJSON([
'ok' => 0,
'selected' => null,
'error_text' => 'Identifier is required',
]);
}
try {
$connector = new \App\Libraries\Abdm\EAtriaBridgeConnector();
$result = $connector->drugsDetail($type, $identifier);
} catch (\Throwable $e) {
return $this->response->setJSON([
'ok' => 0,
'selected' => null,
'error_text' => $e->getMessage(),
]);
}
$record = $this->extractDrugResultObject($result);
$label = $this->extractDrugText($record, ['label', 'name', 'display', 'term', 'title', 'drug_name']);
$generic = $this->extractDrugText($record, ['generic_name', 'genericname', 'generic', 'salt', 'molecule']);
$hsnCode = $this->extractDrugText($record, ['hsn_code', 'hsn', 'hscode']);
$formulation = $this->extractDrugText($record, ['formulation', 'dosage_form', 'drug_form']);
$packing = $this->extractDrugText($record, ['packing', 'pack_size', 'package']);
$itemType = strtolower($this->extractDrugText($record, ['type', 'entity_type', 'category']));
if ($itemType === '') {
$itemType = $type;
}
$canonicalId = $this->extractDrugText($record, ['identifier', 'id', 'code', 'concept_id', 'value']);
if ($canonicalId === '') {
$canonicalId = $identifier;
}
$payloadJson = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($payloadJson)) {
$payloadJson = '{}';
}
return $this->response->setJSON([
'ok' => (int) ($result['ok'] ?? 0),
'selected' => [
'label' => $label,
'generic_name' => $generic,
'hsn_code' => $hsnCode,
'formulation' => $formulation,
'packing' => $packing,
'type' => $itemType,
'identifier' => $canonicalId,
'payload_json' => $payloadJson,
'synced_at' => date('Y-m-d H:i:s'),
],
'error_text' => (string) ($result['error_text'] ?? $result['message'] ?? ''),
]);
}
/**
* @param array<string, mixed> $result
* @return array<int, array<string, mixed>>
*/
private function extractDrugResultList(array $result): array
{
$candidate = $result['data'] ?? $result['result'] ?? $result;
if (! is_array($candidate)) {
return [];
}
if (array_is_list($candidate)) {
return array_values(array_filter($candidate, static fn ($v) => is_array($v)));
}
foreach (['items', 'suggestions', 'results', 'data', 'records'] as $key) {
if (isset($candidate[$key]) && is_array($candidate[$key]) && array_is_list($candidate[$key])) {
return array_values(array_filter($candidate[$key], static fn ($v) => is_array($v)));
}
}
if ($this->extractDrugText($candidate, ['identifier', 'id', 'code', 'concept_id', 'value']) !== '') {
return [$candidate];
}
return [];
}
/**
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function extractDrugResultObject(array $result): array
{
$candidate = $result['data'] ?? $result['result'] ?? $result;
if (! is_array($candidate)) {
return [];
}
if (array_is_list($candidate)) {
$first = $candidate[0] ?? [];
return is_array($first) ? $first : [];
}
foreach (['item', 'result', 'record', 'data'] as $key) {
if (isset($candidate[$key]) && is_array($candidate[$key])) {
return $candidate[$key];
}
}
return $candidate;
}
/**
* @param array<string, mixed> $data
* @param array<int, string> $keys
*/
private function extractDrugText(array $data, array $keys): string
{
$flat = [];
$walker = static function ($node) use (&$walker, &$flat): void {
if (! is_array($node)) {
return;
}
foreach ($node as $k => $v) {
if (is_array($v)) {
$walker($v);
continue;
}
if (is_scalar($v) || $v === null) {
$flat[strtolower((string) $k)] = trim((string) $v);
}
}
};
$walker($data);
foreach ($keys as $key) {
$val = trim((string) ($flat[strtolower($key)] ?? ''));
if ($val !== '') {
return $val;
}
}
return '';
}
public function product_edit($productId = 0)
{
if ($deny = $this->ensurePharmacyAccess()) {
@ -5149,6 +5369,15 @@ class Medical extends BaseController
$coldStorage = trim((string) ($this->request->getPost('input_cold_storage') ?? ''));
$companyId = (int) ($this->request->getPost('input_company_name') ?? 0);
$relatedDrugId = (int) ($this->request->getPost('related_drug_id') ?? 0);
$abdmDrugType = trim((string) ($this->request->getPost('abdm_drug_type') ?? ''));
$abdmDrugIdentifier = trim((string) ($this->request->getPost('abdm_drug_identifier') ?? ''));
$abdmDrugDisplay = trim((string) ($this->request->getPost('abdm_drug_display') ?? ''));
$abdmDrugGeneric = trim((string) ($this->request->getPost('abdm_drug_generic') ?? ''));
$abdmDrugPayloadJson = trim((string) ($this->request->getPost('abdm_drug_payload_json') ?? ''));
$abdmDrugLastSyncedAt = trim((string) ($this->request->getPost('abdm_drug_last_synced_at') ?? ''));
if (strlen($abdmDrugPayloadJson) > 65000) {
$abdmDrugPayloadJson = substr($abdmDrugPayloadJson, 0, 65000);
}
$errors = [];
if ($itemName === '' || mb_strlen($itemName) < 3) {
@ -5193,6 +5422,12 @@ class Medical extends BaseController
'shelf_no' => $shelfNo,
'cold_storage' => $coldStorage,
'company_id' => $companyId,
'abdm_drug_type' => $abdmDrugType,
'abdm_drug_identifier' => $abdmDrugIdentifier,
'abdm_drug_display' => $abdmDrugDisplay,
'abdm_drug_generic' => $abdmDrugGeneric,
'abdm_drug_payload_json' => $abdmDrugPayloadJson,
'abdm_drug_last_synced_at' => $abdmDrugLastSyncedAt,
'ban_flag_id' => $flag('chk_ban_flag_id'),
'batch_applicable' => $flag('chk_batch_applicable'),
'is_continue' => $flag('chk_is_continue'),

View File

@ -0,0 +1,86 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAbdmDrugTerminologyFieldsToMedProductMaster extends Migration
{
public function up()
{
if (! $this->db->tableExists('med_product_master')) {
return;
}
$fields = $this->db->getFieldNames('med_product_master') ?? [];
$add = [];
if (! in_array('abdm_drug_type', $fields, true)) {
$add['abdm_drug_type'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'default' => null];
}
if (! in_array('abdm_drug_identifier', $fields, true)) {
$add['abdm_drug_identifier'] = ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true, 'default' => null];
}
if (! in_array('abdm_drug_display', $fields, true)) {
$add['abdm_drug_display'] = ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true, 'default' => null];
}
if (! in_array('abdm_drug_generic', $fields, true)) {
$add['abdm_drug_generic'] = ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true, 'default' => null];
}
if (! in_array('abdm_drug_payload_json', $fields, true)) {
$add['abdm_drug_payload_json'] = ['type' => 'MEDIUMTEXT', 'null' => true];
}
if (! in_array('abdm_drug_last_synced_at', $fields, true)) {
$add['abdm_drug_last_synced_at'] = ['type' => 'DATETIME', 'null' => true, 'default' => null];
}
if ($add !== []) {
$this->forge->addColumn('med_product_master', $add);
}
// Optional index for fast traceability lookup.
$indexes = $this->db->query('SHOW INDEX FROM med_product_master')->getResultArray();
$hasIdx = false;
foreach ($indexes as $idx) {
if (strcasecmp((string) ($idx['Key_name'] ?? ''), 'idx_abdm_drug_identifier') === 0) {
$hasIdx = true;
break;
}
}
if (! $hasIdx && in_array('abdm_drug_identifier', $this->db->getFieldNames('med_product_master') ?? [], true)) {
try {
$this->db->query('ALTER TABLE med_product_master ADD INDEX idx_abdm_drug_identifier (abdm_drug_identifier)');
} catch (\Throwable $e) {
// Ignore index creation failures to keep migration non-blocking.
}
}
}
public function down()
{
if (! $this->db->tableExists('med_product_master')) {
return;
}
try {
$this->db->query('ALTER TABLE med_product_master DROP INDEX idx_abdm_drug_identifier');
} catch (\Throwable $e) {
// no-op
}
$fields = $this->db->getFieldNames('med_product_master') ?? [];
foreach ([
'abdm_drug_type',
'abdm_drug_identifier',
'abdm_drug_display',
'abdm_drug_generic',
'abdm_drug_payload_json',
'abdm_drug_last_synced_at',
] as $column) {
if (in_array($column, $fields, true)) {
$this->forge->dropColumn('med_product_master', $column);
}
}
}
}

View File

@ -539,4 +539,47 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
{
return $this->post('/v3/hip/link/sms-notify', $payload);
}
// -------------------------------------------------------------------------
// Drug Terminology (CDCI)
// -------------------------------------------------------------------------
public function drugsAutocomplete(string $q, string $type = 'generic', int $limit = 10): array
{
$query = [
'q' => $q,
'type' => $type,
'limit' => max(1, min(50, $limit)),
];
if ($this->hfrId !== '') {
$query['hfr_id'] = $this->hfrId;
}
return $this->get('/v3/drugs/autocomplete', $query);
}
public function drugsDetail(string $type, string $identifier): array
{
$query = [
'type' => $type,
'identifier' => $identifier,
];
if ($this->hfrId !== '') {
$query['hfr_id'] = $this->hfrId;
}
return $this->get('/v3/drugs/detail', $query);
}
public function drugsVersion(): array
{
$query = [];
if ($this->hfrId !== '') {
$query['hfr_id'] = $this->hfrId;
}
return $this->get('/v3/drugs/version', $query);
}
}

View File

@ -16,6 +16,12 @@ $shelfNo = (string) ($product->shelf_no ?? '');
$coldStorage = (string) ($product->cold_storage ?? '');
$companyId = (int) ($product->company_id ?? 0);
$relatedDrugId = (int) ($product->related_drug_id ?? 0);
$abdmDrugType = (string) ($product->abdm_drug_type ?? '');
$abdmDrugIdentifier = (string) ($product->abdm_drug_identifier ?? '');
$abdmDrugDisplay = (string) ($product->abdm_drug_display ?? '');
$abdmDrugGeneric = (string) ($product->abdm_drug_generic ?? '');
$abdmDrugPayloadJson = (string) ($product->abdm_drug_payload_json ?? '');
$abdmDrugLastSyncedAt = (string) ($product->abdm_drug_last_synced_at ?? '');
$flag = static function ($v): string {
return ((int) $v === 1) ? 'checked' : '';
@ -25,16 +31,47 @@ $flag = static function ($v): string {
<div class="card">
<div class="card-header">Product <?= $productId > 0 ? '<small class="text-muted">#' . $productId . '</small>' : '' ?></div>
<div class="card-body pt-3">
<style>
#abdm-drug-suggest {
max-height: 260px;
overflow-y: auto;
z-index: 1055;
display: none;
width: 100%;
}
#abdm-drug-selected {
font-size: 12px;
}
</style>
<div id="product-msg"></div>
<form id="product-form" class="row g-2" method="post" action="javascript:void(0)">
<?= csrf_field() ?>
<input type="hidden" id="product_id" name="product_id" value="<?= esc((string) $productId) ?>">
<input type="hidden" id="related_drug_id" name="related_drug_id" value="<?= esc((string) $relatedDrugId) ?>">
<input type="hidden" id="abdm_drug_type" name="abdm_drug_type" value="<?= esc($abdmDrugType) ?>">
<input type="hidden" id="abdm_drug_identifier" name="abdm_drug_identifier" value="<?= esc($abdmDrugIdentifier) ?>">
<input type="hidden" id="abdm_drug_display" name="abdm_drug_display" value="<?= esc($abdmDrugDisplay) ?>">
<input type="hidden" id="abdm_drug_generic" name="abdm_drug_generic" value="<?= esc($abdmDrugGeneric) ?>">
<input type="hidden" id="abdm_drug_payload_json" name="abdm_drug_payload_json" value="<?= esc($abdmDrugPayloadJson) ?>">
<input type="hidden" id="abdm_drug_last_synced_at" name="abdm_drug_last_synced_at" value="<?= esc($abdmDrugLastSyncedAt) ?>">
<div class="col-md-6">
<label class="form-label">Product Name</label>
<input class="form-control" name="input_item_name" id="input_item_name" type="text" value="<?= esc($itemName) ?>">
<div class="position-relative">
<input class="form-control" name="input_item_name" id="input_item_name" type="text" value="<?= esc($itemName) ?>" autocomplete="off">
<div id="abdm-drug-suggest" class="list-group position-absolute shadow-sm"></div>
</div>
<div class="d-flex align-items-center gap-2 mt-1">
<small class="text-muted">ABDM Drug Search</small>
<select id="abdm_drug_search_type" class="form-select form-select-sm" style="width:auto;">
<option value="generic" selected>Generic</option>
<option value="brand">Brand</option>
<option value="product">Product</option>
<option value="substance">Substance</option>
</select>
</div>
<div id="abdm-drug-selected" class="text-success mt-1"></div>
</div>
<div class="col-md-3">
<label class="form-label">Formulation</label>
@ -131,6 +168,9 @@ $flag = static function ($v): string {
<script>
(function () {
var suggestTimer = null;
var lastQuery = '';
if (window.jQuery && $.fn.select2) {
$('#med_cat_id').select2({
width: '100%',
@ -142,6 +182,177 @@ $flag = static function ($v): string {
$('#product-msg').html(html || '');
}
function showSelectedDrugSummary() {
var id = $('#abdm_drug_identifier').val() || '';
var label = $('#abdm_drug_display').val() || '';
var type = $('#abdm_drug_type').val() || '';
var generic = $('#abdm_drug_generic').val() || '';
var syncedAt = $('#abdm_drug_last_synced_at').val() || '';
if (!id && !label) {
$('#abdm-drug-selected').html('');
return;
}
var parts = [];
if (label) { parts.push(label); }
if (generic) { parts.push('Generic: ' + generic); }
if (type) { parts.push('Type: ' + type); }
if (id) { parts.push('Identifier: ' + id); }
if (syncedAt) { parts.push('Synced: ' + syncedAt); }
$('#abdm-drug-selected').text(parts.join(' | '));
}
function clearSuggestionBox() {
$('#abdm-drug-suggest').hide().empty();
}
function applyDrugDetail(detail) {
if (!detail || typeof detail !== 'object') {
return;
}
var label = detail.label || '';
var generic = detail.generic_name || '';
var hsnCode = detail.hsn_code || '';
var formulation = detail.formulation || '';
var packing = detail.packing || '';
if (label) {
$('#input_item_name').val(label);
$('#abdm_drug_display').val(label);
}
if (generic && !$('#input_genericname').val()) {
$('#input_genericname').val(generic);
}
if (hsnCode && !$('#input_HSNCODE').val()) {
$('#input_HSNCODE').val(hsnCode);
}
if (formulation) {
var $f = $('#input_formulation');
var exists = $f.find('option').filter(function () {
return String($(this).val()).toLowerCase() === String(formulation).toLowerCase();
}).length > 0;
if (exists) {
$f.val(formulation);
}
}
if (packing && !$('#input_packing_type').val()) {
$('#input_packing_type').val(packing);
}
$('#abdm_drug_type').val(detail.type || '');
$('#abdm_drug_identifier').val(detail.identifier || '');
$('#abdm_drug_generic').val(generic);
$('#abdm_drug_payload_json').val(detail.payload_json || '{}');
$('#abdm_drug_last_synced_at').val(detail.synced_at || '');
showSelectedDrugSummary();
}
function fetchDrugDetail(type, identifier) {
if (!identifier) {
return;
}
$.getJSON('<?= base_url('product_master/drug_terminology_detail') ?>', {
type: type || 'generic',
identifier: identifier
}, function (resp) {
if (!resp || Number(resp.ok || 0) !== 1 || !resp.selected) {
return;
}
applyDrugDetail(resp.selected);
});
}
function renderSuggestions(items) {
var $box = $('#abdm-drug-suggest');
$box.empty();
if (!Array.isArray(items) || items.length === 0) {
clearSuggestionBox();
return;
}
items.forEach(function (item) {
var label = item.label || '';
var type = item.type || '';
var identifier = item.identifier || '';
if (!label && !identifier) {
return;
}
var $a = $('<a href="#" class="list-group-item list-group-item-action py-1 px-2"></a>');
$a.text(label + (type ? ' [' + type + ']' : '') + (identifier ? ' (' + identifier + ')' : ''));
$a.on('click', function (e) {
e.preventDefault();
clearSuggestionBox();
if (label) {
$('#input_item_name').val(label);
}
fetchDrugDetail(type, identifier);
});
$box.append($a);
});
if ($box.children().length > 0) {
$box.show();
} else {
clearSuggestionBox();
}
}
function fetchDrugSuggestions() {
var q = ($('#input_item_name').val() || '').trim();
var type = ($('#abdm_drug_search_type').val() || 'generic').trim();
if (q.length < 2) {
clearSuggestionBox();
return;
}
if (q === lastQuery) {
return;
}
lastQuery = q;
$.getJSON('<?= base_url('product_master/drug_terminology_autocomplete') ?>', {
q: q,
type: type,
limit: 10
}, function (resp) {
if (!resp || Number(resp.ok || 0) !== 1) {
clearSuggestionBox();
return;
}
renderSuggestions(resp.suggestions || []);
}).fail(function () {
clearSuggestionBox();
});
}
$('#input_item_name').off('input').on('input', function () {
if (suggestTimer) {
clearTimeout(suggestTimer);
}
suggestTimer = setTimeout(fetchDrugSuggestions, 250);
});
$('#abdm_drug_search_type').off('change').on('change', function () {
lastQuery = '';
fetchDrugSuggestions();
});
$(document).off('click.abdmDrug').on('click.abdmDrug', function (evt) {
if (!$(evt.target).closest('#abdm-drug-suggest, #input_item_name').length) {
clearSuggestionBox();
}
});
showSelectedDrugSummary();
$('#btn_update_stock').off('click').on('click', function () {
$.post('<?= base_url('product_master/product_master_update') ?>/' + ($('#product_id').val() || '0'), $('#product-form').serialize(), function (data) {
if (!data || typeof data !== 'object') {