feat: SNOMED background coding pipeline + ABDM Coding Panel
- Add opd_coding_queue + opd_snomed_suggestions migrations - Add ProcessSnomedCoding spark command (cron: snomed:process-coding) Extracts clinical phrases, matches local SNOMED FULLTEXT, saves suggestions - Add AbdmCodingPanel controller: index/review/confirm/reject/correct/mark_fhir_ready/tip_check - Add coding_panel + coding_panel_review views (Bootstrap 5, AJAX actions) - Inject queue trigger in opd_prescription_save (fail-open, ON DUPLICATE KEY) - Add consult form tip badge: polls tip_check 4s after save, shows review link - Add AbdmCodingPanel routes behind abdm permission filter - Various ABDM gateway, FHIR, ABHA, SNOMED library updates
This commit is contained in:
parent
0628f8f813
commit
a56e8a5776
360
app/Commands/ProcessSnomedCoding.php
Normal file
360
app/Commands/ProcessSnomedCoding.php
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
/**
|
||||
* Spark command: php spark snomed:process-coding
|
||||
*
|
||||
* Runs from cron every minute:
|
||||
* * * * * * cd /var/www/html && php spark snomed:process-coding >> /dev/null 2>&1
|
||||
*
|
||||
* Picks pending rows from opd_coding_queue, extracts clinical phrases from
|
||||
* free-text OPD fields, queries local SNOMED FULLTEXT index, saves
|
||||
* suggestions into opd_snomed_suggestions for staff review in ABDM Coding Panel.
|
||||
*/
|
||||
class ProcessSnomedCoding extends BaseCommand
|
||||
{
|
||||
protected $group = 'ABDM';
|
||||
protected $name = 'snomed:process-coding';
|
||||
protected $description = 'Process pending OPD SNOMED coding queue — extract phrases, match SNOMED codes, save suggestions.';
|
||||
|
||||
/** Max records per run (keep cron fast) */
|
||||
private const BATCH = 5;
|
||||
|
||||
/** Source fields to process and their SNOMED semantic tag preferences */
|
||||
private const FIELD_TAGS = [
|
||||
'complaints' => ['finding', 'disorder', 'symptom', 'observable entity'],
|
||||
'diagnosis' => ['disorder', 'finding', 'disease'],
|
||||
'Provisional_diagnosis' => ['disorder', 'finding', 'disease'],
|
||||
'Finding_Examinations' => ['finding', 'observable entity', 'procedure'],
|
||||
];
|
||||
|
||||
public function run(array $params): void
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Claim a batch atomically (status = pending → processing)
|
||||
$rows = $db->table('opd_coding_queue')
|
||||
->where('status', 'pending')
|
||||
->orderBy('queued_at', 'ASC')
|
||||
->limit(self::BATCH)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
CLI::write('[snomed:process-coding] No pending items.', 'green');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rows as $qRow) {
|
||||
$queueId = (int) $qRow['id'];
|
||||
$opdId = (int) $qRow['opd_id'];
|
||||
$sessionId = (int) $qRow['opd_session_id'];
|
||||
|
||||
// Mark processing to prevent parallel workers grabbing same row
|
||||
$db->table('opd_coding_queue')
|
||||
->where('id', $queueId)
|
||||
->where('status', 'pending') // optimistic lock
|
||||
->update(['status' => 'processing']);
|
||||
|
||||
if ($db->affectedRows() === 0) {
|
||||
// Another worker claimed it — skip
|
||||
continue;
|
||||
}
|
||||
|
||||
CLI::write("[snomed:process-coding] Processing queue #{$queueId} opd_id={$opdId} session={$sessionId}");
|
||||
|
||||
try {
|
||||
$prescription = $db->table('opd_prescription')
|
||||
->where('id', $sessionId)
|
||||
->where('opd_id', $opdId)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
if (empty($prescription)) {
|
||||
throw new \RuntimeException("opd_prescription row not found for session {$sessionId}");
|
||||
}
|
||||
|
||||
$suggestionCount = $this->processPrescription($db, $opdId, $sessionId, $prescription);
|
||||
|
||||
$db->table('opd_coding_queue')
|
||||
->where('id', $queueId)
|
||||
->update([
|
||||
'status' => 'done',
|
||||
'has_suggestions' => $suggestionCount > 0 ? 1 : 0,
|
||||
'processed_at' => date('Y-m-d H:i:s'),
|
||||
'error_message' => null,
|
||||
]);
|
||||
|
||||
CLI::write(" → {$suggestionCount} suggestions saved.", 'green');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$db->table('opd_coding_queue')
|
||||
->where('id', $queueId)
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'processed_at' => date('Y-m-d H:i:s'),
|
||||
'error_message' => substr($e->getMessage(), 0, 500),
|
||||
]);
|
||||
|
||||
CLI::write(" → FAILED: " . $e->getMessage(), 'red');
|
||||
}
|
||||
}
|
||||
|
||||
CLI::write('[snomed:process-coding] Done.', 'green');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Process one OPD prescription row — extract phrases from all relevant
|
||||
* fields and find SNOMED matches.
|
||||
*
|
||||
* @return int Number of suggestions inserted
|
||||
*/
|
||||
private function processPrescription(\CodeIgniter\Database\BaseConnection $db, int $opdId, int $sessionId, array $prescription): int
|
||||
{
|
||||
$inserted = 0;
|
||||
|
||||
// Delete any stale suggestions for this session (re-process is safe)
|
||||
$db->table('opd_snomed_suggestions')
|
||||
->where('opd_session_id', $sessionId)
|
||||
->where('status', 'pending_review')
|
||||
->delete();
|
||||
|
||||
foreach (self::FIELD_TAGS as $field => $preferredTags) {
|
||||
$text = trim((string) ($prescription[$field] ?? ''));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$phrases = $this->extractPhrases($text);
|
||||
|
||||
foreach ($phrases as $phrase) {
|
||||
$matches = $this->searchSnomed($db, $phrase, $preferredTags, 3);
|
||||
|
||||
foreach ($matches as $idx => $match) {
|
||||
// Skip very low confidence
|
||||
if ($match['confidence'] < 0.15) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->table('opd_snomed_suggestions')->insert([
|
||||
'opd_id' => $opdId,
|
||||
'opd_session_id' => $sessionId,
|
||||
'source_field' => $field,
|
||||
'source_phrase' => substr($phrase, 0, 500),
|
||||
'concept_id' => $match['concept_id'],
|
||||
'snomed_term' => substr($match['term'], 0, 500),
|
||||
'semantic_tag' => substr($match['semantic_tag'], 0, 100),
|
||||
'confidence' => round($match['confidence'], 3),
|
||||
'status' => 'pending_review',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$inserted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful clinical phrases from free-text.
|
||||
* Splits on delimiters, filters noise, deduplicates.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function extractPhrases(string $text): array
|
||||
{
|
||||
// Strip NABH structured lines if present (Drug Allergy Status etc.)
|
||||
$text = preg_replace('/^(Drug Allergy Status|Drug Allergy Details|ADR History|Current Medications)[^\n]*\n?/im', '', $text);
|
||||
|
||||
// Normalise separators
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = preg_replace('/\s*[,;\/|]\s*/', "\n", $text);
|
||||
$text = preg_replace('/\s+and\s+|\s+with\s+/i', "\n", $text);
|
||||
|
||||
$lines = explode("\n", $text);
|
||||
$phrases = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Also split on sentence end inside a line
|
||||
$parts = preg_split('/\.\s+/', $line);
|
||||
foreach ($parts as $part) {
|
||||
$part = trim(preg_replace('/\s+/', ' ', $part) ?? $part);
|
||||
// Filter by length
|
||||
if (mb_strlen($part) < 3 || mb_strlen($part) > 150) {
|
||||
continue;
|
||||
}
|
||||
// Skip pure numeric or very short
|
||||
if (preg_match('/^\d+$/', $part)) {
|
||||
continue;
|
||||
}
|
||||
$key = mb_strtolower($part);
|
||||
$phrases[$key] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($phrases);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search local SNOMED FULLTEXT for a phrase.
|
||||
* Returns top $limit results with confidence scores.
|
||||
*
|
||||
* @return array<int,array{concept_id:string,term:string,semantic_tag:string,confidence:float}>
|
||||
*/
|
||||
private function searchSnomed(\CodeIgniter\Database\BaseConnection $db, string $phrase, array $preferredTags, int $limit): array
|
||||
{
|
||||
if (trim($phrase) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$safeTerm = str_replace(['"', "'", '\\'], ' ', $phrase);
|
||||
$safeTerm = trim($safeTerm);
|
||||
|
||||
if ($safeTerm === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Try FULLTEXT boolean mode first
|
||||
$boolTerm = '+' . implode(' +', array_filter(explode(' ', $safeTerm)));
|
||||
$sql = "SELECT
|
||||
sd.conceptId AS concept_id,
|
||||
sd.term,
|
||||
MATCH(sd.term) AGAINST(? IN BOOLEAN MODE) AS ft_score
|
||||
FROM snomed_description sd
|
||||
WHERE sd.active = '1'
|
||||
AND sd.typeId IN ('900000000000003001','900000000000013009')
|
||||
AND MATCH(sd.term) AGAINST(? IN BOOLEAN MODE)
|
||||
ORDER BY ft_score DESC
|
||||
LIMIT ?";
|
||||
|
||||
$results = $db->query($sql, [$boolTerm, $boolTerm, $limit * 4])->getResultArray();
|
||||
|
||||
if (empty($results)) {
|
||||
// Fallback to natural language mode
|
||||
$sql2 = "SELECT
|
||||
sd.conceptId AS concept_id,
|
||||
sd.term,
|
||||
MATCH(sd.term) AGAINST(? IN NATURAL LANGUAGE MODE) AS ft_score
|
||||
FROM snomed_description sd
|
||||
WHERE sd.active = '1'
|
||||
AND sd.typeId IN ('900000000000003001','900000000000013009')
|
||||
AND MATCH(sd.term) AGAINST(? IN NATURAL LANGUAGE MODE)
|
||||
ORDER BY ft_score DESC
|
||||
LIMIT ?";
|
||||
$results = $db->query($sql2, [$safeTerm, $safeTerm, $limit * 4])->getResultArray();
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Enrich with semantic tag and score
|
||||
$conceptIds = array_unique(array_column($results, 'concept_id'));
|
||||
$tagMap = $this->fetchSemanticTags($db, $conceptIds);
|
||||
|
||||
$scored = [];
|
||||
$maxFt = (float) max(array_column($results, 'ft_score'));
|
||||
if ($maxFt <= 0) {
|
||||
$maxFt = 1;
|
||||
}
|
||||
|
||||
foreach ($results as $row) {
|
||||
$conceptId = (string) $row['concept_id'];
|
||||
$term = (string) $row['term'];
|
||||
$ftScore = (float) $row['ft_score'];
|
||||
$semanticTag = $tagMap[$conceptId] ?? '';
|
||||
|
||||
// Normalised FT score
|
||||
$ftNorm = $ftScore / $maxFt;
|
||||
|
||||
// Term similarity bonus (word overlap)
|
||||
$phraseWords = array_filter(explode(' ', mb_strtolower($phrase)));
|
||||
$termWords = array_filter(explode(' ', mb_strtolower($term)));
|
||||
$overlap = count(array_intersect($phraseWords, $termWords));
|
||||
$simBonus = $overlap / max(count($phraseWords), 1) * 0.3;
|
||||
|
||||
// Preferred semantic tag bonus
|
||||
$tagBonus = 0.0;
|
||||
$tagLower = mb_strtolower($semanticTag);
|
||||
foreach ($preferredTags as $preferred) {
|
||||
if (str_contains($tagLower, mb_strtolower($preferred))) {
|
||||
$tagBonus = 0.2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$confidence = min(1.0, $ftNorm * 0.5 + $simBonus + $tagBonus);
|
||||
|
||||
$scored[] = [
|
||||
'concept_id' => $conceptId,
|
||||
'term' => $term,
|
||||
'semantic_tag' => $semanticTag,
|
||||
'confidence' => $confidence,
|
||||
];
|
||||
}
|
||||
|
||||
// De-duplicate by concept_id, keeping highest confidence
|
||||
$best = [];
|
||||
foreach ($scored as $item) {
|
||||
$cid = $item['concept_id'];
|
||||
if (!isset($best[$cid]) || $item['confidence'] > $best[$cid]['confidence']) {
|
||||
$best[$cid] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending
|
||||
usort($best, fn($a, $b) => $b['confidence'] <=> $a['confidence']);
|
||||
|
||||
return array_slice(array_values($best), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch semantic tags for an array of conceptIds.
|
||||
* Parses parenthesised tag from FSN term e.g. "Fever (finding)" → "finding"
|
||||
*
|
||||
* @param string[] $conceptIds
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function fetchSemanticTags(\CodeIgniter\Database\BaseConnection $db, array $conceptIds): array
|
||||
{
|
||||
if (empty($conceptIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($conceptIds), '?'));
|
||||
|
||||
try {
|
||||
$rows = $db->query(
|
||||
"SELECT conceptId, term FROM snomed_description
|
||||
WHERE conceptId IN ({$placeholders})
|
||||
AND typeId = '900000000000003001'
|
||||
AND active = '1'
|
||||
LIMIT " . (count($conceptIds) * 2),
|
||||
$conceptIds
|
||||
)->getResultArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
if (preg_match('/\(([^)]+)\)\s*$/', (string) $row['term'], $m)) {
|
||||
$map[(string) $row['conceptId']] = $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
|
@ -136,4 +136,12 @@ class AbdmConnector extends BaseConfig
|
|||
|
||||
/** HTTP timeout in seconds for direct ABDM API calls. */
|
||||
public int $directAbdmTimeoutSec = 30;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// FHIR record encryption key (AES-256-GCM)
|
||||
// Set in .env: ABDM_FHIR_ENCRYPTION_KEY = <64-char hex string>
|
||||
// Generate: php -r "echo bin2hex(random_bytes(32));"
|
||||
// REQUIRED on production; falls back to a derived key in development.
|
||||
// ------------------------------------------------------------------
|
||||
// ABDM_FHIR_ENCRYPTION_KEY =
|
||||
}
|
||||
|
|
|
|||
|
|
@ -542,9 +542,21 @@ $routes->post('AbdmGateway/abha_validate', 'AbdmGateway::abhaValidate', ['filter
|
|||
$routes->post('AbdmGateway/scan_share_lookup', 'AbdmGateway::scanShareLookup', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback');
|
||||
$routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback');
|
||||
$routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback');
|
||||
$routes->post('AbdmGateway/share_prescription_bundle', 'AbdmGateway::sharePrescriptionBundle', ['filter' => $abdmPermFilter]);
|
||||
// SNOMED Coding Panel
|
||||
$routes->get('AbdmCodingPanel', 'AbdmCodingPanel::index', ['filter' => $abdmPermFilter]);
|
||||
$routes->get('AbdmCodingPanel/review/(:num)', 'AbdmCodingPanel::review/$1', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmCodingPanel/confirm', 'AbdmCodingPanel::confirm', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmCodingPanel/reject', 'AbdmCodingPanel::reject', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmCodingPanel/correct', 'AbdmCodingPanel::correct', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmCodingPanel/mark_fhir_ready', 'AbdmCodingPanel::markFhirReady', ['filter' => $abdmPermFilter]);
|
||||
$routes->get('AbdmCodingPanel/tip_check', 'AbdmCodingPanel::tipCheck');
|
||||
$routes->post('AbdmGateway/share_ipd_discharge_bundle', 'AbdmGateway::shareIpdDischargeBundle', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/share_diagnosis_report_bundle', 'AbdmGateway::shareDiagnosisReportBundle', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/push_health_record', 'AbdmGateway::pushHealthRecord', ['filter' => $abdmPermFilter]);
|
||||
$routes->get('AbdmGateway/health_records_list', 'AbdmGateway::healthRecordsList', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/nhcx_claim_create', 'AbdmGateway::nhcxClaimCreate', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/nhcx_claim_status_request', 'AbdmGateway::nhcxClaimStatusRequest', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/nhcx_claim_status_callback', 'AbdmGateway::nhcxClaimStatusCallback');
|
||||
|
|
@ -576,8 +588,12 @@ $routes->get('Opd_prescription/fhir_bundle/(:num)', 'Opd_prescription::fhir_bund
|
|||
$routes->get('Opd_prescription/fhir_bundle/(:num)/(:num)', 'Opd_prescription::fhir_bundle/$1/$2');
|
||||
$routes->get('Opd_prescription/fhir_bundle_history/(:num)', 'Opd_prescription::fhir_bundle_history/$1');
|
||||
$routes->get('Opd_prescription/fhir_bundle_history/(:num)/(:num)', 'Opd_prescription::fhir_bundle_history/$1/$2');
|
||||
$routes->get('Opd_prescription/fhir_bundle_preview/(:num)', 'Opd_prescription::fhir_bundle_preview/$1');
|
||||
$routes->get('Opd_prescription/fhir_bundle_preview/(:num)/(:num)', 'Opd_prescription::fhir_bundle_preview/$1/$2');
|
||||
$routes->get('Opd_prescription/complaints_search', 'Opd_prescription::complaints_search');
|
||||
$routes->get('Opd_prescription/section_recent_entries', 'Opd_prescription::section_recent_entries');
|
||||
$routes->get('Opd_prescription/provisional_diagnosis_search', 'Opd_prescription::provisional_diagnosis_search');
|
||||
$routes->get('Opd_prescription/diagnosis_search', 'Opd_prescription::provisional_diagnosis_search');
|
||||
$routes->get('Opd_prescription/finding_exam_search', 'Opd_prescription::finding_exam_search');
|
||||
$routes->get('Opd_prescription/get_complaints', 'Opd_prescription::get_complaints');
|
||||
$routes->get('Opd_prescription/get_disease', 'Opd_prescription::get_disease');
|
||||
|
|
@ -680,6 +696,7 @@ $routes->post('Opd_prescription/investigation_remove/(:num)', 'Opd_prescription:
|
|||
$routes->get('Opd_prescription/opd_invest_master', 'Opd_prescription::opd_invest_master', ['filter' => 'permission:doctor_work.medicine.manage,doctor_work.access']);
|
||||
$routes->get('Opd_prescription/opd_invest_master_specs', 'Opd_prescription::opd_invest_master_specs');
|
||||
$routes->get('Opd_prescription/opd_invest_master_data', 'Opd_prescription::opd_invest_master_data');
|
||||
$routes->get('Opd_prescription/opd_invest_snomed_search', 'Opd_prescription::opd_invest_snomed_search');
|
||||
$routes->get('Opd_prescription/opd_invest_master_get/(:num)', 'Opd_prescription::opd_invest_master_get/$1');
|
||||
$routes->post('Opd_prescription/opd_invest_master_save', 'Opd_prescription::opd_invest_master_save');
|
||||
$routes->post('Opd_prescription/opd_invest_master_remove/(:num)', 'Opd_prescription::opd_invest_master_remove/$1');
|
||||
|
|
|
|||
314
app/Controllers/AbdmCodingPanel.php
Normal file
314
app/Controllers/AbdmCodingPanel.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
/**
|
||||
* ABDM SNOMED Coding Panel
|
||||
*
|
||||
* Provides a staff task-board to review, correct, confirm, or reject
|
||||
* AI-generated SNOMED coding suggestions for OPD consultations before
|
||||
* they are marked FHIR-ready and pushed to ABDM.
|
||||
*
|
||||
* Routes (all behind $abdmPermFilter):
|
||||
* GET AbdmCodingPanel → index()
|
||||
* GET AbdmCodingPanel/review/{id} → review($opdSessionId)
|
||||
* POST AbdmCodingPanel/confirm → confirm()
|
||||
* POST AbdmCodingPanel/reject → reject()
|
||||
* POST AbdmCodingPanel/correct → correct()
|
||||
* POST AbdmCodingPanel/mark_fhir_ready → markFhirReady()
|
||||
* GET AbdmCodingPanel/tip_check → tipCheck() (called from consult form)
|
||||
*/
|
||||
class AbdmCodingPanel extends BaseController
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Index — list of OPD sessions needing review
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Sessions with suggestions, ordered by most recent first
|
||||
$rows = $db->query("
|
||||
SELECT
|
||||
q.id AS queue_id,
|
||||
q.opd_id,
|
||||
q.opd_session_id,
|
||||
q.status AS queue_status,
|
||||
q.queued_at,
|
||||
q.processed_at,
|
||||
om.opd_date,
|
||||
om.opd_no,
|
||||
pm.p_name AS patient_name,
|
||||
pm.p_mobile AS patient_mobile,
|
||||
(SELECT COUNT(*) FROM opd_snomed_suggestions s
|
||||
WHERE s.opd_session_id = q.opd_session_id) AS total_suggestions,
|
||||
(SELECT COUNT(*) FROM opd_snomed_suggestions s
|
||||
WHERE s.opd_session_id = q.opd_session_id
|
||||
AND s.status = 'pending_review') AS pending_count,
|
||||
(SELECT COUNT(*) FROM opd_snomed_suggestions s
|
||||
WHERE s.opd_session_id = q.opd_session_id
|
||||
AND s.status = 'confirmed') AS confirmed_count
|
||||
FROM opd_coding_queue q
|
||||
LEFT JOIN opd_master om ON om.opd_id = q.opd_id
|
||||
LEFT JOIN patient_master pm ON pm.id = om.p_id
|
||||
WHERE q.status IN ('done','failed')
|
||||
AND q.has_suggestions = 1
|
||||
ORDER BY q.queued_at DESC
|
||||
LIMIT 200
|
||||
")->getResultArray();
|
||||
|
||||
return view('abdm/coding_panel', [
|
||||
'sessions' => $rows,
|
||||
'page_title' => 'SNOMED Coding Panel',
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Review — show all suggestions for one OPD session
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function review(int $sessionId = 0)
|
||||
{
|
||||
if ($sessionId <= 0) {
|
||||
return redirect()->to('AbdmCodingPanel');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$suggestions = $db->table('opd_snomed_suggestions')
|
||||
->where('opd_session_id', $sessionId)
|
||||
->orderBy('source_field', 'ASC')
|
||||
->orderBy('confidence', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($suggestions)) {
|
||||
return redirect()->to('AbdmCodingPanel')->with('message', 'No suggestions found for this session.');
|
||||
}
|
||||
|
||||
// Fetch consult header info
|
||||
$consult = $db->query("
|
||||
SELECT op.*, om.opd_date, om.opd_no, pm.p_name AS patient_name
|
||||
FROM opd_prescription op
|
||||
JOIN opd_master om ON om.opd_id = op.opd_id
|
||||
JOIN patient_master pm ON pm.id = om.p_id
|
||||
WHERE op.id = ?
|
||||
LIMIT 1
|
||||
", [$sessionId])->getRowArray();
|
||||
|
||||
$queueRow = $db->table('opd_coding_queue')
|
||||
->where('opd_session_id', $sessionId)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
return view('abdm/coding_panel_review', [
|
||||
'session_id' => $sessionId,
|
||||
'consult' => $consult,
|
||||
'suggestions' => $suggestions,
|
||||
'queue_row' => $queueRow,
|
||||
'page_title' => 'Review SNOMED Codes',
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Confirm a suggestion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function confirm()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0]);
|
||||
}
|
||||
|
||||
$id = (int) $this->request->getPost('suggestion_id');
|
||||
$userId = (int) session()->get('user_id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid suggestion_id']);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('opd_snomed_suggestions')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'confirmed',
|
||||
'reviewed_by' => $userId ?: null,
|
||||
'reviewed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON(['ok' => 1, 'suggestion_id' => $id, 'status' => 'confirmed']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reject a suggestion
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function reject()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0]);
|
||||
}
|
||||
|
||||
$id = (int) $this->request->getPost('suggestion_id');
|
||||
$userId = (int) session()->get('user_id');
|
||||
|
||||
if ($id <= 0) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid suggestion_id']);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('opd_snomed_suggestions')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'rejected',
|
||||
'reviewed_by' => $userId ?: null,
|
||||
'reviewed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON(['ok' => 1, 'suggestion_id' => $id, 'status' => 'rejected']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Correct — reviewer overrides with a different SNOMED code
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function correct()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0]);
|
||||
}
|
||||
|
||||
$id = (int) $this->request->getPost('suggestion_id');
|
||||
$conceptId = trim((string) $this->request->getPost('corrected_concept_id'));
|
||||
$term = trim((string) $this->request->getPost('corrected_term'));
|
||||
$userId = (int) session()->get('user_id');
|
||||
|
||||
if ($id <= 0 || $conceptId === '' || $term === '') {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'suggestion_id, corrected_concept_id and corrected_term are required']);
|
||||
}
|
||||
|
||||
// Validate concept_id is numeric (18-digit SNOMED CT)
|
||||
if (! preg_match('/^\d{6,18}$/', $conceptId)) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid SNOMED concept_id format']);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('opd_snomed_suggestions')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'corrected',
|
||||
'corrected_concept_id' => $conceptId,
|
||||
'corrected_term' => substr($term, 0, 500),
|
||||
'reviewed_by' => $userId ?: null,
|
||||
'reviewed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON(['ok' => 1, 'suggestion_id' => $id, 'status' => 'corrected']);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mark all confirmed/corrected suggestions for this session as FHIR-ready
|
||||
// (updates opd_prescription SNOMED fields with the final codes)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function markFhirReady()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0]);
|
||||
}
|
||||
|
||||
$sessionId = (int) $this->request->getPost('session_id');
|
||||
if ($sessionId <= 0) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'session_id required']);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Load finalised suggestions for this session
|
||||
$suggestions = $db->table('opd_snomed_suggestions')
|
||||
->where('opd_session_id', $sessionId)
|
||||
->whereIn('status', ['confirmed', 'corrected'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($suggestions)) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'No confirmed/corrected suggestions found']);
|
||||
}
|
||||
|
||||
// Build field → best-confidence suggestion map
|
||||
$byField = [];
|
||||
foreach ($suggestions as $s) {
|
||||
$field = $s['source_field'];
|
||||
if (!isset($byField[$field]) || (float) $s['confidence'] > (float) $byField[$field]['confidence']) {
|
||||
$byField[$field] = $s;
|
||||
}
|
||||
}
|
||||
|
||||
// Map our source_field names to opd_prescription column names
|
||||
$fieldColumnMap = [
|
||||
'complaints' => ['snomed_id' => null, 'snomed_term' => null], // stored in complaint_snomed_json
|
||||
'diagnosis' => ['snomed_id' => 'diagnosis_snomed_id', 'snomed_term' => 'diagnosis_snomed_term'],
|
||||
'Provisional_diagnosis' => ['snomed_id' => 'provisional_diagnosis_snomed_id', 'snomed_term' => 'provisional_diagnosis_snomed_term'],
|
||||
];
|
||||
|
||||
$updates = [];
|
||||
foreach ($byField as $field => $s) {
|
||||
$finalConceptId = $s['status'] === 'corrected' ? $s['corrected_concept_id'] : $s['concept_id'];
|
||||
$finalTerm = $s['status'] === 'corrected' ? $s['corrected_term'] : $s['snomed_term'];
|
||||
|
||||
if (! isset($fieldColumnMap[$field])) {
|
||||
continue;
|
||||
}
|
||||
$cols = $fieldColumnMap[$field];
|
||||
if ($cols['snomed_id'] !== null) {
|
||||
$updates[$cols['snomed_id']] = $finalConceptId;
|
||||
$updates[$cols['snomed_term']] = $finalTerm;
|
||||
$updates[str_replace('_id', '_source', $cols['snomed_id'])] = 'coding-panel';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
$db->table('opd_prescription')
|
||||
->where('id', $sessionId)
|
||||
->update($updates);
|
||||
}
|
||||
|
||||
// Mark queue row as fhir-ready (reuse 'done' status, set has_suggestions = 2 as flag)
|
||||
$db->table('opd_coding_queue')
|
||||
->where('opd_session_id', $sessionId)
|
||||
->update(['has_suggestions' => 2]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => 1,
|
||||
'session_id' => $sessionId,
|
||||
'updated' => count($updates) / 2,
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tip check — lightweight poll from the consult form after save
|
||||
// Returns whether fast coding suggestions are available for this session
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function tipCheck()
|
||||
{
|
||||
$sessionId = (int) $this->request->getGet('session_id');
|
||||
if ($sessionId <= 0) {
|
||||
return $this->response->setJSON(['has_suggestions' => 0]);
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$count = $db->table('opd_snomed_suggestions')
|
||||
->where('opd_session_id', $sessionId)
|
||||
->where('status', 'pending_review')
|
||||
->countAllResults();
|
||||
|
||||
return $this->response->setJSON([
|
||||
'has_suggestions' => $count > 0 ? 1 : 0,
|
||||
'count' => $count,
|
||||
'review_url' => base_url('AbdmCodingPanel/review/' . $sessionId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ namespace App\Controllers;
|
|||
use App\Libraries\Abdm\AbdmConnectorInterface;
|
||||
use App\Libraries\Abdm\AbdmConnectorFactory;
|
||||
use App\Libraries\FhirR4Builder;
|
||||
use App\Libraries\FhirEncryptionService;
|
||||
use App\Libraries\AbdmAuditService;
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
class AbdmGateway extends BaseController
|
||||
|
|
@ -328,13 +330,43 @@ class AbdmGateway extends BaseController
|
|||
'bundle' => $bundle,
|
||||
];
|
||||
|
||||
// Store FHIR payload in health_records before pushing
|
||||
$healthRecordId = $this->storeHealthRecord([
|
||||
'patient_id' => $patientId,
|
||||
'abha_id' => $abhaId,
|
||||
'hi_type' => 'OPConsultRecord',
|
||||
'entity_type' => 'opd',
|
||||
'entity_id' => (string) $opdId,
|
||||
'fhir_bundle' => $bundleJson,
|
||||
'consent_handle' => (string) ($consent['consent_handle'] ?? ''),
|
||||
]);
|
||||
|
||||
$queueId = null;
|
||||
$connectorError = null;
|
||||
try {
|
||||
$result = $this->connector->sharePrescriptionBundle($payload, (string) ($bundleRow['id'] ?? ''));
|
||||
$queueId = $result['queue_id'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$connectorError = $e->getMessage();
|
||||
}
|
||||
|
||||
// Update health_record with txn_id and create record_link
|
||||
if ($healthRecordId > 0) {
|
||||
$this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError);
|
||||
}
|
||||
|
||||
$this->getAuditService()->log([
|
||||
'action' => 'push_record',
|
||||
'entity_type' => 'opd',
|
||||
'entity_id' => (string) $opdId,
|
||||
'abha_id' => $abhaId,
|
||||
'patient_id' => $patientId,
|
||||
'request' => ['opd_id' => $opdId, 'hi_type' => 'OPConsultRecord', 'consent_handle' => (string) ($consent['consent_handle'] ?? '')],
|
||||
'response' => ['queue_id' => $queueId],
|
||||
'outcome' => $connectorError === null ? 'success' : 'failure',
|
||||
'error_message' => (string) ($connectorError ?? ''),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => 1,
|
||||
'queue_id' => $queueId,
|
||||
|
|
@ -393,13 +425,42 @@ class AbdmGateway extends BaseController
|
|||
'summary_html' => $summaryHtml,
|
||||
];
|
||||
|
||||
// Store in health_records
|
||||
$healthRecordId = $this->storeHealthRecord([
|
||||
'patient_id' => $patientId,
|
||||
'abha_id' => $abhaId,
|
||||
'hi_type' => 'DischargeSummary',
|
||||
'entity_type' => 'ipd',
|
||||
'entity_id' => (string) $ipdId,
|
||||
'fhir_bundle' => json_encode($payload),
|
||||
'consent_handle' => (string) ($consent['consent_handle'] ?? ''),
|
||||
]);
|
||||
|
||||
$queueId = null;
|
||||
$connectorError = null;
|
||||
try {
|
||||
$result = $this->connector->shareIpdDischargeBundle($payload, (string) $ipdId);
|
||||
$queueId = $result['queue_id'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$connectorError = $e->getMessage();
|
||||
}
|
||||
|
||||
if ($healthRecordId > 0) {
|
||||
$this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError);
|
||||
}
|
||||
|
||||
$this->getAuditService()->log([
|
||||
'action' => 'push_record',
|
||||
'entity_type' => 'ipd',
|
||||
'entity_id' => (string) $ipdId,
|
||||
'abha_id' => $abhaId,
|
||||
'patient_id' => $patientId,
|
||||
'request' => ['ipd_id' => $ipdId, 'hi_type' => 'DischargeSummary', 'consent_handle' => (string) ($consent['consent_handle'] ?? '')],
|
||||
'response' => ['queue_id' => $queueId],
|
||||
'outcome' => $connectorError === null ? 'success' : 'failure',
|
||||
'error_message' => (string) ($connectorError ?? ''),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => 1,
|
||||
'queue_id' => $queueId,
|
||||
|
|
@ -451,13 +512,42 @@ class AbdmGateway extends BaseController
|
|||
'consent_handle' => $consentRecord['consent_handle'] ?? $consentHandle,
|
||||
];
|
||||
|
||||
// Store in health_records (report HTML as the FHIR payload placeholder)
|
||||
$healthRecordId = $this->storeHealthRecord([
|
||||
'patient_id' => $patientId,
|
||||
'abha_id' => $abhaId,
|
||||
'hi_type' => 'DiagnosticReport',
|
||||
'entity_type' => 'lab',
|
||||
'entity_id' => (string) $labReqId,
|
||||
'fhir_bundle' => json_encode($payload),
|
||||
'consent_handle' => $payload['consent_handle'],
|
||||
]);
|
||||
|
||||
$queueId = null;
|
||||
$connectorError = null;
|
||||
try {
|
||||
$result = $this->connector->shareDiagnosisReportBundle($payload, (string) $labReqId);
|
||||
$queueId = $result['queue_id'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$connectorError = $e->getMessage();
|
||||
}
|
||||
|
||||
if ($healthRecordId > 0) {
|
||||
$this->updateHealthRecordTxn($healthRecordId, (string) ($queueId ?? ''), $connectorError);
|
||||
}
|
||||
|
||||
$this->getAuditService()->log([
|
||||
'action' => 'push_record',
|
||||
'entity_type' => 'lab',
|
||||
'entity_id' => (string) $labReqId,
|
||||
'abha_id' => $abhaId,
|
||||
'patient_id' => $patientId,
|
||||
'request' => ['lab_req_id' => $labReqId, 'hi_type' => 'DiagnosticReport', 'consent_handle' => $payload['consent_handle']],
|
||||
'response' => ['queue_id' => $queueId],
|
||||
'outcome' => $connectorError === null ? 'success' : 'failure',
|
||||
'error_message' => (string) ($connectorError ?? ''),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => 1,
|
||||
'queue_id' => $queueId,
|
||||
|
|
@ -684,6 +774,325 @@ class AbdmGateway extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Record Linked Callback — ABDM notifies HMS when a record is successfully
|
||||
// linked to an ABHA address.
|
||||
// POST /AbdmGateway/record_linked_callback (no auth filter — public webhook)
|
||||
// =========================================================================
|
||||
|
||||
public function recordLinkedCallback()
|
||||
{
|
||||
$signatureFailure = $this->validateWebhookSignature();
|
||||
if ($signatureFailure !== null) {
|
||||
return $signatureFailure;
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true);
|
||||
if (! is_array($payload)) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON payload']);
|
||||
}
|
||||
|
||||
$abdmTxnId = trim((string) ($payload['queue_id'] ?? $payload['abdm_txn_id'] ?? ''));
|
||||
$careContextRef = trim((string) ($payload['care_context_reference'] ?? $payload['care_context_ref'] ?? ''));
|
||||
$abhaId = trim((string) ($payload['abha_id'] ?? ''));
|
||||
$status = strtolower(trim((string) ($payload['status'] ?? 'linked')));
|
||||
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
|
||||
if ($abdmTxnId === '' && $careContextRef === '') {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'queue_id or care_context_reference is required']);
|
||||
}
|
||||
|
||||
// Update health_records
|
||||
if ($this->db->tableExists('health_records')) {
|
||||
$hrBuilder = $this->db->table('health_records');
|
||||
if ($abdmTxnId !== '') {
|
||||
$hrBuilder->where('abdm_txn_id', $abdmTxnId);
|
||||
} elseif ($careContextRef !== '') {
|
||||
$hrBuilder->where('care_context_reference', $careContextRef);
|
||||
}
|
||||
$hrBuilder->update([
|
||||
'push_status' => 'linked',
|
||||
'linked_at' => $now,
|
||||
'care_context_reference' => $careContextRef !== '' ? $careContextRef : null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
// Insert / update record_links
|
||||
if ($this->db->tableExists('record_links')) {
|
||||
$existing = $this->db->table('record_links')
|
||||
->where('abdm_txn_id', $abdmTxnId)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
$responseJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if (! empty($existing)) {
|
||||
$this->db->table('record_links')
|
||||
->where('id', (int) $existing['id'])
|
||||
->update([
|
||||
'link_status' => $status,
|
||||
'care_context_reference' => $careContextRef !== '' ? $careContextRef : (string) ($existing['care_context_reference'] ?? ''),
|
||||
'response_json' => $responseJson,
|
||||
'linked_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$this->db->table('record_links')->insert([
|
||||
'abdm_txn_id' => $abdmTxnId !== '' ? $abdmTxnId : null,
|
||||
'care_context_reference' => $careContextRef !== '' ? $careContextRef : null,
|
||||
'abha_id' => $abhaId !== '' ? $abhaId : null,
|
||||
'link_status' => $status,
|
||||
'response_json' => $responseJson,
|
||||
'linked_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->getAuditService()->log([
|
||||
'action' => 'record_linked',
|
||||
'entity_type' => 'health_record',
|
||||
'entity_id' => $abdmTxnId,
|
||||
'abha_id' => $abhaId,
|
||||
'response' => $payload,
|
||||
'outcome' => $status === 'linked' ? 'success' : 'failure',
|
||||
]);
|
||||
|
||||
// Update patient_master.abdm_linked_at when a record is confirmed linked
|
||||
if ($status === 'linked' && $abhaId !== '' && $this->db->tableExists('patient_master')) {
|
||||
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
|
||||
if (in_array('abdm_linked_at', $pmFields, true)) {
|
||||
$abhaFieldCandidates = ['abha_id', 'abha_no', 'abha_address', 'abha'];
|
||||
foreach ($abhaFieldCandidates as $col) {
|
||||
if (in_array($col, $pmFields, true)) {
|
||||
$this->db->table('patient_master')
|
||||
->where($col, $abhaId)
|
||||
->where('abdm_linked_at IS NULL', null, false)
|
||||
->update(['abdm_linked_at' => $now]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['ok' => 1, 'status' => $status]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Consent Revoked Callback — ABDM notifies HMS when consent is revoked.
|
||||
// POST /AbdmGateway/consent_revoked_callback (no auth filter — public webhook)
|
||||
// =========================================================================
|
||||
|
||||
public function consentRevokedCallback()
|
||||
{
|
||||
$signatureFailure = $this->validateWebhookSignature();
|
||||
if ($signatureFailure !== null) {
|
||||
return $signatureFailure;
|
||||
}
|
||||
|
||||
$payload = $this->request->getJSON(true);
|
||||
if (! is_array($payload)) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON payload']);
|
||||
}
|
||||
|
||||
$consentHandle = trim((string) ($payload['consent_handle'] ?? ''));
|
||||
$abhaId = trim((string) ($payload['abha_id'] ?? ''));
|
||||
$revokedAt = trim((string) ($payload['revoked_at'] ?? ''));
|
||||
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
|
||||
if ($consentHandle === '') {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'consent_handle is required']);
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('abdm_consent_records')) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'abdm_consent_records table not found']);
|
||||
}
|
||||
|
||||
$existing = $this->db->table('abdm_consent_records')
|
||||
->where('consent_handle', $consentHandle)
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
if (empty($existing)) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'consent_handle not found']);
|
||||
}
|
||||
|
||||
// Idempotency — already revoked
|
||||
if (strtolower((string) ($existing['consent_status'] ?? '')) === 'revoked') {
|
||||
return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked', 'duplicate' => 1]);
|
||||
}
|
||||
|
||||
$this->db->table('abdm_consent_records')
|
||||
->where('consent_handle', $consentHandle)
|
||||
->update([
|
||||
'consent_status' => 'revoked',
|
||||
'raw_payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->getAuditService()->log([
|
||||
'action' => 'consent_revoke',
|
||||
'entity_type' => 'consent',
|
||||
'entity_id' => $consentHandle,
|
||||
'abha_id' => $abhaId,
|
||||
'patient_id' => (int) ($existing['patient_id'] ?? 0),
|
||||
'response' => $payload,
|
||||
'outcome' => 'success',
|
||||
]);
|
||||
|
||||
return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Push a specific health_record to ABDM (user-triggered retry / initial push)
|
||||
// POST /AbdmGateway/push_health_record
|
||||
// =========================================================================
|
||||
|
||||
public function pushHealthRecord()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
||||
}
|
||||
|
||||
$hrId = (int) $this->request->getPost('health_record_id');
|
||||
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
|
||||
|
||||
if ($hrId <= 0) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'health_record_id is required']);
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('health_records')) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'health_records table not found']);
|
||||
}
|
||||
|
||||
$hr = $this->db->table('health_records')
|
||||
->where('id', $hrId)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
|
||||
if (empty($hr)) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'Health record not found']);
|
||||
}
|
||||
|
||||
$patientId = (int) ($hr['patient_id'] ?? 0);
|
||||
$abhaId = trim((string) ($hr['abha_id'] ?? ''));
|
||||
$hiType = trim((string) ($hr['hi_type'] ?? ''));
|
||||
|
||||
if ($abhaId === '') {
|
||||
// Try from POST param as override
|
||||
$abhaId = trim((string) $this->request->getPost('abha_id'));
|
||||
}
|
||||
|
||||
// Decrypt the stored payload (JSON encoded full push payload, may contain 'bundle' key)
|
||||
$storedPayload = [];
|
||||
$encPayload = trim((string) ($hr['fhir_bundle_enc'] ?? ''));
|
||||
if ($encPayload !== '') {
|
||||
try {
|
||||
$enc = new \App\Libraries\FhirEncryptionService();
|
||||
$decrypted = $enc->decrypt($encPayload);
|
||||
$storedPayload = json_decode($decrypted, true) ?? [];
|
||||
} catch (\Throwable $e) {
|
||||
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => 'Could not decrypt FHIR bundle: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract just the FHIR bundle from the stored payload (stored as full HMS payload)
|
||||
$fhirBundle = $storedPayload['bundle'] ?? $storedPayload['fhir_bundle'] ?? $storedPayload;
|
||||
|
||||
$entityType = trim((string) ($hr['entity_type'] ?? ''));
|
||||
$entityId = trim((string) ($hr['entity_id'] ?? ''));
|
||||
$visitDate = trim((string) ($storedPayload['visit_date'] ?? $storedPayload['reg_date'] ?? $storedPayload['reported_time'] ?? ''));
|
||||
$doctorName = trim((string) ($storedPayload['doctor_name'] ?? $storedPayload['doctor'] ?? ''));
|
||||
$patientName = trim((string) ($storedPayload['patient_name'] ?? ''));
|
||||
$department = trim((string) ($storedPayload['department'] ?? $storedPayload['dept_name'] ?? ''));
|
||||
|
||||
$queueId = null;
|
||||
$connectorErr = null;
|
||||
try {
|
||||
// Use the new store-and-link flow (POST /v3/records/push) for re-push:
|
||||
// No consent needed — bridge stores record and serves it when ABDM requests data.
|
||||
$result = $this->connector->pushRecord([
|
||||
'patient_id' => (string) $patientId,
|
||||
'patient_name' => $patientName,
|
||||
'abha_id' => $abhaId,
|
||||
'hi_type' => $hiType,
|
||||
'visit_date' => $visitDate !== '' ? $visitDate : date('Y-m-d'),
|
||||
'doctor_name' => $doctorName,
|
||||
'department' => $department,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'care_context_reference' => trim((string) ($hr['care_context_reference'] ?? '')),
|
||||
'record_data' => $fhirBundle,
|
||||
]);
|
||||
$queueId = $result['queue_id'] ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$connectorErr = $e->getMessage();
|
||||
}
|
||||
|
||||
$this->updateHealthRecordTxn($hrId, (string) ($queueId ?? ''), $connectorErr);
|
||||
|
||||
$this->getAuditService()->log([
|
||||
'action' => 'push_record',
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'abha_id' => $abhaId,
|
||||
'patient_id' => $patientId,
|
||||
'request' => ['health_record_id' => $hrId, 'hi_type' => $hiType],
|
||||
'response' => ['queue_id' => $queueId],
|
||||
'outcome' => $connectorErr === null ? 'success' : 'failure',
|
||||
'error_message' => (string) ($connectorErr ?? ''),
|
||||
]);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ok' => $connectorErr === null ? 1 : 0,
|
||||
'queue_id' => $queueId,
|
||||
'error' => $connectorErr,
|
||||
'status' => $connectorErr === null ? 'queued' : 'failed',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// List health_records for a patient (AJAX — used by ABDM task board)
|
||||
// GET /AbdmGateway/health_records_list?patient_id=X&abha_id=Y
|
||||
// =========================================================================
|
||||
|
||||
public function healthRecordsList()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
|
||||
}
|
||||
|
||||
if (! $this->db->tableExists('health_records')) {
|
||||
return $this->response->setJSON(['ok' => 1, 'records' => [], 'total' => 0]);
|
||||
}
|
||||
|
||||
$patientId = (int) $this->request->getGet('patient_id');
|
||||
$abhaId = trim((string) $this->request->getGet('abha_id'));
|
||||
|
||||
if ($patientId <= 0 && $abhaId === '') {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id or abha_id is required']);
|
||||
}
|
||||
|
||||
$builder = $this->db->table('health_records')
|
||||
->select('id, patient_id, abha_id, hi_type, entity_type, entity_id, push_status, abdm_txn_id, care_context_reference, consent_handle, push_at, linked_at, created_at')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(50);
|
||||
|
||||
if ($patientId > 0) {
|
||||
$builder->where('patient_id', $patientId);
|
||||
}
|
||||
if ($abhaId !== '') {
|
||||
$builder->where('abha_id', $abhaId);
|
||||
}
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
return $this->response->setJSON(['ok' => 1, 'records' => $rows, 'total' => count($rows)]);
|
||||
}
|
||||
|
||||
private function validateWebhookSignature()
|
||||
{
|
||||
$secret = $this->readRuntimeSetting('EKA_WEBHOOK_SECRET');
|
||||
|
|
@ -783,6 +1192,114 @@ class AbdmGateway extends BaseController
|
|||
return $currentRank > 0 && $incomingRank > 0 && $incomingRank < $currentRank;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Persist an encrypted FHIR bundle to health_records and return the new row id.
|
||||
* Returns 0 when the table is absent or on any DB error (fail-open).
|
||||
*
|
||||
* @param array{patient_id: int, abha_id: string, hi_type: string, entity_type: string,
|
||||
* entity_id: string, fhir_bundle: string, consent_handle?: string} $data
|
||||
*/
|
||||
private function storeHealthRecord(array $data): int
|
||||
{
|
||||
try {
|
||||
if (! $this->db->tableExists('health_records')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$enc = new FhirEncryptionService();
|
||||
$encPayload = '';
|
||||
$rawBundle = (string) ($data['fhir_bundle'] ?? '');
|
||||
if ($rawBundle !== '') {
|
||||
$encPayload = $enc->encrypt($rawBundle);
|
||||
}
|
||||
|
||||
$session = \Config\Services::session();
|
||||
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
|
||||
$this->db->table('health_records')->insert([
|
||||
'patient_id' => (int) ($data['patient_id'] ?? 0) > 0 ? (int) $data['patient_id'] : null,
|
||||
'abha_id' => trim((string) ($data['abha_id'] ?? '')) ?: null,
|
||||
'hi_type' => (string) ($data['hi_type'] ?? 'unknown'),
|
||||
'entity_type' => (string) ($data['entity_type'] ?? ''),
|
||||
'entity_id' => (string) ($data['entity_id'] ?? ''),
|
||||
'fhir_bundle_enc' => $encPayload !== '' ? $encPayload : null,
|
||||
'push_status' => 'queued',
|
||||
'push_at' => $now,
|
||||
'consent_handle' => trim((string) ($data['consent_handle'] ?? '')) ?: null,
|
||||
'created_by_user_id' => (int) ($session->get('user_id') ?? 0) > 0 ? (int) $session->get('user_id') : null,
|
||||
'created_by_name' => trim((string) ($session->get('full_name') ?? $session->get('name') ?? '')) ?: null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return (int) $this->db->insertID();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a health_record's push status and txn_id after getting a connector response.
|
||||
* Also creates a matching record_links row.
|
||||
*/
|
||||
private function updateHealthRecordTxn(int $healthRecordId, string $queueId, ?string $error): void
|
||||
{
|
||||
try {
|
||||
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
|
||||
if ($this->db->tableExists('health_records')) {
|
||||
$this->db->table('health_records')
|
||||
->where('id', $healthRecordId)
|
||||
->update([
|
||||
'abdm_txn_id' => $queueId !== '' ? $queueId : null,
|
||||
'push_status' => $error === null ? 'queued' : 'failed',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($queueId !== '' && $this->db->tableExists('record_links')) {
|
||||
// Only insert if not already present
|
||||
$existing = $this->db->table('record_links')
|
||||
->where('health_record_id', $healthRecordId)
|
||||
->where('abdm_txn_id', $queueId)
|
||||
->countAllResults();
|
||||
|
||||
if ($existing === 0) {
|
||||
$hr = $this->db->tableExists('health_records')
|
||||
? ($this->db->table('health_records')->select('abha_id')->where('id', $healthRecordId)->get(1)->getRowArray() ?? [])
|
||||
: [];
|
||||
|
||||
$this->db->table('record_links')->insert([
|
||||
'health_record_id' => $healthRecordId,
|
||||
'abdm_txn_id' => $queueId,
|
||||
'abha_id' => trim((string) ($hr['abha_id'] ?? '')) ?: null,
|
||||
'link_status' => 'pending',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fail-open
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a lazily-constructed AbdmAuditService instance.
|
||||
*/
|
||||
private function getAuditService(): AbdmAuditService
|
||||
{
|
||||
static $svc = null;
|
||||
if ($svc === null) {
|
||||
$svc = new AbdmAuditService();
|
||||
}
|
||||
return $svc;
|
||||
}
|
||||
|
||||
private function getActiveConsentRecord(int $patientId, string $abhaId, string $consentHandle = ''): ?array
|
||||
{
|
||||
if (! $this->db->tableExists('abdm_consent_records')) {
|
||||
|
|
|
|||
|
|
@ -174,6 +174,16 @@ class AbdmOpdQueue extends BaseController
|
|||
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// Mirror status to local DB so list() stays consistent
|
||||
try {
|
||||
\Config\Database::connect()
|
||||
->table('abdm_opd_tokens')
|
||||
->where('gateway_token_id', $tokenId)
|
||||
->update(['status' => $status, 'updated_at' => date('Y-m-d H:i:s')]);
|
||||
} catch (\Throwable $e) {
|
||||
// non-critical — gateway update already succeeded
|
||||
}
|
||||
|
||||
return $this->response->setJSON($result);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -415,7 +415,9 @@ class Opd_prescription extends BaseController
|
|||
'complaint_onset' => trim((string) $this->request->getPost('complaint_onset')),
|
||||
'complaint_duration_days' => trim((string) $this->request->getPost('complaint_duration_days')),
|
||||
'complaint_severity' => trim((string) $this->request->getPost('complaint_severity')),
|
||||
'diagnosis' => trim((string) $this->request->getPost('diagnosis')),
|
||||
'complaint_snomed_json' => trim((string) $this->request->getPost('complaint_snomed_json')),
|
||||
'diagnosis' => trim((string) $this->request->getPost('diagnosis')),
|
||||
'diagnosis_json' => trim((string) $this->request->getPost('diagnosis_json')),
|
||||
'diagnosis_snomed_id' => trim((string) $this->request->getPost('diagnosis_snomed_id')),
|
||||
'diagnosis_snomed_term' => trim((string) $this->request->getPost('diagnosis_snomed_term')),
|
||||
'diagnosis_snomed_source' => trim((string) $this->request->getPost('diagnosis_snomed_source')),
|
||||
|
|
@ -613,15 +615,8 @@ class Opd_prescription extends BaseController
|
|||
}
|
||||
}
|
||||
|
||||
// ABDM readiness is collected as non-blocking warnings; save proceeds regardless.
|
||||
$abdmValidationErrors = $this->validateAbdmReadinessForConsult($payload, $patientRow, (array) $opdRow);
|
||||
if (! empty($abdmValidationErrors)) {
|
||||
return $this->response->setJSON([
|
||||
'update' => 0,
|
||||
'error_text' => implode(' ', $abdmValidationErrors),
|
||||
'csrfName' => csrf_token(),
|
||||
'csrfHash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($payload as $value) {
|
||||
if (mb_strlen($value) > 4000) {
|
||||
|
|
@ -704,11 +699,26 @@ class Opd_prescription extends BaseController
|
|||
|
||||
$this->markOpdVisitedOnConsultCompletion((int) $opdId);
|
||||
|
||||
// Queue background SNOMED coding (fail-open — do not block save response)
|
||||
try {
|
||||
if ($this->db->tableExists('opd_coding_queue') && $recordId > 0) {
|
||||
$this->db->query(
|
||||
"INSERT INTO opd_coding_queue (opd_id, opd_session_id, status, queued_at)"
|
||||
. " VALUES (?, ?, 'pending', NOW())"
|
||||
. " ON DUPLICATE KEY UPDATE status = 'pending', queued_at = NOW()",
|
||||
[(int) $opdId, (int) $recordId]
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $queueEx) {
|
||||
log_message('error', 'SNOMED coding queue insert failed: ' . $queueEx->getMessage());
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'update' => 1,
|
||||
'opd_session_id' => $recordId,
|
||||
'error_text' => 'Prescription saved',
|
||||
'fhir_stored' => $fhirStored ? 1 : 0,
|
||||
'abdm_warnings' => $abdmValidationErrors,
|
||||
'saved_at' => date('d-m-Y H:i:s'),
|
||||
'csrfName' => csrf_token(),
|
||||
'csrfHash' => csrf_hash(),
|
||||
|
|
@ -3538,7 +3548,7 @@ class Opd_prescription extends BaseController
|
|||
}
|
||||
|
||||
$selectCols = $keyField . ' as id, ' . $codeField . ' as code, ' . $nameField . ' as name';
|
||||
foreach (['short_name', 'is_favourite', 'spec_ids', 'category_name'] as $f) {
|
||||
foreach (['short_name', 'is_favourite', 'spec_ids', 'category_name', 'snomed_concept_id', 'snomed_term', 'loinc_code'] as $f) {
|
||||
if (in_array($f, $fields, true)) {
|
||||
$selectCols .= ', ' . $f;
|
||||
}
|
||||
|
|
@ -3608,13 +3618,16 @@ class Opd_prescription extends BaseController
|
|||
return $this->response->setJSON([
|
||||
'update' => 1,
|
||||
'row' => [
|
||||
'id' => (int) ($row[$keyField] ?? 0),
|
||||
'code' => (string) ($row[$codeField ?? 'code'] ?? ''),
|
||||
'name' => (string) ($row[$nameField ?? 'name'] ?? ''),
|
||||
'short_name' => (string) ($row['short_name'] ?? ''),
|
||||
'category_name' => (string) ($row['category_name'] ?? ''),
|
||||
'is_favourite' => (int) ($row['is_favourite'] ?? 0),
|
||||
'spec_ids' => (string) ($row['spec_ids'] ?? ''),
|
||||
'id' => (int) ($row[$keyField] ?? 0),
|
||||
'code' => (string) ($row[$codeField ?? 'code'] ?? ''),
|
||||
'name' => (string) ($row[$nameField ?? 'name'] ?? ''),
|
||||
'short_name' => (string) ($row['short_name'] ?? ''),
|
||||
'category_name' => (string) ($row['category_name'] ?? ''),
|
||||
'is_favourite' => (int) ($row['is_favourite'] ?? 0),
|
||||
'spec_ids' => (string) ($row['spec_ids'] ?? ''),
|
||||
'snomed_concept_id' => (string) ($row['snomed_concept_id'] ?? ''),
|
||||
'snomed_term' => (string) ($row['snomed_term'] ?? ''),
|
||||
'loinc_code' => (string) ($row['loinc_code'] ?? ''),
|
||||
],
|
||||
'csrfName' => csrf_token(),
|
||||
'csrfHash' => csrf_hash(),
|
||||
|
|
@ -3653,7 +3666,7 @@ class Opd_prescription extends BaseController
|
|||
$nameField => $name,
|
||||
$codeField => $code,
|
||||
];
|
||||
foreach (['short_name', 'category_name', 'spec_ids'] as $f) {
|
||||
foreach (['short_name', 'category_name', 'spec_ids', 'snomed_concept_id', 'snomed_term', 'loinc_code'] as $f) {
|
||||
if (in_array($f, $fields, true)) {
|
||||
$data[$f] = trim((string) $this->request->getPost($f));
|
||||
}
|
||||
|
|
@ -3733,6 +3746,17 @@ class Opd_prescription extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
public function opd_invest_snomed_search()
|
||||
{
|
||||
$q = trim((string) $this->request->getGet('q'));
|
||||
if ($q === '' || strlen($q) < 2) {
|
||||
return $this->response->setJSON(['rows' => []]);
|
||||
}
|
||||
$svc = new \App\Libraries\CsnotkTerminologyService();
|
||||
$rows = $svc->searchProcedure($q, 20);
|
||||
return $this->response->setJSON(['rows' => $rows]);
|
||||
}
|
||||
|
||||
public function opd_invest_shortcuts_manager()
|
||||
{
|
||||
return view('billing/opd_invest_shortcuts_manager');
|
||||
|
|
@ -4301,16 +4325,31 @@ class Opd_prescription extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'status' => 'ok',
|
||||
'document_id' => (int) ($row['id'] ?? 0),
|
||||
'opd_id' => (int) ($row['opd_id'] ?? 0),
|
||||
'opd_session_id' => (int) ($row['opd_session_id'] ?? 0),
|
||||
'bundle_type' => (string) ($row['bundle_type'] ?? ''),
|
||||
'generated_at' => (string) ($row['generated_at'] ?? ''),
|
||||
'generated_by' => (string) ($row['generated_by'] ?? ''),
|
||||
'bundle' => $bundle,
|
||||
]);
|
||||
$prettyJson = (string) json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$prettyJsonJs = json_encode($prettyJson);
|
||||
$docId = (int) ($row['id'] ?? 0);
|
||||
$generatedAt = htmlspecialchars((string) ($row['generated_at'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$bundleType = htmlspecialchars((string) ($row['bundle_type'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
$entryCount = isset($bundle['entry']) && is_array($bundle['entry']) ? count($bundle['entry']) : 0;
|
||||
|
||||
$html = '<!DOCTYPE html><html lang="en"><head>'
|
||||
. '<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
. '<title>FHIR Bundle Preview — OPD #' . $opdId . '</title>'
|
||||
. '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">'
|
||||
. '<style>pre{background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:1rem;font-size:.8rem;max-height:80vh;overflow:auto}</style>'
|
||||
. '</head><body class="p-3"><div class="container-fluid">'
|
||||
. '<div class="d-flex align-items-center justify-content-between mb-3">'
|
||||
. '<h5 class="mb-0">FHIR Bundle Preview</h5>'
|
||||
. '<div><span class="badge bg-secondary me-1">' . $bundleType . '</span>'
|
||||
. '<span class="badge bg-info text-dark me-1">' . $entryCount . ' entries</span>'
|
||||
. '<span class="text-muted small">Generated: ' . $generatedAt
|
||||
. ' | Doc #' . $docId . ' | OPD #' . $opdId . ' / Session #' . $sessionId . '</span>'
|
||||
. '</div></div>'
|
||||
. '<pre id="bj"></pre>'
|
||||
. '</div><script>document.getElementById("bj").textContent=' . $prettyJsonJs . ';</script>'
|
||||
. '</body></html>';
|
||||
|
||||
return $this->response->setHeader('Content-Type', 'text/html; charset=UTF-8')->setBody($html);
|
||||
}
|
||||
|
||||
public function advice_search()
|
||||
|
|
@ -4351,70 +4390,100 @@ class Opd_prescription extends BaseController
|
|||
return $this->response->setJSON(['rows' => []]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$out = [];
|
||||
$seen = [];
|
||||
$needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
|
||||
|
||||
// 1. SNOMED CT via CSNOtk (finding / disorder semantic tags)
|
||||
$csnotkRows = (new CsnotkTerminologyService())->searchFinding($q, 20);
|
||||
foreach ($csnotkRows as $row) {
|
||||
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['term'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$conceptId = trim((string) ($row['concept_id'] ?? ''));
|
||||
$hierarchy = trim((string) ($row['hierarchy'] ?? ''));
|
||||
$k = mb_strtoupper($name) . '|' . $conceptId;
|
||||
if (isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'concept_id' => $conceptId,
|
||||
'hierarchy' => $hierarchy,
|
||||
'source' => 'snomed',
|
||||
];
|
||||
if (count($out) >= 20) {
|
||||
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Local complaints_master fallback
|
||||
if ($this->db->tableExists('complaints_master')) {
|
||||
$rows = $this->db->table('complaints_master')
|
||||
$localRows = $this->db->table('complaints_master')
|
||||
->select('Code as code, Name as name, name_hinglish, show_in_short, ai_hint')
|
||||
->where('is_active', 1)
|
||||
->groupStart()
|
||||
->like('Name', $q)
|
||||
->orLike('name_hinglish', $q)
|
||||
->orLike('keywords', $q)
|
||||
->like('Name', $q)
|
||||
->orLike('name_hinglish', $q)
|
||||
->orLike('keywords', $q)
|
||||
->groupEnd()
|
||||
->orderBy('show_in_short', 'DESC')
|
||||
->orderBy('Name', 'ASC')
|
||||
->limit(20)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($localRows as $row) {
|
||||
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['name'] ?? ''));
|
||||
if ($name === '' || ($needle !== '' && mb_stripos($name, $needle) === false)) {
|
||||
continue;
|
||||
}
|
||||
$k = mb_strtoupper($name) . '|';
|
||||
if (isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'concept_id' => '',
|
||||
'hierarchy' => '',
|
||||
'source' => 'local',
|
||||
'name_hinglish' => (string) ($row['name_hinglish'] ?? ''),
|
||||
];
|
||||
if (count($out) >= 20) {
|
||||
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User autotype keywords
|
||||
$keywordRows = $this->fetchAutotypeKeywords(
|
||||
'complaints',
|
||||
$q,
|
||||
max(0, (int) ($this->getCurrentUserId() ?? 0)),
|
||||
20
|
||||
10
|
||||
);
|
||||
|
||||
$out = [];
|
||||
$seen = [];
|
||||
$needle = mb_strtolower($this->normalizeAutocompleteSuggestionText($q));
|
||||
foreach ($rows as $row) {
|
||||
$name = $this->normalizeAutocompleteSuggestionText((string) ($row['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
if ($needle !== '' && mb_stripos($name, $needle) === false) {
|
||||
continue;
|
||||
}
|
||||
$k = mb_strtoupper($name);
|
||||
if (isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$row['name'] = $name;
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
foreach ($keywordRows as $keyword) {
|
||||
$name = $this->normalizeAutocompleteSuggestionText((string) $keyword);
|
||||
if ($name === '') {
|
||||
if ($name === '' || ($needle !== '' && mb_stripos($name, $needle) === false)) {
|
||||
continue;
|
||||
}
|
||||
if ($needle !== '' && mb_stripos($name, $needle) === false) {
|
||||
continue;
|
||||
}
|
||||
$k = mb_strtoupper($name);
|
||||
$k = mb_strtoupper($name) . '|';
|
||||
if (isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$out[] = [
|
||||
'code' => '',
|
||||
'name' => $name,
|
||||
'name_hinglish' => '',
|
||||
'show_in_short' => 0,
|
||||
'ai_hint' => '',
|
||||
'name' => $name,
|
||||
'concept_id' => '',
|
||||
'hierarchy' => '',
|
||||
'source' => 'keyword',
|
||||
];
|
||||
if (count($out) >= 20) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->response->setJSON(['rows' => array_slice($out, 0, 20)]);
|
||||
|
|
@ -4422,10 +4491,6 @@ class Opd_prescription extends BaseController
|
|||
|
||||
public function provisional_diagnosis_search()
|
||||
{
|
||||
if (! $this->canAccessPrescription()) {
|
||||
return $this->response->setStatusCode(403)->setJSON(['rows' => []]);
|
||||
}
|
||||
|
||||
$q = trim((string) $this->request->getGet('q'));
|
||||
if ($q === '') {
|
||||
return $this->response->setJSON(['rows' => []]);
|
||||
|
|
@ -9079,22 +9144,7 @@ class Opd_prescription extends BaseController
|
|||
$snomedId = trim((string) ($row['provisional_diagnosis_snomed_id'] ?? ''));
|
||||
$snomedTerm = trim((string) ($row['provisional_diagnosis_snomed_term'] ?? ''));
|
||||
|
||||
if ($diagnosisSnomedId === '' && $diagnosis !== '' && strpos($diagnosis, ',') === false) {
|
||||
$resolvedDiagnosis = $this->resolveProvisionalDiagnosisSnomedMatch($diagnosis);
|
||||
if (! empty($resolvedDiagnosis['concept_id'])) {
|
||||
$diagnosisSnomedId = (string) ($resolvedDiagnosis['concept_id'] ?? '');
|
||||
$diagnosisSnomedTerm = (string) ($resolvedDiagnosis['term'] ?? $diagnosis);
|
||||
}
|
||||
}
|
||||
|
||||
if ($snomedId === '' && $provisional !== '' && strpos($provisional, ',') === false) {
|
||||
$resolved = $this->resolveProvisionalDiagnosisSnomedMatch($provisional);
|
||||
if (! empty($resolved['concept_id'])) {
|
||||
$snomedId = (string) ($resolved['concept_id'] ?? '');
|
||||
$snomedTerm = (string) ($resolved['term'] ?? $provisional);
|
||||
}
|
||||
}
|
||||
|
||||
// Do not perform reverse SNOMED lookups here; use only pre-stored codes to keep save fast.
|
||||
$out = [];
|
||||
$seen = [];
|
||||
|
||||
|
|
@ -9158,17 +9208,7 @@ class Opd_prescription extends BaseController
|
|||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$complaint = [
|
||||
'text' => $part,
|
||||
];
|
||||
|
||||
$resolvedComplaint = $this->resolveProvisionalDiagnosisSnomedMatch($part);
|
||||
if (! empty($resolvedComplaint['code'])) {
|
||||
$complaint['snomed_code'] = (string) $resolvedComplaint['code'];
|
||||
$complaint['snomed_display'] = (string) ($resolvedComplaint['display'] ?? $part);
|
||||
}
|
||||
|
||||
$complaints[] = $complaint;
|
||||
$complaints[] = ['text' => $part];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -697,8 +697,9 @@ class Patient extends BaseController
|
|||
->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
||||
}
|
||||
|
||||
$pid = (int) $this->request->getPost('p_id');
|
||||
$abhaId = trim((string) $this->request->getPost('abha_id'));
|
||||
$pid = (int) $this->request->getPost('p_id');
|
||||
$abhaId = trim((string) $this->request->getPost('abha_id'));
|
||||
$verified = (int) $this->request->getPost('verified'); // 1 = came from OTP flow
|
||||
|
||||
if ($abhaId !== '' && ! $this->isValidAbhaId($abhaId)) {
|
||||
return $this->response->setJSON([
|
||||
|
|
@ -716,13 +717,20 @@ class Patient extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
// Set abdm_linked_at when ABHA is verified via OTP; clear when manually entered
|
||||
$pmFields = $this->db->getFieldNames('patient_master') ?? [];
|
||||
if (in_array('abdm_linked_at', $pmFields, true)) {
|
||||
$data['abdm_linked_at'] = ($verified === 1 && $abhaId !== '') ? date('Y-m-d H:i:s') : null;
|
||||
}
|
||||
|
||||
$patientModel = new PatientModel();
|
||||
$patientModel->updatePatient($data, $pid);
|
||||
$this->enqueuePatientAbhaSync($pid, $data, 'patient.abha.updated');
|
||||
|
||||
return $this->response->setJSON([
|
||||
'update' => 1,
|
||||
'showcontent' => 'Data Saved successfully',
|
||||
'update' => 1,
|
||||
'showcontent' => 'Data Saved successfully',
|
||||
'verified' => $verified === 1 && $abhaId !== '' ? 1 : 0,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Creates three tables for ABDM health-record push lifecycle:
|
||||
*
|
||||
* health_records — centralised FHIR payload store (AES-256-GCM encrypted)
|
||||
* record_links — ABDM transaction / care-context tracking
|
||||
* abdm_audit_logs — immutable audit trail (who pushed what, when, outcome)
|
||||
*/
|
||||
class CreateHealthRecordsAndAuditTables extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->createHealthRecordsTable();
|
||||
$this->createRecordLinksTable();
|
||||
$this->createAbdmAuditLogsTable();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('abdm_audit_logs', true);
|
||||
$this->forge->dropTable('record_links', true);
|
||||
$this->forge->dropTable('health_records', true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function createHealthRecordsTable(): void
|
||||
{
|
||||
if ($this->db->tableExists('health_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'BIGINT',
|
||||
'constraint' => 20,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'patient_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
'abha_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'null' => true,
|
||||
],
|
||||
// ABDM M3 HI type: OPConsultRecord | PrescriptionRecord |
|
||||
// DiagnosticReport | DischargeSummary | WellnessRecord |
|
||||
// ImmunizationRecord | HealthDocumentRecord | InvoiceRecord
|
||||
'hi_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 80,
|
||||
'null' => false,
|
||||
],
|
||||
// Source module: opd | lab | ipd | wellness | document
|
||||
'entity_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 40,
|
||||
'null' => true,
|
||||
],
|
||||
// Primary key of the source record
|
||||
'entity_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 40,
|
||||
'null' => true,
|
||||
],
|
||||
// AES-256-GCM encrypted FHIR R4 bundle JSON (base64 IV+TAG+ciphertext)
|
||||
'fhir_bundle_enc' => [
|
||||
'type' => 'LONGTEXT',
|
||||
'null' => true,
|
||||
],
|
||||
// Optional path to scanned / uploaded attachment (relative to FCPATH)
|
||||
'attachment_path' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 500,
|
||||
'null' => true,
|
||||
],
|
||||
// ABDM gateway queue_id returned after push
|
||||
'abdm_txn_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
// pending | queued | pushed | failed | linked
|
||||
'push_status' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'default' => 'pending',
|
||||
],
|
||||
'push_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'linked_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
// ABDM care-context reference returned on link callback
|
||||
'care_context_reference' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
// Consent handle used when this record was pushed
|
||||
'consent_handle' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
'created_by_user_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => true,
|
||||
],
|
||||
'created_by_name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('patient_id');
|
||||
$this->forge->addKey('abha_id');
|
||||
$this->forge->addKey('hi_type');
|
||||
$this->forge->addKey(['entity_type', 'entity_id'], false, false, 'idx_hr_entity');
|
||||
$this->forge->addKey('push_status');
|
||||
$this->forge->createTable('health_records', true);
|
||||
}
|
||||
|
||||
private function createRecordLinksTable(): void
|
||||
{
|
||||
if ($this->db->tableExists('record_links')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'BIGINT',
|
||||
'constraint' => 20,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
// FK to health_records.id
|
||||
'health_record_id' => [
|
||||
'type' => 'BIGINT',
|
||||
'constraint' => 20,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
// Queue/transaction ID from ABDM gateway
|
||||
'abdm_txn_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
// ABDM care-context reference
|
||||
'care_context_reference' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
'abha_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'null' => true,
|
||||
],
|
||||
// pending | linked | failed
|
||||
'link_status' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'default' => 'pending',
|
||||
],
|
||||
// Full JSON response from ABDM link callback
|
||||
'response_json' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'linked_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('health_record_id');
|
||||
$this->forge->addKey('abdm_txn_id');
|
||||
$this->forge->addKey('abha_id');
|
||||
$this->forge->addKey('link_status');
|
||||
$this->forge->createTable('record_links', true);
|
||||
}
|
||||
|
||||
private function createAbdmAuditLogsTable(): void
|
||||
{
|
||||
if ($this->db->tableExists('abdm_audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'BIGINT',
|
||||
'constraint' => 20,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'actor_user_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => true,
|
||||
],
|
||||
'actor_name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
// push_record | link_record | consent_request | consent_grant |
|
||||
// consent_revoke | record_linked | consent_callback | abha_validate
|
||||
'action' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 60,
|
||||
'null' => false,
|
||||
],
|
||||
// opd | lab | ipd | wellness | consent
|
||||
'entity_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 40,
|
||||
'null' => true,
|
||||
],
|
||||
'entity_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 80,
|
||||
'null' => true,
|
||||
],
|
||||
'abha_id' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'null' => true,
|
||||
],
|
||||
'patient_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => true,
|
||||
],
|
||||
// Sanitised request parameters (no PII tokens, no secrets)
|
||||
'request_json' => [
|
||||
'type' => 'LONGTEXT',
|
||||
'null' => true,
|
||||
],
|
||||
// ABDM gateway response
|
||||
'response_json' => [
|
||||
'type' => 'LONGTEXT',
|
||||
'null' => true,
|
||||
],
|
||||
// Requester IP (IPv4 or IPv6)
|
||||
'ip_address' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 45,
|
||||
'null' => true,
|
||||
],
|
||||
'user_agent' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
// success | failure
|
||||
'outcome' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'default' => 'success',
|
||||
],
|
||||
'error_message' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('actor_user_id');
|
||||
$this->forge->addKey('action');
|
||||
$this->forge->addKey('abha_id');
|
||||
$this->forge->addKey('patient_id');
|
||||
$this->forge->addKey('outcome');
|
||||
$this->forge->createTable('abdm_audit_logs', true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Adds a FULLTEXT index on snomed_description.term for fast in-DB SNOMED search.
|
||||
* This replaces the need to call the external csnotk.e-atria.in API for every search.
|
||||
* Run: php spark migrate
|
||||
*/
|
||||
class AddSnomedFulltextIndex extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->hasTable('snomed_description')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if FULLTEXT index already exists
|
||||
$existing = $this->db->query(
|
||||
"SHOW INDEX FROM snomed_description WHERE Index_type = 'FULLTEXT' AND Column_name = 'term'"
|
||||
)->getResultArray();
|
||||
|
||||
if (! empty($existing)) {
|
||||
return; // Already present
|
||||
}
|
||||
|
||||
// Add FULLTEXT index — runs as online DDL (no table lock on InnoDB MySQL 5.6+)
|
||||
$this->db->query('ALTER TABLE snomed_description ADD FULLTEXT INDEX ft_term (term)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! $this->hasTable('snomed_description')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->db->query(
|
||||
"SHOW INDEX FROM snomed_description WHERE Key_name = 'ft_term'"
|
||||
)->getResultArray();
|
||||
|
||||
if (! empty($existing)) {
|
||||
$this->db->query('ALTER TABLE snomed_description DROP INDEX ft_term');
|
||||
}
|
||||
}
|
||||
|
||||
private function hasTable(string $table): bool
|
||||
{
|
||||
return $this->db->tableExists($table);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateOpdCodingTables extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Queue: one row per OPD session to process
|
||||
if (! $this->db->tableExists('opd_coding_queue')) {
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
||||
'opd_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0],
|
||||
'opd_session_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0],
|
||||
'status' => ['type' => 'ENUM', 'constraint' => ['pending', 'processing', 'done', 'failed'], 'default' => 'pending'],
|
||||
'has_suggestions'=> ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0],
|
||||
'queued_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'processed_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'error_message' => ['type' => 'TEXT', 'null' => true],
|
||||
]);
|
||||
$this->forge->addPrimaryKey('id');
|
||||
$this->forge->addUniqueKey('opd_session_id');
|
||||
$this->forge->addKey(['status', 'queued_at']);
|
||||
$this->forge->createTable('opd_coding_queue');
|
||||
}
|
||||
|
||||
// Suggestions: AI/SNOMED suggestions per phrase per session
|
||||
if (! $this->db->tableExists('opd_snomed_suggestions')) {
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
|
||||
'opd_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0],
|
||||
'opd_session_id' => ['type' => 'INT', 'unsigned' => true, 'default' => 0],
|
||||
'source_field' => ['type' => 'VARCHAR', 'constraint' => 60, 'default' => ''],
|
||||
'source_phrase' => ['type' => 'VARCHAR', 'constraint' => 500, 'default' => ''],
|
||||
'concept_id' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''],
|
||||
'snomed_term' => ['type' => 'VARCHAR', 'constraint' => 500, 'default' => ''],
|
||||
'semantic_tag' => ['type' => 'VARCHAR', 'constraint' => 100, 'default' => ''],
|
||||
'confidence' => ['type' => 'DECIMAL', 'constraint' => '5,3', 'default' => 0],
|
||||
'status' => ['type' => 'ENUM', 'constraint' => ['pending_review', 'confirmed', 'rejected', 'corrected'], 'default' => 'pending_review'],
|
||||
'corrected_concept_id' => ['type' => 'VARCHAR', 'constraint' => 30, 'default' => ''],
|
||||
'corrected_term' => ['type' => 'VARCHAR', 'constraint' => 500, 'default' => ''],
|
||||
'reviewed_by' => ['type' => 'INT', 'null' => true],
|
||||
'reviewed_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
]);
|
||||
$this->forge->addPrimaryKey('id');
|
||||
$this->forge->addKey(['opd_session_id', 'source_field']);
|
||||
$this->forge->addKey('status');
|
||||
$this->forge->createTable('opd_snomed_suggestions');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('opd_snomed_suggestions', true);
|
||||
$this->forge->dropTable('opd_coding_queue', true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddComplaintSnomedJsonToOpdPrescription extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$fields = $this->db->getFieldNames('opd_prescription') ?? [];
|
||||
|
||||
if (! in_array('complaint_snomed_json', $fields, true)) {
|
||||
$this->forge->addColumn('opd_prescription', [
|
||||
'complaint_snomed_json' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
'default' => null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$fields = $this->db->getFieldNames('opd_prescription') ?? [];
|
||||
if (in_array('complaint_snomed_json', $fields, true)) {
|
||||
$this->forge->dropColumn('opd_prescription', 'complaint_snomed_json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddDiagnosisJsonToOpdPrescription extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$fields = [
|
||||
'diagnosis_json' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
'after' => 'diagnosis',
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->db->fieldExists('diagnosis_json', 'opd_prescription')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addColumn('opd_prescription', $fields);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->db->fieldExists('diagnosis_json', 'opd_prescription')) {
|
||||
$this->forge->dropColumn('opd_prescription', 'diagnosis_json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddSnomedToInvestigation extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->db->tableExists('investigation')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = $this->db->getFieldNames('investigation');
|
||||
$add = [];
|
||||
|
||||
if (! in_array('snomed_concept_id', $fields, true)) {
|
||||
$add['snomed_concept_id'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'default' => null];
|
||||
}
|
||||
if (! in_array('snomed_term', $fields, true)) {
|
||||
$add['snomed_term'] = ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true, 'default' => null];
|
||||
}
|
||||
if (! in_array('loinc_code', $fields, true)) {
|
||||
$add['loinc_code'] = ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true, 'default' => null];
|
||||
}
|
||||
|
||||
if (! empty($add)) {
|
||||
$this->forge->addColumn('investigation', $add);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if (! $this->db->tableExists('investigation')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = $this->db->getFieldNames('investigation');
|
||||
foreach (['snomed_concept_id', 'snomed_term', 'loinc_code'] as $col) {
|
||||
if (in_array($col, $fields, true)) {
|
||||
$this->forge->dropColumn('investigation', $col);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +168,25 @@ interface AbdmConnectorInterface
|
|||
array $fullPayload = []
|
||||
): array;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Health Records — store-and-link flow (POST /api/v3/records/push)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Push a health record to the bridge using the store-and-link flow.
|
||||
* The bridge stores the record; ABDM links it when the patient uses their PHR app
|
||||
* (user-initiated) or when HMS calls shareRecord().
|
||||
* Returns ['ok'=>1, 'queue_id'=>'REC-...', 'id'=>42, 'status'=>'pending'] on success.
|
||||
*
|
||||
* Required keys in $data:
|
||||
* patient_id, patient_name, hi_type, visit_date, record_data (FHIR bundle or raw JSON)
|
||||
* Optional:
|
||||
* abha_id, abha_address, doctor_name, department, care_context_reference, notes
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function pushRecord(array $data): array;
|
||||
|
||||
// OPD Queue
|
||||
public function opdQueueFetch(string $date = '', string $status = '', int $page = 1, int $limit = 100): array;
|
||||
public function opdTokenCreate(array $payload): array;
|
||||
|
|
|
|||
|
|
@ -182,6 +182,13 @@ class DreamsoftConnector implements AbdmConnectorInterface
|
|||
return ['ok' => 1, 'queue_id' => $queueId, 'status' => 'queued'];
|
||||
}
|
||||
|
||||
public function pushRecord(array $data): array
|
||||
{
|
||||
// Dreamsoft bridge does not support store-and-link flow.
|
||||
// Delegate to sharePrescriptionBundle as a best-effort fallback.
|
||||
return $this->sharePrescriptionBundle($data, (string) ($data['entity_id'] ?? ''));
|
||||
}
|
||||
|
||||
public function nhcxClaimStatusRequest(
|
||||
int $documentId,
|
||||
string $externalRef,
|
||||
|
|
|
|||
|
|
@ -269,6 +269,39 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
|
|||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Health Records — store-and-link flow POST /api/v3/records/push
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function pushRecord(array $data): array
|
||||
{
|
||||
// Map HMS hi_type → gateway record_type enum
|
||||
$hiType = (string) ($data['hi_type'] ?? '');
|
||||
$recordType = match (true) {
|
||||
in_array($hiType, ['OPConsultRecord', 'PrescriptionRecord', 'OPConsultation'], true) => 'prescription',
|
||||
in_array($hiType, ['DiagnosticReport', 'DiagnosticReportRecord'], true) => 'lab_report',
|
||||
in_array($hiType, ['DischargeSummary', 'DischargeSummaryRecord'], true) => 'discharge_summary',
|
||||
$hiType === 'WellnessRecord' => 'wellness_record',
|
||||
default => 'health_document',
|
||||
};
|
||||
|
||||
$body = [
|
||||
'patient_id' => (string) ($data['patient_id'] ?? ''),
|
||||
'patient_name' => (string) ($data['patient_name'] ?? ''),
|
||||
'record_type' => $recordType,
|
||||
'visit_date' => (string) ($data['visit_date'] ?? date('Y-m-d')),
|
||||
'record_data' => $data['record_data'] ?? $data['bundle'] ?? (object) [],
|
||||
];
|
||||
|
||||
foreach (['abha_id', 'abha_address', 'doctor_name', 'department', 'care_context_reference', 'notes', 'queue_id'] as $optional) {
|
||||
if (! empty($data[$optional])) {
|
||||
$body[$optional] = (string) $data[$optional];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->post('/v3/records/push', $body);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scan & Share
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
102
app/Libraries/AbdmAuditService.php
Normal file
102
app/Libraries/AbdmAuditService.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use CodeIgniter\I18n\Time;
|
||||
|
||||
/**
|
||||
* ABDM Audit Trail Service
|
||||
*
|
||||
* Records every ABDM operation (push record, link record, consent lifecycle,
|
||||
* ABHA validation, callbacks) to the abdm_audit_logs table.
|
||||
*
|
||||
* The service is FAIL-OPEN: if the DB insert fails for any reason, the
|
||||
* exception is swallowed and the calling code continues unaffected.
|
||||
*
|
||||
* Logged fields per entry:
|
||||
* actor_user_id, actor_name — from session or caller-supplied
|
||||
* action — push_record | link_record | consent_request |
|
||||
* consent_grant | consent_revoke | record_linked |
|
||||
* consent_callback | abha_validate | push_failure
|
||||
* entity_type, entity_id — source module + record PK
|
||||
* abha_id, patient_id — subject identifiers
|
||||
* request_json — sanitised outbound payload
|
||||
* response_json — ABDM gateway response
|
||||
* ip_address, user_agent — requester context
|
||||
* outcome — success | failure
|
||||
* error_message — populated on failure
|
||||
*/
|
||||
class AbdmAuditService
|
||||
{
|
||||
private \CodeIgniter\Database\ConnectionInterface $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one audit entry.
|
||||
*
|
||||
* @param array{
|
||||
* action: string,
|
||||
* entity_type?: string,
|
||||
* entity_id?: string|int,
|
||||
* abha_id?: string,
|
||||
* patient_id?: int,
|
||||
* request?: array<mixed>,
|
||||
* response?: array<mixed>,
|
||||
* outcome?: string,
|
||||
* error_message?: string,
|
||||
* actor_user_id?: int,
|
||||
* actor_name?: string,
|
||||
* } $data
|
||||
*/
|
||||
public function log(array $data): void
|
||||
{
|
||||
try {
|
||||
if (! $this->db->tableExists('abdm_audit_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$httpRequest = \Config\Services::request();
|
||||
$session = \Config\Services::session();
|
||||
|
||||
$actorUserId = (int) ($data['actor_user_id'] ?? $session->get('user_id') ?? 0);
|
||||
$actorName = trim((string) ($data['actor_name']
|
||||
?? $session->get('full_name')
|
||||
?? $session->get('name')
|
||||
?? ''));
|
||||
|
||||
// Serialise request / response to JSON; never store raw binary data
|
||||
$requestJson = isset($data['request'])
|
||||
? json_encode($data['request'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: null;
|
||||
$responseJson = isset($data['response'])
|
||||
? json_encode($data['response'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: null;
|
||||
|
||||
$entityId = (string) ($data['entity_id'] ?? '');
|
||||
$patientId = (int) ($data['patient_id'] ?? 0);
|
||||
|
||||
$this->db->table('abdm_audit_logs')->insert([
|
||||
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
|
||||
'actor_name' => $actorName !== '' ? $actorName : null,
|
||||
'action' => (string) ($data['action'] ?? 'unknown'),
|
||||
'entity_type' => (string) ($data['entity_type'] ?? ''),
|
||||
'entity_id' => $entityId !== '' ? $entityId : null,
|
||||
'abha_id' => trim((string) ($data['abha_id'] ?? '')) ?: null,
|
||||
'patient_id' => $patientId > 0 ? $patientId : null,
|
||||
'request_json' => $requestJson,
|
||||
'response_json' => $responseJson,
|
||||
'ip_address' => $httpRequest->getIPAddress(),
|
||||
'user_agent' => mb_substr((string) $httpRequest->getUserAgent(), 0, 255),
|
||||
'outcome' => (string) ($data['outcome'] ?? 'success'),
|
||||
'error_message' => trim((string) ($data['error_message'] ?? '')) ?: null,
|
||||
'created_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Fail-open: audit failure must never break the main application flow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,24 @@
|
|||
|
||||
namespace App\Libraries;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
/**
|
||||
* SNOMED CT terminology service.
|
||||
* Priority: local DB (snomed_description + snomed_concept tables).
|
||||
* Falls back to external csnotk.e-atria.in API only when local tables are absent.
|
||||
*/
|
||||
class CsnotkTerminologyService
|
||||
{
|
||||
/** SNOMED type_id for Fully Specified Names (ends with semantic tag in parentheses) */
|
||||
private const FSN_TYPE_ID = '900000000000003001';
|
||||
|
||||
private string $baseUrl;
|
||||
private int $timeoutSec;
|
||||
private bool $enabled;
|
||||
private bool $apiEnabled;
|
||||
private ?BaseConnection $db = null;
|
||||
private bool $localAvailable = false;
|
||||
private bool $hasFtIndex = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
|
@ -14,179 +27,432 @@ class CsnotkTerminologyService
|
|||
if ($baseUrl === '') {
|
||||
$baseUrl = trim((string) env('CSNOTK_BASE_URL', 'https://csnotk.e-atria.in'));
|
||||
}
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
$this->timeoutSec = max(1, (int) env('snomed.csnotk.timeoutSec', 5));
|
||||
|
||||
$enabledRaw = strtolower(trim((string) env('snomed.csnotk.enabled', env('CSNOTK_ENABLED', ''))));
|
||||
$this->enabled = in_array($enabledRaw, ['1', 'true', 'yes', 'on'], true) || $this->baseUrl !== '';
|
||||
$enabledRaw = strtolower(trim((string) env('snomed.csnotk.enabled', env('CSNOTK_ENABLED', ''))));
|
||||
$this->apiEnabled = in_array($enabledRaw, ['1', 'true', 'yes', 'on'], true) || $this->baseUrl !== '';
|
||||
|
||||
try {
|
||||
$db = db_connect();
|
||||
if ($db->tableExists('snomed_description') && $db->tableExists('snomed_concept')) {
|
||||
$this->db = $db;
|
||||
$this->localAvailable = true;
|
||||
$this->hasFtIndex = $this->checkFtIndex($db);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Local DB not available — will fall through to API
|
||||
}
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled && $this->baseUrl !== '';
|
||||
return $this->localAvailable || ($this->apiEnabled && $this->baseUrl !== '');
|
||||
}
|
||||
|
||||
// ─── Public search API ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* General diagnosis search (findings + disorders).
|
||||
* @return array<int, array{concept_id:string,term:string,source:string}>
|
||||
*/
|
||||
public function searchDiagnosis(string $query, int $limit = 20): array
|
||||
{
|
||||
$q = trim($query);
|
||||
if ($q === '' || ! $this->isEnabled()) {
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
$limit = max(1, min(50, $limit));
|
||||
|
||||
// Local DB is primary — ~17ms vs ~330ms for external API from server
|
||||
$rows = $this->localSearch($q, $limit, ['finding', 'disorder', 'morphologic abnormality', 'disease']);
|
||||
|
||||
if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
|
||||
$rows = $this->mergeFallback($rows, $this->apiSearchDiagnosis($q, $limit), $limit);
|
||||
}
|
||||
|
||||
return array_map(fn ($r) => [
|
||||
'concept_id' => (string) ($r['concept_id'] ?? ''),
|
||||
'term' => (string) ($r['term'] ?? ''),
|
||||
'source' => (string) ($r['source'] ?? 'local'),
|
||||
], array_slice($rows, 0, $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for clinical findings / symptoms / disorders (for chief complaints autocomplete).
|
||||
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
|
||||
*/
|
||||
public function searchFinding(string $query, int $limit = 20): array
|
||||
{
|
||||
$q = trim($query);
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
$limit = max(1, min(50, $limit));
|
||||
|
||||
// Local DB is primary — ~17ms vs ~330ms for external API from server
|
||||
$rows = $this->localSearch($q, $limit, ['finding', 'disorder']);
|
||||
|
||||
if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
|
||||
$rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['finding', 'disorder']), $limit);
|
||||
}
|
||||
|
||||
return array_slice($rows, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for procedures / observable entities (for investigation advice autocomplete).
|
||||
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
|
||||
*/
|
||||
public function searchProcedure(string $query, int $limit = 20): array
|
||||
{
|
||||
$q = trim($query);
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
$limit = max(1, min(50, $limit));
|
||||
|
||||
// Local DB is primary — ~17ms vs ~330ms for external API from server
|
||||
$rows = $this->localSearch($q, $limit, ['procedure', 'observable entity', 'regime/therapy']);
|
||||
|
||||
if (count($rows) < $limit && $this->apiEnabled && $this->baseUrl !== '') {
|
||||
$rows = $this->mergeFallback($rows, $this->apiSearchBySemTag($q, $limit, ['procedure', 'observable entity']), $limit);
|
||||
}
|
||||
|
||||
return array_slice($rows, 0, $limit);
|
||||
}
|
||||
|
||||
// ─── Local DB search ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search local snomed_description.
|
||||
* Uses FULLTEXT MATCH if index exists, falls back to prefix then substring LIKE.
|
||||
*
|
||||
* @param string[] $semanticTags e.g. ['finding','disorder'] — matched against FSN suffix
|
||||
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
|
||||
*/
|
||||
private function localSearch(string $q, int $limit, array $semanticTags = []): array
|
||||
{
|
||||
if (! $this->localAvailable || $this->db === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = max(1, min(50, $limit));
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
$fetchLimit = max($limit * 5, 100);
|
||||
|
||||
$search = $this->requestJson('/api/search/search', [
|
||||
'term' => $q,
|
||||
'state' => 'active',
|
||||
'acceptability' => 'all',
|
||||
'groupbyconcept' => 'true',
|
||||
'fullconcept' => 'false',
|
||||
'returnlimit' => (string) max(10, $limit * 2),
|
||||
]);
|
||||
// 1. Try FULLTEXT
|
||||
$dbRows = $this->hasFtIndex ? $this->localFtSearch($q, $fetchLimit) : [];
|
||||
|
||||
$items = $this->extractItems($search['data']);
|
||||
$validated = 0;
|
||||
$maxValidated = min(10, $limit);
|
||||
// 2. Try prefix on term_normalized (indexed)
|
||||
if (empty($dbRows)) {
|
||||
$dbRows = $this->localPrefixSearch($q, $fetchLimit);
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (count($rows) >= $limit) {
|
||||
// 3. Try substring (slower — full scan but catches mid-word)
|
||||
if (empty($dbRows)) {
|
||||
$dbRows = $this->localSubstringSearch($q, $fetchLimit);
|
||||
}
|
||||
|
||||
return $this->filterAndFormat($dbRows, $semanticTags, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* FULLTEXT MATCH ... AGAINST (Boolean Mode) search.
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function localFtSearch(string $q, int $limit): array
|
||||
{
|
||||
$words = preg_split('/\s+/', mb_strtolower(trim($q))) ?: [$q];
|
||||
$ftWords = array_filter($words, fn ($w) => mb_strlen($w) >= 3);
|
||||
|
||||
if (empty($ftWords)) {
|
||||
// FT cannot handle words shorter than innodb_ft_min_token_size (default 3)
|
||||
return $this->localPrefixSearch($q, $limit);
|
||||
}
|
||||
|
||||
$ftQuery = implode(' ', array_map(fn ($w) => '+' . $w . '*', $ftWords));
|
||||
|
||||
try {
|
||||
return (array) $this->db->query(
|
||||
"SELECT d.concept_id, d.term
|
||||
FROM snomed_description d
|
||||
INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1
|
||||
WHERE d.active = 1
|
||||
AND d.language_code = 'en'
|
||||
AND d.type_id = ?
|
||||
AND MATCH(d.term) AGAINST(? IN BOOLEAN MODE)
|
||||
ORDER BY d.term ASC
|
||||
LIMIT ?",
|
||||
[self::FSN_TYPE_ID, $ftQuery, $limit]
|
||||
)->getResultArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->hasFtIndex = false; // Index may not exist yet — disable for this request
|
||||
return $this->localPrefixSearch($q, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix search: term_normalized LIKE 'query%' (uses B-tree index).
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function localPrefixSearch(string $q, int $limit): array
|
||||
{
|
||||
$norm = mb_strtolower(trim($q));
|
||||
try {
|
||||
return (array) $this->db->query(
|
||||
"SELECT d.concept_id, d.term
|
||||
FROM snomed_description d
|
||||
INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1
|
||||
WHERE d.active = 1
|
||||
AND d.language_code = 'en'
|
||||
AND d.type_id = ?
|
||||
AND d.term_normalized LIKE ?
|
||||
ORDER BY d.term ASC
|
||||
LIMIT ?",
|
||||
[self::FSN_TYPE_ID, $norm . '%', $limit]
|
||||
)->getResultArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substring search: term_normalized LIKE '%query%' (full scan — slower).
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function localSubstringSearch(string $q, int $limit): array
|
||||
{
|
||||
$norm = mb_strtolower(trim($q));
|
||||
try {
|
||||
return (array) $this->db->query(
|
||||
"SELECT d.concept_id, d.term
|
||||
FROM snomed_description d
|
||||
INNER JOIN snomed_concept c ON c.concept_id = d.concept_id AND c.active = 1
|
||||
WHERE d.active = 1
|
||||
AND d.language_code = 'en'
|
||||
AND d.type_id = ?
|
||||
AND d.term_normalized LIKE ?
|
||||
ORDER BY d.term ASC
|
||||
LIMIT ?",
|
||||
[self::FSN_TYPE_ID, '%' . $norm . '%', $limit]
|
||||
)->getResultArray();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter raw DB rows by semantic tag and format for output.
|
||||
*
|
||||
* @param array<int, array<string, string>> $dbRows
|
||||
* @param string[] $semanticTags
|
||||
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
|
||||
*/
|
||||
private function filterAndFormat(array $dbRows, array $semanticTags, int $limit): array
|
||||
{
|
||||
$tagsLower = array_map('mb_strtolower', $semanticTags);
|
||||
$out = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($dbRows as $row) {
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']);
|
||||
$term = $this->readString($item, ['term', 'conceptFsn', 'name']);
|
||||
if (! preg_match('/^[0-9]{6,20}$/', $conceptId) || $term === '') {
|
||||
$conceptId = trim((string) ($row['concept_id'] ?? ''));
|
||||
$fsn = trim((string) ($row['term'] ?? ''));
|
||||
if ($conceptId === '' || $fsn === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($validated < $maxValidated) {
|
||||
$isValid = $this->validateConceptId($conceptId);
|
||||
$validated++;
|
||||
if ($isValid === false) {
|
||||
if (isset($seen[$conceptId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract semantic tag from FSN suffix: "Fever (finding)" → "finding"
|
||||
$hierarchy = '';
|
||||
$displayTerm = $fsn;
|
||||
if (preg_match('/^(.*)\(([^)]+)\)$/', $fsn, $m)) {
|
||||
$hierarchy = trim($m[2]);
|
||||
$displayTerm = trim($m[1]);
|
||||
}
|
||||
|
||||
// Apply semantic tag filter
|
||||
if (! empty($tagsLower)) {
|
||||
if (! in_array(mb_strtolower($hierarchy), $tagsLower, true)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$key = strtoupper($term) . '|' . $conceptId;
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$rows[] = [
|
||||
$seen[$conceptId] = true;
|
||||
$out[] = [
|
||||
'concept_id' => $conceptId,
|
||||
'term' => $term,
|
||||
'source' => 'csnotk',
|
||||
'term' => $displayTerm !== '' ? $displayTerm : $fsn,
|
||||
'fsn' => $fsn,
|
||||
'hierarchy' => $hierarchy,
|
||||
'source' => 'local',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($rows) >= $limit) {
|
||||
return $rows;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
$suggest = $this->requestJson('/api/search/suggest', [
|
||||
'term' => $q,
|
||||
'state' => 'active',
|
||||
'acceptability' => 'all',
|
||||
'returnlimit' => (string) $limit,
|
||||
// ─── External API fallbacks ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
|
||||
*/
|
||||
private function apiSearchDiagnosis(string $q, int $limit): array
|
||||
{
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
|
||||
$search = $this->requestJson('/api/search/search', [
|
||||
'term' => $q,
|
||||
'state' => 'active',
|
||||
'acceptability' => 'all',
|
||||
'groupbyconcept' => 'true',
|
||||
'fullconcept' => 'false',
|
||||
'returnlimit' => (string) max(10, $limit * 2),
|
||||
]);
|
||||
|
||||
$suggestItems = $this->extractItems($suggest['data']);
|
||||
foreach ($suggestItems as $item) {
|
||||
if (count($rows) >= $limit) {
|
||||
foreach ($this->extractItems($search['data']) as $item) {
|
||||
if (! is_array($item) || count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$term = '';
|
||||
if (is_string($item)) {
|
||||
$term = trim($item);
|
||||
} elseif (is_array($item)) {
|
||||
$term = $this->readString($item, ['term', 'name']);
|
||||
}
|
||||
if ($term === '') {
|
||||
$conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']);
|
||||
$term = $this->readString($item, ['term', 'conceptFsn', 'name']);
|
||||
if (! preg_match('/^[0-9]{6,20}$/', $conceptId) || $term === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = strtoupper($term) . '|';
|
||||
if (isset($seen[$key])) {
|
||||
if (isset($seen[$conceptId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$rows[] = [
|
||||
'concept_id' => '',
|
||||
'term' => $term,
|
||||
'source' => 'csnotk-suggest',
|
||||
];
|
||||
$seen[$conceptId] = true;
|
||||
$rows[] = ['concept_id' => $conceptId, 'term' => $term, 'fsn' => $term, 'hierarchy' => '', 'source' => 'csnotk'];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function validateConceptId(string $conceptId): ?bool
|
||||
{
|
||||
$res = $this->requestJson('/api/validate/id', ['id' => $conceptId]);
|
||||
if (! $res['ok']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $res['data'];
|
||||
if (is_array($data)) {
|
||||
if (array_key_exists('valueBoolean', $data)) {
|
||||
return filter_var($data['valueBoolean'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
|
||||
}
|
||||
if (array_key_exists('valid', $data)) {
|
||||
return filter_var($data['valid'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
|
||||
}
|
||||
$message = trim((string) ($data['valueString'] ?? $data['message'] ?? ''));
|
||||
if ($message !== '') {
|
||||
return stripos($message, ' is valid') !== false;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @return array<int, mixed>
|
||||
* @param string[] $semTags
|
||||
* @return array<int, array{concept_id:string,term:string,fsn:string,hierarchy:string,source:string}>
|
||||
*/
|
||||
private function extractItems($data): array
|
||||
private function apiSearchBySemTag(string $q, int $limit, array $semTags): array
|
||||
{
|
||||
if (! is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
|
||||
if ($this->isList($data)) {
|
||||
return $data;
|
||||
}
|
||||
foreach ($semTags as $semTag) {
|
||||
if (count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
$res = $this->requestJson('/api/search/suggest', [
|
||||
'term' => $q,
|
||||
'state' => 'active',
|
||||
'semantictag' => $semTag,
|
||||
'acceptability' => 'preferred',
|
||||
'returnlimit' => (string) max(10, $limit * 2),
|
||||
]);
|
||||
|
||||
foreach (['rows', 'results', 'data', 'items'] as $key) {
|
||||
if (isset($data[$key]) && is_array($data[$key])) {
|
||||
return $this->isList($data[$key]) ? $data[$key] : [];
|
||||
foreach ($this->extractItems($res['data']) as $item) {
|
||||
if (count($rows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
$conceptId = '';
|
||||
$term = '';
|
||||
$fsn = '';
|
||||
$hierarchy = $semTag;
|
||||
|
||||
if (is_string($item)) {
|
||||
$term = trim($item);
|
||||
} elseif (is_array($item)) {
|
||||
$conceptId = $this->readString($item, ['conceptId', 'concept_id', 'id']);
|
||||
$term = $this->readString($item, ['term', 'name']);
|
||||
$fsn = $this->readString($item, ['conceptFsn', 'fsn', 'fullySpecifiedName']);
|
||||
$hierarchy = $this->readString($item, ['hierarchy', 'semanticTag', 'semantictag']) ?: $semTag;
|
||||
$activeStatus = $item['activeStatus'] ?? $item['active_status'] ?? null;
|
||||
if ($activeStatus !== null && (int) $activeStatus !== 1) {
|
||||
continue;
|
||||
}
|
||||
if (strtolower((string) ($item['conceptState'] ?? $item['concept_state'] ?? 'active')) === 'inactive') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($term === '') {
|
||||
continue;
|
||||
}
|
||||
$displayTerm = $fsn !== '' ? $fsn : $term;
|
||||
$key = strtoupper($displayTerm) . '|' . $conceptId;
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$rows[] = [
|
||||
'concept_id' => $conceptId,
|
||||
'term' => $displayTerm,
|
||||
'fsn' => $fsn,
|
||||
'hierarchy' => $hierarchy,
|
||||
'source' => 'csnotk',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function isList(array $arr): bool
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge primary results with fallback rows, deduplicating by concept_id.
|
||||
*
|
||||
* @param array<int, array<string, string>> $primary
|
||||
* @param array<int, array<string, string>> $fallback
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function mergeFallback(array $primary, array $fallback, int $limit): array
|
||||
{
|
||||
if ($arr === []) {
|
||||
return true;
|
||||
$seen = [];
|
||||
foreach ($primary as $r) {
|
||||
$k = (string) ($r['concept_id'] ?? '');
|
||||
if ($k !== '') {
|
||||
$seen[$k] = true;
|
||||
}
|
||||
}
|
||||
foreach ($fallback as $r) {
|
||||
if (count($primary) >= $limit) {
|
||||
break;
|
||||
}
|
||||
$k = (string) ($r['concept_id'] ?? '');
|
||||
if ($k !== '' && isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
if ($k !== '') {
|
||||
$seen[$k] = true;
|
||||
}
|
||||
$primary[] = $r;
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
return $primary;
|
||||
}
|
||||
|
||||
private function checkFtIndex(BaseConnection $db): bool
|
||||
{
|
||||
try {
|
||||
$rows = $db->query(
|
||||
"SHOW INDEX FROM snomed_description WHERE Index_type = 'FULLTEXT' AND Column_name = 'term'"
|
||||
)->getResultArray();
|
||||
|
||||
return ! empty($rows);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<int, string> $keys
|
||||
* @param array<string, mixed> $row
|
||||
* @param array<int, string> $keys
|
||||
*/
|
||||
private function readString(array $row, array $keys): string
|
||||
{
|
||||
|
|
@ -204,35 +470,59 @@ class CsnotkTerminologyService
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $query
|
||||
* @param mixed $data
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function extractItems($data): array
|
||||
{
|
||||
if (! is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
if ($this->isList($data)) {
|
||||
return $data;
|
||||
}
|
||||
foreach (['rows', 'results', 'data', 'items'] as $key) {
|
||||
if (isset($data[$key]) && is_array($data[$key])) {
|
||||
return $this->isList($data[$key]) ? $data[$key] : [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function isList(array $arr): bool
|
||||
{
|
||||
return $arr === [] || array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $query
|
||||
* @return array{ok:bool,status:int,body:string,data:mixed}
|
||||
*/
|
||||
private function requestJson(string $path, array $query): array
|
||||
{
|
||||
if (! $this->isEnabled()) {
|
||||
if (! $this->apiEnabled || $this->baseUrl === '') {
|
||||
return ['ok' => false, 'status' => 0, 'body' => '', 'data' => null];
|
||||
}
|
||||
|
||||
$url = $this->baseUrl . $path;
|
||||
$qs = http_build_query($query);
|
||||
$qs = http_build_query($query);
|
||||
if ($qs !== '') {
|
||||
$url .= (strpos($url, '?') === false ? '?' : '&') . $qs;
|
||||
$url .= (str_contains($url, '?') ? '&' : '?') . $qs;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->timeoutSec,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSec,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => $this->timeoutSec,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $error !== '') {
|
||||
|
|
@ -242,10 +532,10 @@ class CsnotkTerminologyService
|
|||
$decoded = json_decode((string) $body, true);
|
||||
|
||||
return [
|
||||
'ok' => $status >= 200 && $status < 300,
|
||||
'ok' => $status >= 200 && $status < 300,
|
||||
'status' => $status,
|
||||
'body' => (string) $body,
|
||||
'data' => $decoded,
|
||||
'body' => (string) $body,
|
||||
'data' => $decoded,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
133
app/Libraries/FhirEncryptionService.php
Normal file
133
app/Libraries/FhirEncryptionService.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
/**
|
||||
* FHIR Payload Encryption Service
|
||||
*
|
||||
* Provides AES-256-GCM authenticated encryption for FHIR bundle JSON
|
||||
* payloads stored at rest in the health_records table.
|
||||
*
|
||||
* Key configuration — add to .env:
|
||||
* ABDM_FHIR_ENCRYPTION_KEY = <64-char lowercase hex string — 32 random bytes>
|
||||
*
|
||||
* Generate a key with:
|
||||
* php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
|
||||
*
|
||||
* If ABDM_FHIR_ENCRYPTION_KEY is absent the service falls back to an HMAC
|
||||
* of the application's existing encryption.key with a fixed domain label.
|
||||
* This guarantees the service always works, but a dedicated key is strongly
|
||||
* recommended for production.
|
||||
*/
|
||||
class FhirEncryptionService
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
private const IV_LEN = 12; // 96-bit IV (recommended for GCM)
|
||||
private const TAG_LEN = 16; // 128-bit authentication tag
|
||||
|
||||
private string $key;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Prefer getenv() so the value is available before CI4 bootstraps .env
|
||||
$keyHex = trim((string) getenv('ABDM_FHIR_ENCRYPTION_KEY'));
|
||||
if ($keyHex === '') {
|
||||
$keyHex = trim((string) env('ABDM_FHIR_ENCRYPTION_KEY', ''));
|
||||
}
|
||||
|
||||
if ($keyHex !== '' && ctype_xdigit($keyHex) && strlen($keyHex) >= 64) {
|
||||
// Decode exactly 32 bytes from the first 64 hex chars
|
||||
$this->key = (string) hex2bin(substr($keyHex, 0, 64));
|
||||
} else {
|
||||
// Fallback: derive a 32-byte key from the CI4 application key
|
||||
$appKey = trim((string) env('encryption.key', (string) env('app.key', '')));
|
||||
$this->key = hash_hmac('sha256', 'abdm-fhir-records-v1', $appKey, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with AES-256-GCM.
|
||||
*
|
||||
* Output format (base64-encoded): IV (12 B) | TAG (16 B) | CIPHERTEXT
|
||||
*
|
||||
* @throws \RuntimeException on OpenSSL failure
|
||||
*/
|
||||
public function encrypt(string $plaintext): string
|
||||
{
|
||||
if ($plaintext === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = random_bytes(self::IV_LEN);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$this->key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LEN
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new \RuntimeException(
|
||||
'FHIR payload encryption failed: ' . (openssl_error_string() ?: 'unknown OpenSSL error')
|
||||
);
|
||||
}
|
||||
|
||||
return base64_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a payload produced by encrypt().
|
||||
*
|
||||
* The GCM authentication tag is verified automatically; a mismatch
|
||||
* (indicating tampering or a wrong key) throws a RuntimeException.
|
||||
*
|
||||
* @throws \RuntimeException on decryption or authentication failure
|
||||
*/
|
||||
public function decrypt(string $encoded): string
|
||||
{
|
||||
if ($encoded === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = base64_decode($encoded, true);
|
||||
|
||||
if ($raw === false || strlen($raw) < self::IV_LEN + self::TAG_LEN + 1) {
|
||||
throw new \RuntimeException('Invalid encrypted FHIR payload: data is truncated or corrupt');
|
||||
}
|
||||
|
||||
$iv = substr($raw, 0, self::IV_LEN);
|
||||
$tag = substr($raw, self::IV_LEN, self::TAG_LEN);
|
||||
$ciphertext = substr($raw, self::IV_LEN + self::TAG_LEN);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$this->key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new \RuntimeException(
|
||||
'FHIR payload decryption failed: authentication tag mismatch or corrupt data'
|
||||
);
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current PHP build supports AES-256-GCM.
|
||||
*/
|
||||
public static function isSupported(): bool
|
||||
{
|
||||
return in_array(self::CIPHER, openssl_get_cipher_methods(), true);
|
||||
}
|
||||
}
|
||||
|
|
@ -719,4 +719,868 @@ class FhirR4Builder
|
|||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Enhanced FHIR Bundle Builders (ABDM M3 Compliant)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Build a full DiagnosticReport FHIR Bundle (ABDM M3 — DiagnosticReportRecord).
|
||||
*
|
||||
* Supports LOINC-coded observations, specimen metadata, Practitioner performer,
|
||||
* and an optional scanned-document attachment via DocumentReference + Binary.
|
||||
*
|
||||
* @param array<string, mixed> $patient Patient demographics
|
||||
* @param array<string, mixed> $diagnosticReport {id, title, category_loinc,
|
||||
* category_display, status, conclusion, reported_at}
|
||||
* @param array<int, array<string, mixed>> $observations Each: {test_name, loinc_code, value,
|
||||
* value_type (quantity|string), unit, ucum_code,
|
||||
* reference_range_low, reference_range_high,
|
||||
* interpretation, status}
|
||||
* @param array<string, mixed>|null $specimen {id, type_text, collection_date}
|
||||
* @param array<string, mixed>|null $attachment {content_type, data_base64, title}
|
||||
* @param array<string, mixed>|null $practitioner {id, name, registration_number}
|
||||
* @param array<string, mixed>|null $organization {id, name, hfr_id}
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildEnhancedLabReportBundle(
|
||||
array $patient,
|
||||
array $diagnosticReport,
|
||||
array $observations = [],
|
||||
?array $specimen = null,
|
||||
?array $attachment = null,
|
||||
?array $practitioner = null,
|
||||
?array $organization = null
|
||||
): array {
|
||||
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
||||
$reportId = 'lab-rpt-' . (string) ($diagnosticReport['id'] ?? date('YmdHis'));
|
||||
|
||||
$entries = [['resource' => $this->buildPatientResource($patient)]];
|
||||
|
||||
$practitionerRef = '';
|
||||
$organizationRef = '';
|
||||
|
||||
if ($practitioner !== null) {
|
||||
$practId = trim((string) ($practitioner['id'] ?? ''));
|
||||
$practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : '';
|
||||
if ($practitionerRef !== '') {
|
||||
$practRes = [
|
||||
'resourceType' => 'Practitioner',
|
||||
'id' => $practId,
|
||||
'name' => [['text' => trim((string) ($practitioner['name'] ?? ''))]],
|
||||
];
|
||||
$regNo = trim((string) ($practitioner['registration_number'] ?? ''));
|
||||
if ($regNo !== '') {
|
||||
$practRes['identifier'] = [[
|
||||
'system' => 'https://hpr.abdm.gov.in/hpr-id',
|
||||
'value' => $regNo,
|
||||
]];
|
||||
}
|
||||
$entries[] = ['resource' => $practRes];
|
||||
}
|
||||
}
|
||||
|
||||
if ($organization !== null) {
|
||||
$orgId = trim((string) ($organization['id'] ?? ''));
|
||||
$organizationRef = $orgId !== '' ? ('Organization/' . $orgId) : '';
|
||||
if ($organizationRef !== '') {
|
||||
$orgRes = [
|
||||
'resourceType' => 'Organization',
|
||||
'id' => $orgId,
|
||||
'name' => trim((string) ($organization['name'] ?? '')),
|
||||
];
|
||||
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
||||
if ($hfrId !== '') {
|
||||
$orgRes['identifier'] = [[
|
||||
'system' => 'https://facility.abdm.gov.in/hfr',
|
||||
'value' => $hfrId,
|
||||
]];
|
||||
}
|
||||
$entries[] = ['resource' => $orgRes];
|
||||
}
|
||||
}
|
||||
|
||||
// Specimen (optional)
|
||||
$specimenRef = '';
|
||||
if ($specimen !== null) {
|
||||
$specimenId = 'specimen-' . (string) ($specimen['id'] ?? '1');
|
||||
$specimenRef = 'Specimen/' . $specimenId;
|
||||
$specimenRes = [
|
||||
'resourceType' => 'Specimen',
|
||||
'id' => $specimenId,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'type' => ['text' => (string) ($specimen['type_text'] ?? 'Blood')],
|
||||
];
|
||||
$collDate = trim((string) ($specimen['collection_date'] ?? ''));
|
||||
if ($collDate !== '') {
|
||||
$specimenRes['collection'] = ['collectedDateTime' => $collDate];
|
||||
}
|
||||
$entries[] = ['resource' => $specimenRes];
|
||||
}
|
||||
|
||||
// Observations
|
||||
$observationRefs = [];
|
||||
foreach ($observations as $index => $obs) {
|
||||
$obsId = 'obs-' . ($index + 1);
|
||||
$observationRefs[] = ['reference' => 'Observation/' . $obsId];
|
||||
|
||||
$loincCode = trim((string) ($obs['loinc_code'] ?? ''));
|
||||
$testName = (string) ($obs['test_name'] ?? '');
|
||||
$valueType = trim((string) ($obs['value_type'] ?? 'string'));
|
||||
$rawValue = $obs['value'] ?? '';
|
||||
$unit = (string) ($obs['unit'] ?? '');
|
||||
$ucumCode = (string) ($obs['ucum_code'] ?? $unit);
|
||||
$interpText = (string) ($obs['interpretation'] ?? '');
|
||||
|
||||
$code = ['text' => $testName];
|
||||
if ($loincCode !== '') {
|
||||
$code['coding'] = [[
|
||||
'system' => 'http://loinc.org',
|
||||
'code' => $loincCode,
|
||||
'display' => $testName,
|
||||
]];
|
||||
}
|
||||
|
||||
$obsResource = [
|
||||
'resourceType' => 'Observation',
|
||||
'id' => $obsId,
|
||||
'status' => (string) ($obs['status'] ?? 'final'),
|
||||
'category' => [[
|
||||
'coding' => [[
|
||||
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
|
||||
'code' => 'laboratory',
|
||||
'display' => 'Laboratory',
|
||||
]],
|
||||
]],
|
||||
'code' => $code,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'issued' => $issuedAt,
|
||||
];
|
||||
|
||||
if ($specimenRef !== '') {
|
||||
$obsResource['specimen'] = ['reference' => $specimenRef];
|
||||
}
|
||||
if ($practitionerRef !== '') {
|
||||
$obsResource['performer'] = [['reference' => $practitionerRef]];
|
||||
}
|
||||
|
||||
if ($valueType === 'quantity' && is_numeric($rawValue)) {
|
||||
$obsResource['valueQuantity'] = [
|
||||
'value' => (float) $rawValue,
|
||||
'unit' => $unit,
|
||||
'system' => 'http://unitsofmeasure.org',
|
||||
'code' => $ucumCode !== '' ? $ucumCode : $unit,
|
||||
];
|
||||
} else {
|
||||
$obsResource['valueString'] = (string) $rawValue;
|
||||
}
|
||||
|
||||
$refLow = $obs['reference_range_low'] ?? null;
|
||||
$refHigh = $obs['reference_range_high'] ?? null;
|
||||
if ($refLow !== null || $refHigh !== null) {
|
||||
$refRange = [];
|
||||
if (is_numeric($refLow)) { $refRange['low'] = ['value' => (float) $refLow, 'unit' => $unit]; }
|
||||
if (is_numeric($refHigh)) { $refRange['high'] = ['value' => (float) $refHigh, 'unit' => $unit]; }
|
||||
if (! empty($refRange)) {
|
||||
$obsResource['referenceRange'] = [$refRange];
|
||||
}
|
||||
}
|
||||
|
||||
if ($interpText !== '') {
|
||||
$obsResource['interpretation'] = [['text' => $interpText]];
|
||||
}
|
||||
|
||||
$entries[] = ['resource' => $obsResource];
|
||||
}
|
||||
|
||||
// Optional scanned attachment
|
||||
$docRefRef = '';
|
||||
if ($attachment !== null && ! empty($attachment['data_base64'])) {
|
||||
$docEntries = $this->buildDocumentReferenceEntry(
|
||||
'doc-lab-' . $reportId,
|
||||
(string) ($attachment['title'] ?? 'Lab Report Document'),
|
||||
(string) ($attachment['content_type'] ?? 'application/pdf'),
|
||||
(string) $attachment['data_base64'],
|
||||
$patientRef,
|
||||
$issuedAt
|
||||
);
|
||||
$docRefRef = 'DocumentReference/doc-lab-' . $reportId;
|
||||
foreach ($docEntries as $entry) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$performers = [];
|
||||
if ($practitionerRef !== '') { $performers[] = ['reference' => $practitionerRef]; }
|
||||
if ($organizationRef !== '') { $performers[] = ['reference' => $organizationRef]; }
|
||||
|
||||
$categoryLoinc = trim((string) ($diagnosticReport['category_loinc'] ?? '26436-6'));
|
||||
$categoryDisplay = trim((string) ($diagnosticReport['category_display'] ?? 'Laboratory studies (procedure)'));
|
||||
|
||||
$drResource = [
|
||||
'resourceType' => 'DiagnosticReport',
|
||||
'id' => $reportId,
|
||||
'status' => (string) ($diagnosticReport['status'] ?? 'final'),
|
||||
'category' => [[
|
||||
'coding' => [[
|
||||
'system' => 'http://loinc.org',
|
||||
'code' => $categoryLoinc,
|
||||
'display' => $categoryDisplay,
|
||||
]],
|
||||
]],
|
||||
'code' => ['text' => (string) ($diagnosticReport['title'] ?? 'Laboratory Report')],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'issued' => (string) ($diagnosticReport['reported_at'] ?? $issuedAt),
|
||||
'result' => $observationRefs,
|
||||
'conclusion' => (string) ($diagnosticReport['conclusion'] ?? ''),
|
||||
];
|
||||
|
||||
if ($specimenRef !== '') {
|
||||
$drResource['specimen'] = [['reference' => $specimenRef]];
|
||||
}
|
||||
if (! empty($performers)) {
|
||||
$drResource['performer'] = $performers;
|
||||
}
|
||||
if ($docRefRef !== '') {
|
||||
$drResource['presentedForm'] = [[
|
||||
'contentType' => (string) ($attachment['content_type'] ?? 'application/pdf'),
|
||||
'title' => (string) ($attachment['title'] ?? 'Lab Report'),
|
||||
]];
|
||||
}
|
||||
$entries[] = ['resource' => $drResource];
|
||||
|
||||
// Composition (LOINC 11502-2 = Laboratory report)
|
||||
$compositionSections = [
|
||||
['title' => 'Lab Results', 'entry' => $observationRefs],
|
||||
];
|
||||
if ($docRefRef !== '') {
|
||||
$compositionSections[] = [
|
||||
'title' => 'Report Document',
|
||||
'entry' => [['reference' => $docRefRef]],
|
||||
];
|
||||
}
|
||||
|
||||
$composition = [
|
||||
'resourceType' => 'Composition',
|
||||
'id' => 'comp-' . $reportId,
|
||||
'status' => 'final',
|
||||
'type' => [
|
||||
'coding' => [[
|
||||
'system' => 'http://loinc.org',
|
||||
'code' => '11502-2',
|
||||
'display' => 'Laboratory report',
|
||||
]],
|
||||
'text' => 'Diagnostic Report - Lab',
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'date' => $issuedAt,
|
||||
'title' => (string) ($diagnosticReport['title'] ?? 'Laboratory Report'),
|
||||
'section' => $compositionSections,
|
||||
];
|
||||
if ($practitionerRef !== '') {
|
||||
$composition['author'] = [['reference' => $practitionerRef]];
|
||||
}
|
||||
$entries[] = ['resource' => $composition];
|
||||
|
||||
return [
|
||||
'resourceType' => 'Bundle',
|
||||
'identifier' => [
|
||||
'system' => 'urn:ietf:rfc:3986',
|
||||
'value' => 'urn:uuid:bundle-lab-' . $reportId . '-' . date('YmdHis'),
|
||||
],
|
||||
'type' => 'document',
|
||||
'timestamp' => $issuedAt,
|
||||
'entry' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a full Discharge Summary FHIR Bundle (ABDM M3 — DischargeSummaryRecord).
|
||||
*
|
||||
* Covers all mandatory composition sections: chief complaints, diagnosis,
|
||||
* allergies, procedures, medications, investigations, care plan, and a
|
||||
* free-text clinical notes narrative.
|
||||
*
|
||||
* @param array<string, mixed> $patient Patient demographics
|
||||
* @param array<string, mixed> $practitioner {id, name, registration_number}
|
||||
* @param array<string, mixed> $organization {id, name, hfr_id}
|
||||
* @param array<string, mixed> $encounter {id, admission_date, discharge_date}
|
||||
* @param array<string, mixed> $summary {
|
||||
* title, chief_complaints[], conditions[], allergies[],
|
||||
* procedures[], medications[], investigations[], care_plan,
|
||||
* follow_up_date, clinical_notes_html
|
||||
* }
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildEnhancedDischargeSummaryBundle(
|
||||
array $patient,
|
||||
array $practitioner,
|
||||
array $organization,
|
||||
array $encounter,
|
||||
array $summary
|
||||
): array {
|
||||
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
$encounterId = (string) ($encounter['id'] ?? 'enc-' . date('YmdHis'));
|
||||
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
||||
$encounterRef = 'Encounter/' . $encounterId;
|
||||
|
||||
$practId = trim((string) ($practitioner['id'] ?? ''));
|
||||
$orgId = trim((string) ($organization['id'] ?? ''));
|
||||
$practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : '';
|
||||
$organizationRef = $orgId !== '' ? ('Organization/' . $orgId) : '';
|
||||
|
||||
$entries = [['resource' => $this->buildPatientResource($patient)]];
|
||||
|
||||
// Practitioner
|
||||
if ($practitionerRef !== '') {
|
||||
$practRes = [
|
||||
'resourceType' => 'Practitioner',
|
||||
'id' => $practId,
|
||||
'name' => [['text' => (string) ($practitioner['name'] ?? '')]],
|
||||
];
|
||||
$regNo = trim((string) ($practitioner['registration_number'] ?? ''));
|
||||
if ($regNo !== '') {
|
||||
$practRes['identifier'] = [[
|
||||
'system' => 'https://hpr.abdm.gov.in/hpr-id',
|
||||
'value' => $regNo,
|
||||
]];
|
||||
}
|
||||
$entries[] = ['resource' => $practRes];
|
||||
}
|
||||
|
||||
// Organization
|
||||
if ($organizationRef !== '') {
|
||||
$orgRes = [
|
||||
'resourceType' => 'Organization',
|
||||
'id' => $orgId,
|
||||
'name' => (string) ($organization['name'] ?? ''),
|
||||
];
|
||||
$hfrId = trim((string) ($organization['hfr_id'] ?? ''));
|
||||
if ($hfrId !== '') {
|
||||
$orgRes['identifier'] = [[
|
||||
'system' => 'https://facility.abdm.gov.in/hfr',
|
||||
'value' => $hfrId,
|
||||
]];
|
||||
}
|
||||
$entries[] = ['resource' => $orgRes];
|
||||
}
|
||||
|
||||
// Encounter (inpatient)
|
||||
$encounterResource = [
|
||||
'resourceType' => 'Encounter',
|
||||
'id' => $encounterId,
|
||||
'status' => 'finished',
|
||||
'class' => [
|
||||
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
|
||||
'code' => 'IMP',
|
||||
'display' => 'inpatient encounter',
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'period' => [
|
||||
'start' => (string) ($encounter['admission_date'] ?? $encounter['period_start'] ?? $issuedAt),
|
||||
'end' => (string) ($encounter['discharge_date'] ?? $encounter['period_end'] ?? $issuedAt),
|
||||
],
|
||||
];
|
||||
if ($practitionerRef !== '') {
|
||||
$encounterResource['participant'] = [['individual' => ['reference' => $practitionerRef]]];
|
||||
}
|
||||
if ($organizationRef !== '') {
|
||||
$encounterResource['serviceProvider'] = ['reference' => $organizationRef];
|
||||
}
|
||||
$entries[] = ['resource' => $encounterResource];
|
||||
|
||||
$sections = [];
|
||||
|
||||
// --- Chief Complaints ---
|
||||
$complaintRefs = [];
|
||||
foreach ((array) ($summary['chief_complaints'] ?? []) as $idx => $complaint) {
|
||||
$text = is_array($complaint)
|
||||
? trim((string) ($complaint['text'] ?? ''))
|
||||
: trim((string) $complaint);
|
||||
if ($text === '') { continue; }
|
||||
|
||||
$condId = 'cc-' . ($idx + 1);
|
||||
$code = ['text' => $text];
|
||||
$snomedCode = is_array($complaint) ? trim((string) ($complaint['snomed_code'] ?? '')) : '';
|
||||
if ($snomedCode !== '') {
|
||||
$code['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $text]];
|
||||
}
|
||||
|
||||
$complaintRefs[] = ['reference' => 'Condition/' . $condId];
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'Condition',
|
||||
'id' => $condId,
|
||||
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active']]],
|
||||
'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'problem-list-item']], 'text' => 'Chief Complaint']],
|
||||
'code' => $code,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
]];
|
||||
}
|
||||
if (! empty($complaintRefs)) {
|
||||
$sections[] = ['title' => 'Chief Complaints', 'entry' => $complaintRefs];
|
||||
}
|
||||
|
||||
// --- Diagnosis / Conditions ---
|
||||
$conditionRefs = [];
|
||||
$encounterDiagnosis = [];
|
||||
foreach ((array) ($summary['conditions'] ?? []) as $idx => $cond) {
|
||||
$text = is_array($cond) ? trim((string) ($cond['text'] ?? '')) : trim((string) $cond);
|
||||
if ($text === '') { continue; }
|
||||
|
||||
$condId = 'diag-' . ($idx + 1);
|
||||
$code = ['text' => $text];
|
||||
$snomedCode = is_array($cond) ? trim((string) ($cond['snomed_code'] ?? '')) : '';
|
||||
if ($snomedCode !== '') {
|
||||
$code['coding'] = [[
|
||||
'system' => 'http://snomed.info/sct',
|
||||
'code' => $snomedCode,
|
||||
'display' => is_array($cond) ? (string) ($cond['snomed_display'] ?? $text) : $text,
|
||||
]];
|
||||
}
|
||||
|
||||
$verStatus = is_array($cond) && strtolower((string) ($cond['verification_status'] ?? '')) === 'confirmed'
|
||||
? 'confirmed'
|
||||
: 'provisional';
|
||||
|
||||
$conditionRefs[] = ['reference' => 'Condition/' . $condId];
|
||||
$encounterDiagnosis[] = ['condition' => ['reference' => 'Condition/' . $condId]];
|
||||
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'Condition',
|
||||
'id' => $condId,
|
||||
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active']]],
|
||||
'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', 'code' => $verStatus]]],
|
||||
'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'encounter-diagnosis']]]],
|
||||
'code' => $code,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
]];
|
||||
}
|
||||
if (! empty($conditionRefs)) {
|
||||
$sections[] = ['title' => 'Diagnosis', 'entry' => $conditionRefs];
|
||||
// Back-patch encounter diagnosis references
|
||||
$encounterResource['diagnosis'] = $encounterDiagnosis;
|
||||
}
|
||||
|
||||
// --- Allergies ---
|
||||
$allergyRefs = [];
|
||||
foreach ((array) ($summary['allergies'] ?? []) as $idx => $allergy) {
|
||||
$codeText = is_array($allergy)
|
||||
? trim((string) ($allergy['code_text'] ?? ''))
|
||||
: trim((string) $allergy);
|
||||
if ($codeText === '') { continue; }
|
||||
|
||||
$allergyId = 'allergy-ds-' . ($idx + 1);
|
||||
$allergyRefs[] = ['reference' => 'AllergyIntolerance/' . $allergyId];
|
||||
$allergyRes = [
|
||||
'resourceType' => 'AllergyIntolerance',
|
||||
'id' => $allergyId,
|
||||
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical', 'code' => 'active']]],
|
||||
'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification', 'code' => 'confirmed']]],
|
||||
'patient' => ['reference' => $patientRef],
|
||||
'code' => ['text' => $codeText],
|
||||
];
|
||||
$reaction = is_array($allergy) ? trim((string) ($allergy['reaction_text'] ?? '')) : '';
|
||||
if ($reaction !== '') {
|
||||
$allergyRes['reaction'] = [['description' => $reaction]];
|
||||
}
|
||||
$entries[] = ['resource' => $allergyRes];
|
||||
}
|
||||
if (! empty($allergyRefs)) {
|
||||
$sections[] = ['title' => 'Allergies', 'entry' => $allergyRefs];
|
||||
}
|
||||
|
||||
// --- Procedures ---
|
||||
$procedureRefs = [];
|
||||
foreach ((array) ($summary['procedures'] ?? []) as $idx => $procedure) {
|
||||
$text = is_array($procedure)
|
||||
? trim((string) ($procedure['text'] ?? ''))
|
||||
: trim((string) $procedure);
|
||||
if ($text === '') { continue; }
|
||||
|
||||
$procId = 'proc-' . ($idx + 1);
|
||||
$code = ['text' => $text];
|
||||
$snomedCode = is_array($procedure) ? trim((string) ($procedure['snomed_code'] ?? '')) : '';
|
||||
if ($snomedCode !== '') {
|
||||
$code['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $text]];
|
||||
}
|
||||
|
||||
$procedureRefs[] = ['reference' => 'Procedure/' . $procId];
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'Procedure',
|
||||
'id' => $procId,
|
||||
'status' => is_array($procedure) ? (string) ($procedure['status'] ?? 'completed') : 'completed',
|
||||
'code' => $code,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
]];
|
||||
}
|
||||
if (! empty($procedureRefs)) {
|
||||
$sections[] = ['title' => 'Procedures', 'entry' => $procedureRefs];
|
||||
}
|
||||
|
||||
// --- Medications ---
|
||||
$medicationRefs = [];
|
||||
foreach ((array) ($summary['medications'] ?? []) as $idx => $med) {
|
||||
$drugName = is_array($med) ? trim((string) ($med['drug_name'] ?? '')) : trim((string) $med);
|
||||
if ($drugName === '') { continue; }
|
||||
|
||||
$medId = 'medreq-ds-' . ($idx + 1);
|
||||
$mc = ['text' => $drugName];
|
||||
$snomedCode = is_array($med) ? trim((string) ($med['snomed_code'] ?? '')) : '';
|
||||
$atcCode = is_array($med) ? strtoupper(trim((string) ($med['atc_code'] ?? ''))) : '';
|
||||
if ($snomedCode !== '') {
|
||||
$mc['coding'] = [['system' => 'http://snomed.info/sct', 'code' => $snomedCode, 'display' => $drugName]];
|
||||
} elseif ($atcCode !== '') {
|
||||
$mc['coding'] = [['system' => 'http://www.whocc.no/atc', 'code' => $atcCode, 'display' => $drugName]];
|
||||
}
|
||||
|
||||
$medicationRefs[] = ['reference' => 'MedicationRequest/' . $medId];
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'MedicationRequest',
|
||||
'id' => $medId,
|
||||
'status' => 'active',
|
||||
'intent' => 'order',
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
'medicationCodeableConcept' => $mc,
|
||||
'dosageInstruction' => [['text' => is_array($med) ? (string) ($med['dosage'] ?? '') : '']],
|
||||
]];
|
||||
}
|
||||
if (! empty($medicationRefs)) {
|
||||
$sections[] = ['title' => 'Medications', 'entry' => $medicationRefs];
|
||||
}
|
||||
|
||||
// --- Investigations ---
|
||||
$investigationRefs = [];
|
||||
foreach ((array) ($summary['investigations'] ?? []) as $idx => $inv) {
|
||||
$text = is_array($inv) ? trim((string) ($inv['text'] ?? '')) : trim((string) $inv);
|
||||
if ($text === '') { continue; }
|
||||
|
||||
$svcId = 'svc-ds-' . ($idx + 1);
|
||||
$investigationRefs[] = ['reference' => 'ServiceRequest/' . $svcId];
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'ServiceRequest',
|
||||
'id' => $svcId,
|
||||
'status' => 'completed',
|
||||
'intent' => 'order',
|
||||
'code' => ['text' => $text],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
]];
|
||||
}
|
||||
if (! empty($investigationRefs)) {
|
||||
$sections[] = ['title' => 'Investigations', 'entry' => $investigationRefs];
|
||||
}
|
||||
|
||||
// --- Care Plan ---
|
||||
$carePlanRefs = [];
|
||||
$carePlanText = trim((string) ($summary['care_plan'] ?? ''));
|
||||
if ($carePlanText !== '') {
|
||||
$cpId = 'careplan-1';
|
||||
$carePlanRefs[] = ['reference' => 'CarePlan/' . $cpId];
|
||||
$cpResource = [
|
||||
'resourceType' => 'CarePlan',
|
||||
'id' => $cpId,
|
||||
'status' => 'active',
|
||||
'intent' => 'plan',
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
'description' => $carePlanText,
|
||||
];
|
||||
$followUpDate = trim((string) ($summary['follow_up_date'] ?? ''));
|
||||
if ($followUpDate !== '') {
|
||||
$cpResource['period'] = ['start' => $issuedAt, 'end' => $followUpDate];
|
||||
}
|
||||
$entries[] = ['resource' => $cpResource];
|
||||
$sections[] = ['title' => 'Care Plan', 'entry' => $carePlanRefs];
|
||||
}
|
||||
|
||||
// Clinical notes narrative (unstructured)
|
||||
$clinicalNotesHtml = (string) ($summary['clinical_notes_html'] ?? '');
|
||||
|
||||
// Composition (LOINC 18842-5 = Discharge summary)
|
||||
$composition = [
|
||||
'resourceType' => 'Composition',
|
||||
'id' => 'comp-discharge-' . $encounterId,
|
||||
'status' => 'final',
|
||||
'type' => [
|
||||
'coding' => [[
|
||||
'system' => 'http://loinc.org',
|
||||
'code' => '18842-5',
|
||||
'display' => 'Discharge summary',
|
||||
]],
|
||||
'text' => 'Discharge Summary',
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
'date' => $issuedAt,
|
||||
'title' => (string) ($summary['title'] ?? 'Discharge Summary'),
|
||||
'section' => $sections,
|
||||
];
|
||||
if ($practitionerRef !== '') {
|
||||
$composition['author'] = [['reference' => $practitionerRef]];
|
||||
}
|
||||
if ($clinicalNotesHtml !== '') {
|
||||
$composition['section'][] = [
|
||||
'title' => 'Clinical Summary Notes',
|
||||
'text' => ['status' => 'generated', 'div' => $clinicalNotesHtml],
|
||||
];
|
||||
}
|
||||
$entries[] = ['resource' => $composition];
|
||||
|
||||
return [
|
||||
'resourceType' => 'Bundle',
|
||||
'identifier' => [
|
||||
'system' => 'urn:ietf:rfc:3986',
|
||||
'value' => 'urn:uuid:bundle-discharge-' . $encounterId . '-' . date('YmdHis'),
|
||||
],
|
||||
'type' => 'document',
|
||||
'timestamp' => $issuedAt,
|
||||
'entry' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a WellnessRecord FHIR Bundle (ABDM M3 — WellnessRecord).
|
||||
*
|
||||
* Contains vital-sign / body-measurement Observations and
|
||||
* social-history lifestyle Observations.
|
||||
*
|
||||
* @param array<string, mixed> $patient Patient demographics
|
||||
* @param array<int, array<string, mixed>> $vitals Each: {loinc_code, display, value,
|
||||
* value_type (quantity|string), unit, ucum_code}
|
||||
* @param array<int, array<string, mixed>> $lifestyle Each: {code, display, value, system}
|
||||
* @param array<string, mixed>|null $practitioner {id, name}
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildWellnessBundle(
|
||||
array $patient,
|
||||
array $vitals = [],
|
||||
array $lifestyle = [],
|
||||
?array $practitioner = null
|
||||
): array {
|
||||
$issuedAt = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
$patientRef = 'Patient/' . (string) ($patient['id'] ?? 'unknown');
|
||||
$entries = [['resource' => $this->buildPatientResource($patient)]];
|
||||
|
||||
$practitionerRef = '';
|
||||
if ($practitioner !== null) {
|
||||
$practId = trim((string) ($practitioner['id'] ?? ''));
|
||||
$practitionerRef = $practId !== '' ? ('Practitioner/' . $practId) : '';
|
||||
if ($practitionerRef !== '') {
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'Practitioner',
|
||||
'id' => $practId,
|
||||
'name' => [['text' => (string) ($practitioner['name'] ?? '')]],
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
$observationRefs = [];
|
||||
|
||||
// Vital signs / body measurements
|
||||
foreach ($vitals as $idx => $vital) {
|
||||
$loincCode = trim((string) ($vital['loinc_code'] ?? ''));
|
||||
$display = (string) ($vital['display'] ?? '');
|
||||
$value = $vital['value'] ?? '';
|
||||
$unit = (string) ($vital['unit'] ?? '');
|
||||
$ucumCode = (string) ($vital['ucum_code'] ?? $unit);
|
||||
$valueType = (string) ($vital['value_type'] ?? 'quantity');
|
||||
|
||||
if ($valueType === 'quantity' && ! is_numeric($value)) { continue; }
|
||||
if (trim((string) $value) === '') { continue; }
|
||||
|
||||
$obsId = 'wellness-obs-' . ($idx + 1);
|
||||
$observationRefs[] = ['reference' => 'Observation/' . $obsId];
|
||||
|
||||
$code = ['text' => $display];
|
||||
if ($loincCode !== '') {
|
||||
$code['coding'] = [['system' => 'http://loinc.org', 'code' => $loincCode, 'display' => $display]];
|
||||
}
|
||||
|
||||
$obsResource = [
|
||||
'resourceType' => 'Observation',
|
||||
'id' => $obsId,
|
||||
'status' => 'final',
|
||||
'category' => [[
|
||||
'coding' => [[
|
||||
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
|
||||
'code' => 'vital-signs',
|
||||
'display' => 'Vital Signs',
|
||||
]],
|
||||
]],
|
||||
'code' => $code,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'effectiveDateTime' => $issuedAt,
|
||||
];
|
||||
|
||||
if ($practitionerRef !== '') {
|
||||
$obsResource['performer'] = [['reference' => $practitionerRef]];
|
||||
}
|
||||
|
||||
if ($valueType === 'quantity' && is_numeric($value)) {
|
||||
$obsResource['valueQuantity'] = [
|
||||
'value' => (float) $value,
|
||||
'unit' => $unit,
|
||||
'system' => 'http://unitsofmeasure.org',
|
||||
'code' => $ucumCode,
|
||||
];
|
||||
} else {
|
||||
$obsResource['valueString'] = (string) $value;
|
||||
}
|
||||
|
||||
$entries[] = ['resource' => $obsResource];
|
||||
}
|
||||
|
||||
// Lifestyle / social-history observations
|
||||
foreach ($lifestyle as $idx => $ls) {
|
||||
$code = trim((string) ($ls['code'] ?? ''));
|
||||
$display = (string) ($ls['display'] ?? '');
|
||||
$value = (string) ($ls['value'] ?? '');
|
||||
$system = (string) ($ls['system'] ?? 'http://snomed.info/sct');
|
||||
|
||||
if ($value === '' || $display === '') { continue; }
|
||||
|
||||
$obsId = 'wellness-ls-' . ($idx + 1);
|
||||
$observationRefs[] = ['reference' => 'Observation/' . $obsId];
|
||||
|
||||
$obsCode = ['text' => $display];
|
||||
if ($code !== '') {
|
||||
$obsCode['coding'] = [['system' => $system, 'code' => $code, 'display' => $display]];
|
||||
}
|
||||
|
||||
$entries[] = ['resource' => [
|
||||
'resourceType' => 'Observation',
|
||||
'id' => $obsId,
|
||||
'status' => 'final',
|
||||
'category' => [[
|
||||
'coding' => [[
|
||||
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
|
||||
'code' => 'social-history',
|
||||
'display' => 'Social History',
|
||||
]],
|
||||
]],
|
||||
'code' => $obsCode,
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'effectiveDateTime' => $issuedAt,
|
||||
'valueString' => $value,
|
||||
]];
|
||||
}
|
||||
|
||||
$composition = [
|
||||
'resourceType' => 'Composition',
|
||||
'id' => 'comp-wellness-' . date('YmdHis'),
|
||||
'status' => 'final',
|
||||
'type' => [
|
||||
'coding' => [[
|
||||
'system' => 'http://snomed.info/sct',
|
||||
'code' => '371529009',
|
||||
'display' => 'Health maintenance report (record artifact)',
|
||||
]],
|
||||
'text' => 'Wellness Record',
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'date' => $issuedAt,
|
||||
'title' => 'Wellness Record',
|
||||
'section' => [
|
||||
['title' => 'Vital Signs and Body Measurements', 'entry' => $observationRefs],
|
||||
],
|
||||
];
|
||||
if ($practitionerRef !== '') {
|
||||
$composition['author'] = [['reference' => $practitionerRef]];
|
||||
}
|
||||
$entries[] = ['resource' => $composition];
|
||||
|
||||
return [
|
||||
'resourceType' => 'Bundle',
|
||||
'identifier' => [
|
||||
'system' => 'urn:ietf:rfc:3986',
|
||||
'value' => 'urn:uuid:bundle-wellness-' . date('YmdHis'),
|
||||
],
|
||||
'type' => 'document',
|
||||
'timestamp' => $issuedAt,
|
||||
'entry' => $entries,
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build DocumentReference + Binary resource entries for a scanned attachment.
|
||||
*
|
||||
* Usage: append the returned entries directly to an existing Bundle's entry array.
|
||||
*
|
||||
* @param string $id Resource ID stem (without "DocumentReference/")
|
||||
* @param string $title Document title (human-readable)
|
||||
* @param string $contentType MIME type — application/pdf | image/jpeg | image/png
|
||||
* @param string $dataBase64 Base64-encoded raw file content
|
||||
* @param string $subjectRef Patient reference string (e.g. "Patient/123")
|
||||
* @param string $date ISO 8601 date/datetime string
|
||||
*
|
||||
* @return array<int, array<string, mixed>> [ [resource => Binary], [resource => DocumentReference] ]
|
||||
*/
|
||||
public function buildDocumentReferenceEntry(
|
||||
string $id,
|
||||
string $title,
|
||||
string $contentType,
|
||||
string $dataBase64,
|
||||
string $subjectRef,
|
||||
string $date
|
||||
): array {
|
||||
$binaryId = 'binary-' . $id;
|
||||
$binaryRef = 'Binary/' . $binaryId;
|
||||
|
||||
$binaryResource = [
|
||||
'resourceType' => 'Binary',
|
||||
'id' => $binaryId,
|
||||
'contentType' => $contentType,
|
||||
'data' => $dataBase64,
|
||||
];
|
||||
|
||||
$docRefResource = [
|
||||
'resourceType' => 'DocumentReference',
|
||||
'id' => $id,
|
||||
'status' => 'current',
|
||||
'type' => ['text' => $title],
|
||||
'subject' => ['reference' => $subjectRef],
|
||||
'date' => $date,
|
||||
'content' => [[
|
||||
'attachment' => [
|
||||
'contentType' => $contentType,
|
||||
'url' => $binaryRef,
|
||||
'title' => $title,
|
||||
],
|
||||
]],
|
||||
];
|
||||
|
||||
return [
|
||||
['resource' => $binaryResource],
|
||||
['resource' => $docRefResource],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from disk and return its base64-encoded content.
|
||||
* Returns an empty string when the file is absent or unreadable.
|
||||
*/
|
||||
public function encodeFileAsBase64(string $filePath): string
|
||||
{
|
||||
if ($filePath === '' || ! is_file($filePath) || ! is_readable($filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
|
||||
return $content !== false ? base64_encode($content) : '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
app/Views/abdm/coding_panel.php
Normal file
92
app/Views/abdm/coding_panel.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= esc($page_title) ?> — ABDM</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fb; }
|
||||
.wrap { max-width: 1280px; margin: 24px auto; }
|
||||
.badge-pending { background:#fff3cd; color:#856404; }
|
||||
.badge-done { background:#d1e7dd; color:#0a3622; }
|
||||
.badge-failed { background:#f8d7da; color:#842029; }
|
||||
.tbl td, .tbl th { vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap px-3">
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">SNOMED Coding Panel</h4>
|
||||
<div class="small text-muted">Review AI-matched SNOMED codes before FHIR submission</div>
|
||||
</div>
|
||||
<a href="<?= site_url('AbdmTaskBoard') ?>" class="btn btn-sm btn-outline-secondary">← Task Board</a>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('message')): ?>
|
||||
<div class="alert alert-info py-2"><?= esc(session()->getFlashdata('message')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover tbl mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>OPD Date</th>
|
||||
<th>OPD No</th>
|
||||
<th>Patient</th>
|
||||
<th class="text-center">Total</th>
|
||||
<th class="text-center">Pending</th>
|
||||
<th class="text-center">Confirmed</th>
|
||||
<th>Processed At</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($sessions)): ?>
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No coding sessions ready for review.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sessions as $s): ?>
|
||||
<tr>
|
||||
<td><?= esc($s['opd_date'] ?? '') ?></td>
|
||||
<td><?= esc($s['opd_no'] ?? $s['opd_id']) ?></td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= esc($s['patient_name'] ?? '—') ?></div>
|
||||
<div class="small text-muted"><?= esc($s['patient_mobile'] ?? '') ?></div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-secondary"><?= (int)$s['total_suggestions'] ?></span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ((int)$s['pending_count'] > 0): ?>
|
||||
<span class="badge badge-pending px-2 py-1 rounded-pill"><?= (int)$s['pending_count'] ?> pending</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ((int)$s['confirmed_count'] > 0): ?>
|
||||
<span class="badge badge-done px-2 py-1 rounded-pill"><?= (int)$s['confirmed_count'] ?> done</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="small text-muted"><?= esc($s['processed_at'] ?? '') ?></td>
|
||||
<td>
|
||||
<a href="<?= site_url('AbdmCodingPanel/review/' . (int)$s['opd_session_id']) ?>"
|
||||
class="btn btn-sm btn-outline-primary">Review</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
329
app/Views/abdm/coding_panel_review.php
Normal file
329
app/Views/abdm/coding_panel_review.php
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= esc($page_title) ?> — ABDM</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fb; }
|
||||
.wrap { max-width: 1200px; margin: 24px auto; }
|
||||
.suggestion-card { border-left: 4px solid #dee2e6; }
|
||||
.suggestion-card.status-confirmed { border-left-color: #198754; }
|
||||
.suggestion-card.status-rejected { border-left-color: #dc3545; opacity: .65; }
|
||||
.suggestion-card.status-corrected { border-left-color: #0d6efd; }
|
||||
.confidence-bar { height: 6px; border-radius: 3px; background: #e9ecef; }
|
||||
.confidence-fill { height: 100%; border-radius: 3px; background: #0d6efd; transition: width .3s; }
|
||||
.field-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: #6c757d; }
|
||||
.snomed-tag { font-size: 11px; background: #e7f1ff; color: #084298; border-radius: 3px; padding: 1px 6px; }
|
||||
.correct-form { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap px-3">
|
||||
|
||||
<?php
|
||||
$consult = $consult ?? [];
|
||||
$suggestions = $suggestions ?? [];
|
||||
$sessionId = (int)($session_id ?? 0);
|
||||
|
||||
// Group suggestions by source_field
|
||||
$grouped = [];
|
||||
foreach ($suggestions as $s) {
|
||||
$grouped[$s['source_field']][] = $s;
|
||||
}
|
||||
|
||||
$fieldLabels = [
|
||||
'complaints' => 'Complaints',
|
||||
'diagnosis' => 'Diagnosis',
|
||||
'Provisional_diagnosis' => 'Provisional Diagnosis',
|
||||
'Finding_Examinations' => 'Findings / Examinations',
|
||||
];
|
||||
|
||||
$hasPending = count(array_filter($suggestions, fn($s) => $s['status'] === 'pending_review')) > 0;
|
||||
$csrfName = csrf_token();
|
||||
$csrfHash = csrf_hash();
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Review SNOMED Codes</h4>
|
||||
<div class="small text-muted">
|
||||
<?= esc($consult['patient_name'] ?? '') ?>
|
||||
— OPD <?= esc($consult['opd_no'] ?? $consult['opd_id'] ?? '') ?>
|
||||
— <?= esc($consult['opd_date'] ?? '') ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<?php if (!$hasPending): ?>
|
||||
<button type="button" class="btn btn-success btn-sm" id="btnMarkFhirReady">
|
||||
Mark FHIR Ready & Update Codes
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<a href="<?= site_url('AbdmCodingPanel') ?>" class="btn btn-sm btn-outline-secondary">← Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($consult)): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="row g-2 small">
|
||||
<div class="col-md-6">
|
||||
<span class="fw-semibold">Complaints:</span>
|
||||
<?= esc($consult['complaints'] ?? '—') ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<span class="fw-semibold">Diagnosis:</span>
|
||||
<?= esc($consult['diagnosis'] ?? '—') ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<span class="fw-semibold">Provisional Diagnosis:</span>
|
||||
<?= esc($consult['Provisional_diagnosis'] ?? '—') ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<span class="fw-semibold">Findings:</span>
|
||||
<?= esc($consult['Finding_Examinations'] ?? '—') ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Suggestions grouped by field -->
|
||||
<?php foreach ($grouped as $field => $fieldSuggestions): ?>
|
||||
<div class="mb-4">
|
||||
<h6 class="field-label mb-2"><?= esc($fieldLabels[$field] ?? $field) ?></h6>
|
||||
|
||||
<?php
|
||||
// Group by source_phrase within this field
|
||||
$byPhrase = [];
|
||||
foreach ($fieldSuggestions as $s) {
|
||||
$byPhrase[$s['source_phrase']][] = $s;
|
||||
}
|
||||
?>
|
||||
|
||||
<?php foreach ($byPhrase as $phrase => $phrSuggestions): ?>
|
||||
<div class="card mb-2">
|
||||
<div class="card-header py-2 bg-light">
|
||||
<span class="fw-semibold">"<?= esc($phrase) ?>"</span>
|
||||
<span class="ms-2 small text-muted">— <?= count($phrSuggestions) ?> candidate(s)</span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<?php foreach ($phrSuggestions as $s): ?>
|
||||
<?php
|
||||
$statusClass = 'status-' . $s['status'];
|
||||
$pct = min(100, round((float)$s['confidence'] * 100));
|
||||
?>
|
||||
<div class="suggestion-card p-2 mb-2 rounded bg-white border <?= $statusClass ?>" data-id="<?= (int)$s['id'] ?>">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1 me-3">
|
||||
<div class="fw-semibold"><?= esc($s['snomed_term']) ?></div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
<code class="small"><?= esc($s['concept_id']) ?></code>
|
||||
<?php if ($s['semantic_tag']): ?>
|
||||
<span class="snomed-tag"><?= esc($s['semantic_tag']) ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="small text-muted"><?= $pct ?>% match</span>
|
||||
</div>
|
||||
<div class="confidence-bar mt-1 w-25">
|
||||
<div class="confidence-fill" style="width:<?= $pct ?>%"></div>
|
||||
</div>
|
||||
|
||||
<?php if ($s['status'] === 'corrected'): ?>
|
||||
<div class="mt-1 small text-primary">
|
||||
Corrected to: <strong><?= esc($s['corrected_term']) ?></strong>
|
||||
<code><?= esc($s['corrected_concept_id']) ?></code>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Correction form (hidden by default) -->
|
||||
<div class="correct-form mt-2" id="correctForm<?= (int)$s['id'] ?>">
|
||||
<div class="row g-1">
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Concept ID (numeric)"
|
||||
id="corrConceptId<?= (int)$s['id'] ?>">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="SNOMED Term"
|
||||
id="corrTerm<?= (int)$s['id'] ?>">
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button type="button" class="btn btn-sm btn-primary w-100 btn-save-correction"
|
||||
data-id="<?= (int)$s['id'] ?>">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($s['status'] === 'pending_review'): ?>
|
||||
<div class="d-flex flex-column gap-1 ms-2 flex-shrink-0">
|
||||
<button type="button" class="btn btn-sm btn-success btn-confirm" data-id="<?= (int)$s['id'] ?>">✓ Confirm</button>
|
||||
<button type="button" class="btn btn-sm btn-danger btn-reject" data-id="<?= (int)$s['id'] ?>">✗ Reject</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-correct" data-id="<?= (int)$s['id'] ?>">✎ Correct</button>
|
||||
</div>
|
||||
<?php elseif ($s['status'] === 'confirmed'): ?>
|
||||
<span class="badge bg-success align-self-start">Confirmed</span>
|
||||
<?php elseif ($s['status'] === 'rejected'): ?>
|
||||
<span class="badge bg-danger align-self-start">Rejected</span>
|
||||
<?php elseif ($s['status'] === 'corrected'): ?>
|
||||
<span class="badge bg-primary align-self-start">Corrected</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($grouped)): ?>
|
||||
<div class="alert alert-info">No suggestions for this session.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex gap-2 mt-3 mb-5">
|
||||
<?php
|
||||
$allReviewed = !$hasPending && count($suggestions) > 0;
|
||||
?>
|
||||
<button type="button" class="btn btn-success" id="btnMarkFhirReady"
|
||||
<?= $allReviewed ? '' : 'disabled' ?>>
|
||||
Mark FHIR Ready & Update OPD Codes
|
||||
<?php if (!$allReviewed): ?>
|
||||
<span class="badge bg-warning text-dark ms-1">Pending review</span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
<a href="<?= site_url('AbdmCodingPanel') ?>" class="btn btn-outline-secondary">← Back</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var sessionId = <?= $sessionId ?>;
|
||||
var csrfName = '<?= $csrfName ?>';
|
||||
var csrfHash = '<?= $csrfHash ?>';
|
||||
|
||||
function apiPost(url, data, cb) {
|
||||
data[csrfName] = csrfHash;
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: new URLSearchParams(data)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(j) {
|
||||
csrfName = j.csrfName || csrfName;
|
||||
csrfHash = j.csrfHash || csrfHash;
|
||||
cb(j);
|
||||
})
|
||||
.catch(function(e) { console.error(url, e); cb({ok:0}); });
|
||||
}
|
||||
|
||||
function setSuggestionStatus(id, status) {
|
||||
var card = document.querySelector('.suggestion-card[data-id="' + id + '"]');
|
||||
if (!card) return;
|
||||
card.classList.remove('status-pending_review', 'status-confirmed', 'status-rejected', 'status-corrected');
|
||||
card.classList.add('status-' + status);
|
||||
|
||||
// Replace action buttons with badge
|
||||
var btns = card.querySelector('.d-flex.flex-column');
|
||||
if (btns) {
|
||||
var labels = { confirmed: 'success', rejected: 'danger', corrected: 'primary' };
|
||||
var bc = labels[status] || 'secondary';
|
||||
var label = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
btns.outerHTML = '<span class="badge bg-' + bc + ' align-self-start">' + label + '</span>';
|
||||
}
|
||||
|
||||
checkAllReviewed();
|
||||
}
|
||||
|
||||
function checkAllReviewed() {
|
||||
var pending = document.querySelectorAll('.suggestion-card.status-pending_review').length;
|
||||
var btn = document.getElementById('btnMarkFhirReady');
|
||||
if (btn) {
|
||||
btn.disabled = pending > 0;
|
||||
var badge = btn.querySelector('.badge');
|
||||
if (badge) badge.remove();
|
||||
if (pending > 0) {
|
||||
var b = document.createElement('span');
|
||||
b.className = 'badge bg-warning text-dark ms-1';
|
||||
b.textContent = pending + ' pending';
|
||||
btn.appendChild(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.btn-confirm');
|
||||
if (!btn) return;
|
||||
var id = btn.dataset.id;
|
||||
btn.disabled = true;
|
||||
apiPost('<?= site_url('AbdmCodingPanel/confirm') ?>', { suggestion_id: id }, function(j) {
|
||||
if (j.ok) { setSuggestionStatus(id, 'confirmed'); }
|
||||
else { btn.disabled = false; alert(j.error_text || 'Error'); }
|
||||
});
|
||||
});
|
||||
|
||||
// Reject
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.btn-reject');
|
||||
if (!btn) return;
|
||||
var id = btn.dataset.id;
|
||||
btn.disabled = true;
|
||||
apiPost('<?= site_url('AbdmCodingPanel/reject') ?>', { suggestion_id: id }, function(j) {
|
||||
if (j.ok) { setSuggestionStatus(id, 'rejected'); }
|
||||
else { btn.disabled = false; alert(j.error_text || 'Error'); }
|
||||
});
|
||||
});
|
||||
|
||||
// Show correction form
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.btn-correct');
|
||||
if (!btn) return;
|
||||
var id = btn.dataset.id;
|
||||
var form = document.getElementById('correctForm' + id);
|
||||
if (form) form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Save correction
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.btn-save-correction');
|
||||
if (!btn) return;
|
||||
var id = btn.dataset.id;
|
||||
var conceptId = document.getElementById('corrConceptId' + id).value.trim();
|
||||
var term = document.getElementById('corrTerm' + id).value.trim();
|
||||
if (!conceptId || !term) { alert('Concept ID and Term are required.'); return; }
|
||||
btn.disabled = true;
|
||||
apiPost('<?= site_url('AbdmCodingPanel/correct') ?>', {
|
||||
suggestion_id: id, corrected_concept_id: conceptId, corrected_term: term
|
||||
}, function(j) {
|
||||
if (j.ok) { setSuggestionStatus(id, 'corrected'); }
|
||||
else { btn.disabled = false; alert(j.error_text || 'Error'); }
|
||||
});
|
||||
});
|
||||
|
||||
// Mark FHIR Ready
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('#btnMarkFhirReady');
|
||||
if (!btn || btn.disabled) return;
|
||||
if (!confirm('Mark all confirmed/corrected codes as FHIR-ready and update OPD prescription fields?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving…';
|
||||
apiPost('<?= site_url('AbdmCodingPanel/mark_fhir_ready') ?>', { session_id: sessionId }, function(j) {
|
||||
if (j.ok) {
|
||||
btn.textContent = '✓ FHIR Ready';
|
||||
btn.className = 'btn btn-outline-success';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Mark FHIR Ready & Update OPD Codes';
|
||||
alert(j.error_text || 'Error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,231 +1,227 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OPD Queue — ABDM</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f0f4f8; }
|
||||
.wrap { max-width: 1280px; margin: 20px auto; }
|
||||
.badge-source-scan { background:#0d6efd; }
|
||||
.badge-source-manual{ background:#6c757d; }
|
||||
.token-num { font-size: 1.3rem; font-weight: 700; color: #0d6efd; }
|
||||
.status-PENDING { background:#ffc107; color:#000; }
|
||||
.status-CALLED { background:#0d6efd; color:#fff; }
|
||||
.status-COMPLETED { background:#198754; color:#fff; }
|
||||
.status-CANCELLED { background:#dc3545; color:#fff; }
|
||||
.auto-refresh-bar { height:3px; background:#0d6efd; transition: width linear 30s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap px-3">
|
||||
<style>
|
||||
.abdm-q-token-num { font-size:1.1rem; font-weight:700; color:#0d6efd; }
|
||||
.badge-src-scan { background:#0d6efd; }
|
||||
.badge-src-manual { background:#6c757d; }
|
||||
.status-PENDING { background:#ffc107; color:#000; }
|
||||
.status-CALLED { background:#0d6efd; color:#fff; }
|
||||
.status-COMPLETED { background:#198754; color:#fff; }
|
||||
.status-CANCELLED { background:#dc3545; color:#fff; }
|
||||
.abdm-q-refresh-bar{ height:3px; background:#0d6efd; transition: width linear 30s; border-radius:2px; }
|
||||
</style>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pt-3">
|
||||
<!-- Page title -->
|
||||
<div class="pagetitle">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0">OPD Queue <span class="badge bg-primary ms-2">ABDM</span></h4>
|
||||
<h1 class="mb-0 fs-5">OPD Queue <span class="badge bg-primary ms-2">ABDM</span></h1>
|
||||
<div class="small text-muted">Scan & Share arrivals + walk-in tokens — auto-refresh every 30 s</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="date" id="queueDate" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
|
||||
<select id="queueStatus" class="form-select form-select-sm" style="width:140px">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<input type="date" id="queueDate" class="form-control form-control-sm" style="width:145px" value="<?= date('Y-m-d') ?>">
|
||||
<select id="queueStatus" class="form-select form-select-sm" style="width:130px">
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING" selected>Pending</option>
|
||||
<option value="CALLED">Called</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" id="btnRefresh"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#modalAddToken">
|
||||
+ Add Walk-in
|
||||
<button class="btn btn-sm btn-outline-primary" id="btnRefresh">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
<button class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#abdmQueueModalAddToken">
|
||||
<i class="bi bi-plus-lg"></i> Add Walk-in
|
||||
</button>
|
||||
<a href="javascript:load_form('<?= base_url('AbdmOpdQueue/list') ?>','ABDM OPD List')"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-list-ul"></i> History
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh bar -->
|
||||
<div class="auto-refresh-bar w-100 mb-2 rounded" id="refreshBar"></div>
|
||||
<section class="section">
|
||||
|
||||
<!-- Summary badges -->
|
||||
<div class="d-flex gap-3 mb-3" id="queueSummary">
|
||||
<!-- Auto-refresh progress bar -->
|
||||
<div class="abdm-q-refresh-bar w-100 mb-2" id="abdmQRefreshBar"></div>
|
||||
|
||||
<!-- Summary pills -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" id="abdmQSummary">
|
||||
<span class="badge rounded-pill bg-secondary px-3 py-2">Loading…</span>
|
||||
</div>
|
||||
|
||||
<!-- Queue table -->
|
||||
<!-- Queue card -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th style="width:60px">Token</th>
|
||||
<th>Name</th>
|
||||
<th>ABHA</th>
|
||||
<th>Dept</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>HMS Patient</th>
|
||||
<th>Time</th>
|
||||
<th>Actions</th>
|
||||
<th style="width:70px">Time</th>
|
||||
<th style="min-width:190px">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queueBody">
|
||||
<tr><td colspan="8" class="text-center py-4 text-muted">Loading queue…</td></tr>
|
||||
<tbody id="abdmQueueBody">
|
||||
<tr><td colspan="9" class="text-center py-4 text-muted">Loading queue…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /wrap -->
|
||||
</section>
|
||||
|
||||
<!-- ===== Add Walk-in Modal ===== -->
|
||||
<div class="modal fade" id="modalAddToken" tabindex="-1">
|
||||
<div class="modal fade" id="abdmQueueModalAddToken" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add Walk-in Token</h5>
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title mb-0">Add Walk-in Token</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="addTokenAlert" class="alert alert-danger d-none py-2"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Patient Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="at_name" class="form-control" placeholder="Full name">
|
||||
<div id="abdmAddTokenAlert" class="alert alert-danger d-none py-2 small"></div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label form-label-sm">Patient Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="at_name" class="form-control form-control-sm" placeholder="Full name">
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="tel" id="at_phone" class="form-control" placeholder="10-digit mobile">
|
||||
<label class="form-label form-label-sm">Phone</label>
|
||||
<input type="tel" id="at_phone" class="form-control form-control-sm" placeholder="10-digit mobile">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Gender</label>
|
||||
<select id="at_gender" class="form-select">
|
||||
<label class="form-label form-label-sm">Gender</label>
|
||||
<select id="at_gender" class="form-select form-select-sm">
|
||||
<option value="M">Male</option>
|
||||
<option value="F">Female</option>
|
||||
<option value="O">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">ABHA No (optional)</label>
|
||||
<input type="text" id="at_abha" class="form-control" placeholder="14-digit">
|
||||
<label class="form-label form-label-sm">ABHA No (optional)</label>
|
||||
<input type="text" id="at_abha" class="form-control form-control-sm" placeholder="14-digit">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Department</label>
|
||||
<input type="text" id="at_dept" class="form-control" value="General OPD">
|
||||
<label class="form-label form-label-sm">Department</label>
|
||||
<input type="text" id="at_dept" class="form-control form-control-sm" value="General OPD">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Date</label>
|
||||
<input type="date" id="at_date" class="form-control" value="<?= date('Y-m-d') ?>">
|
||||
<div class="mb-1">
|
||||
<label class="form-label form-label-sm">Date</label>
|
||||
<input type="date" id="at_date" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-success" id="btnAddTokenSubmit">Create Token</button>
|
||||
<div class="modal-footer py-2">
|
||||
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="btn btn-sm btn-success" id="btnAddTokenSubmit">Create Token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Process Scan&Share patient modal (auto-opened) ===== -->
|
||||
<div class="modal fade" id="modalProcessToken" tabindex="-1">
|
||||
<!-- ===== Register patient from Scan&Share token ===== -->
|
||||
<div class="modal fade" id="abdmQueueModalProcess" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title">Register Patient for OPD</h5>
|
||||
<div class="modal-header bg-primary text-white py-2">
|
||||
<h6 class="modal-title mb-0">Register Patient for OPD</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="processTokenBody">
|
||||
<p class="text-muted">Processing…</p>
|
||||
</div>
|
||||
<div class="modal-footer" id="processTokenFooter" style="display:none!important">
|
||||
<div class="modal-body" id="abdmProcessTokenBody">
|
||||
<p class="text-muted small">Processing…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
/* ===== OPD QUEUE BOARD ===== */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const BASE = '<?= base_url() ?>';
|
||||
const csrfName = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
|
||||
const BASE = '<?= base_url() ?>';
|
||||
const CSRF_NAME = '<?= csrf_token() ?>';
|
||||
const REFRESH_SECS = 30;
|
||||
let autoTimer = null;
|
||||
|
||||
let autoRefreshTimer = null;
|
||||
const REFRESH_SECS = 30;
|
||||
|
||||
// ---- helpers ----
|
||||
function post(url, data) {
|
||||
const fd = new FormData();
|
||||
Object.entries(data).forEach(([k, v]) => fd.append(k, v));
|
||||
const csrf = '<?= csrf_token() ?>';
|
||||
const hash = '<?= csrf_hash() ?>';
|
||||
fd.append(csrf, hash);
|
||||
return fetch(url, { method: 'POST', body: fd })
|
||||
.then(r => r.json());
|
||||
fd.append(CSRF_NAME, '<?= csrf_hash() ?>');
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: fd
|
||||
}).then(r => r.json());
|
||||
}
|
||||
|
||||
function statusBadge(s) {
|
||||
return `<span class="badge status-${s} px-2 py-1 rounded-pill">${s}</span>`;
|
||||
return `<span class="badge status-${s} px-2 py-1 rounded-pill small">${s}</span>`;
|
||||
}
|
||||
|
||||
function sourceBadge(src) {
|
||||
if (src === 'scan_share') return '<span class="badge badge-source-scan px-2">ABHA Scan</span>';
|
||||
return '<span class="badge badge-source-manual px-2">Walk-in</span>';
|
||||
return src === 'scan_share'
|
||||
? '<span class="badge badge-src-scan px-2 small">ABHA Scan</span>'
|
||||
: '<span class="badge badge-src-manual px-2 small">Walk-in</span>';
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
return new Date(ts).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
|
||||
}
|
||||
function fmtAbha(n) {
|
||||
const d = String(n).replace(/\D/g, '');
|
||||
return d.length === 14 ? `${d.slice(0,2)}-${d.slice(2,6)}-${d.slice(6,10)}-${d.slice(10)}` : n;
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ---- fetch queue from HMS controller ----
|
||||
function loadQueue() {
|
||||
const date = document.getElementById('queueDate').value;
|
||||
const status = document.getElementById('queueStatus').value;
|
||||
const url = BASE + 'AbdmOpdQueue/fetch?date=' + encodeURIComponent(date)
|
||||
+ '&status=' + encodeURIComponent(status);
|
||||
+ '&status=' + encodeURIComponent(status);
|
||||
|
||||
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(r => r.json())
|
||||
.then(renderQueue)
|
||||
.catch(err => {
|
||||
document.getElementById('queueBody').innerHTML =
|
||||
`<tr><td colspan="8" class="text-danger text-center py-3">Failed to load queue: ${err.message}</td></tr>`;
|
||||
document.getElementById('abdmQueueBody').innerHTML =
|
||||
`<tr><td colspan="9" class="text-danger text-center py-3 small">Failed to load: ${esc(err.message)}</td></tr>`;
|
||||
});
|
||||
|
||||
resetRefreshBar();
|
||||
resetBar();
|
||||
}
|
||||
|
||||
function renderQueue(data) {
|
||||
const tokens = data.data ?? data.tokens ?? [];
|
||||
const body = document.getElementById('queueBody');
|
||||
const summary= document.getElementById('queueSummary');
|
||||
const tokens = data.data ?? data.tokens ?? [];
|
||||
const body = document.getElementById('abdmQueueBody');
|
||||
const summary = document.getElementById('abdmQSummary');
|
||||
|
||||
// Summary counts
|
||||
const counts = { PENDING: 0, CALLED: 0, COMPLETED: 0, CANCELLED: 0 };
|
||||
let opdDoneCount = 0;
|
||||
const counts = { PENDING:0, CALLED:0, COMPLETED:0, CANCELLED:0 };
|
||||
let opdDone = 0;
|
||||
tokens.forEach(t => {
|
||||
if (counts[t.status] !== undefined) counts[t.status]++;
|
||||
if (t.hms_patient_id) opdDoneCount++;
|
||||
if (t.hms_patient_id) opdDone++;
|
||||
});
|
||||
|
||||
summary.innerHTML = [
|
||||
`<span class="badge rounded-pill status-PENDING px-3 py-2">${counts.PENDING} Pending</span>`,
|
||||
`<span class="badge rounded-pill status-CALLED px-3 py-2">${counts.CALLED} Called</span>`,
|
||||
`<span class="badge rounded-pill status-COMPLETED px-3 py-2">${counts.COMPLETED} Completed</span>`,
|
||||
`<span class="badge rounded-pill status-CANCELLED px-3 py-2">${counts.CANCELLED} Cancelled</span>`,
|
||||
`<span class="badge rounded-pill bg-success px-3 py-2">✓ ${opdDoneCount} OPD Registered</span>`,
|
||||
`<span class="badge rounded-pill bg-success px-3 py-2">✓ ${opdDone} OPD Registered</span>`,
|
||||
`<span class="badge rounded-pill bg-secondary px-3 py-2">${tokens.length} Total</span>`,
|
||||
].join('');
|
||||
|
||||
if (tokens.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted">No tokens found for this filter.</td></tr>';
|
||||
if (!tokens.length) {
|
||||
body.innerHTML = '<tr><td colspan="9" class="text-center py-4 text-muted small">No tokens found for this filter.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -233,98 +229,79 @@
|
|||
const id = t.id ?? 0;
|
||||
const isPend = t.status === 'PENDING';
|
||||
const isCalled = t.status === 'CALLED';
|
||||
const isScan = t.source === 'scan_share';
|
||||
const isLinked = !! t.hms_patient_id; // patient already registered in HMS
|
||||
const isLinked = !!t.hms_patient_id;
|
||||
|
||||
const abhaDisplay = t.abha_number
|
||||
? `<div class="small text-primary">${fmtAbha(t.abha_number)}</div>`
|
||||
const abhaCell = t.abha_number
|
||||
? `<div class="small text-primary font-monospace">${fmtAbha(t.abha_number)}</div>`
|
||||
: (t.abha_address ? `<div class="small text-muted">${esc(t.abha_address)}</div>` : '<span class="text-muted">—</span>');
|
||||
|
||||
// HMS patient column
|
||||
let hmsCell = '<span class="text-muted small">—</span>';
|
||||
if (isLinked) {
|
||||
const pLabel = esc((t.hms_p_code ?? '') + ' ' + (t.hms_p_name ?? ''));
|
||||
hmsCell = `<a href="javascript:load_form('${esc(t.hms_profile_url ?? '')}','Patient Profile')"
|
||||
class="text-decoration-none">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">✓ ${pLabel}</span>
|
||||
const lbl = esc((t.hms_p_code ?? '') + ' ' + (t.hms_p_name ?? ''));
|
||||
hmsCell = `<a href="javascript:load_form('${esc(t.hms_profile_url ?? '')}','Patient Profile')" class="text-decoration-none">
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">✓ ${lbl}</span>
|
||||
</a>`;
|
||||
if (t.hms_opd_url && ! t.hms_opd_id) {
|
||||
if (t.hms_opd_url && !t.hms_opd_id) {
|
||||
hmsCell += `<br><a href="${esc(t.hms_opd_url)}" class="small text-primary">+ Add OPD Visit</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
let actions = '';
|
||||
if (isPend) {
|
||||
actions += `<button class="btn btn-xs btn-sm btn-outline-primary me-1" onclick="callToken(${id})">Call</button>`;
|
||||
actions += `<button class="btn btn-xs btn-sm btn-outline-primary me-1" onclick="abdmQCall(${id})">Call</button>`;
|
||||
}
|
||||
if (isPend || isCalled) {
|
||||
actions += `<button class="btn btn-xs btn-sm btn-outline-success me-1" onclick="completeToken(${id})">Complete</button>`;
|
||||
actions += `<button class="btn btn-xs btn-sm btn-outline-danger me-1" onclick="cancelToken(${id})">Cancel</button>`;
|
||||
actions += `<button class="btn btn-xs btn-sm btn-outline-success me-1" onclick="abdmQComplete(${id})">Complete</button>`;
|
||||
actions += `<button class="btn btn-xs btn-sm btn-outline-danger me-1" onclick="abdmQCancel(${id})">Cancel</button>`;
|
||||
}
|
||||
// Show "Register OPD" for SCAN tokens not yet linked, OR any PENDING token without a patient
|
||||
if (! isLinked && (isPend || isCalled)) {
|
||||
const payload = JSON.stringify({
|
||||
if (!isLinked && (isPend || isCalled)) {
|
||||
const pl = JSON.stringify({
|
||||
id: t.id, abha_number: t.abha_number ?? '', abha_address: t.abha_address ?? '',
|
||||
patient_name: t.patient_name ?? '', phone: t.phone ?? '',
|
||||
gender: t.gender ?? '', dob: t.dob ?? ''
|
||||
}).replace(/'/g, ''');
|
||||
actions += `<button class="btn btn-xs btn-sm btn-primary" onclick="openRegisterModal('${payload}')">Register OPD</button>`;
|
||||
actions += `<button class="btn btn-xs btn-sm btn-primary" onclick="abdmQRegister('${pl}')">Register OPD</button>`;
|
||||
}
|
||||
|
||||
return `<tr id="row-${id}" class="${isLinked ? 'table-success' : ''}">
|
||||
<td><span class="token-num">#${t.token_number ?? id}</span></td>
|
||||
return `<tr id="abdmqrow-${id}" class="${isLinked ? 'table-success' : ''}">
|
||||
<td><span class="abdm-q-token-num">#${t.token_number ?? id}</span></td>
|
||||
<td>
|
||||
<div>${esc(t.patient_name ?? '—')}</div>
|
||||
<div class="fw-semibold small">${esc(t.patient_name ?? '—')}</div>
|
||||
<div class="small text-muted">${esc(t.phone ?? '')}</div>
|
||||
</td>
|
||||
<td>${abhaDisplay}</td>
|
||||
<td>${esc(t.department ?? 'General OPD')}</td>
|
||||
<td>${abhaCell}</td>
|
||||
<td class="small">${esc(t.department ?? 'General OPD')}</td>
|
||||
<td>${sourceBadge(t.source ?? 'manual')}</td>
|
||||
<td>${statusBadge(t.status ?? 'PENDING')}</td>
|
||||
<td>${hmsCell}</td>
|
||||
<td>${fmtTime(t.created_at)}</td>
|
||||
<td style="min-width:180px">${actions}</td>
|
||||
<td class="small">${fmtTime(t.created_at)}</td>
|
||||
<td>${actions}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function fmtAbha(n) {
|
||||
const d = String(n).replace(/\D/g, '');
|
||||
if (d.length === 14) return `${d.slice(0,2)}-${d.slice(2,6)}-${d.slice(6,10)}-${d.slice(10)}`;
|
||||
return n;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ---- token status actions ----
|
||||
function setTokenStatus(tokenId, status) {
|
||||
function setStatus(tokenId, status) {
|
||||
post(BASE + 'AbdmOpdQueue/token_status/' + tokenId, { status })
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
loadQueue();
|
||||
} else {
|
||||
alert('Error: ' + (r.error_text ?? r.message ?? 'Unknown'));
|
||||
}
|
||||
if (r.ok) { loadQueue(); }
|
||||
else { alert('Error: ' + (r.error_text ?? r.message ?? 'Unknown error')); }
|
||||
})
|
||||
.catch(err => alert('Request failed: ' + err.message));
|
||||
}
|
||||
|
||||
window.callToken = id => setTokenStatus(id, 'CALLED');
|
||||
window.completeToken = id => setTokenStatus(id, 'COMPLETED');
|
||||
window.cancelToken = id => { if (confirm('Cancel token #' + id + '?')) setTokenStatus(id, 'CANCELLED'); };
|
||||
window.abdmQCall = id => setStatus(id, 'CALLED');
|
||||
window.abdmQComplete = id => setStatus(id, 'COMPLETED');
|
||||
window.abdmQCancel = id => { if (confirm('Cancel token #' + id + '?')) setStatus(id, 'CANCELLED'); };
|
||||
|
||||
// ---- Register OPD from scan_share token ----
|
||||
window.openRegisterModal = function (payloadJson) {
|
||||
const t = JSON.parse(payloadJson);
|
||||
const body = document.getElementById('processTokenBody');
|
||||
window.abdmQRegister = function (payloadJson) {
|
||||
const t = JSON.parse(payloadJson);
|
||||
const body = document.getElementById('abdmProcessTokenBody');
|
||||
body.innerHTML = `
|
||||
<p class="mb-2">Processing ABHA Scan token for:</p>
|
||||
<p class="mb-2 small">Processing ABHA Scan token for:</p>
|
||||
<div class="card border-primary mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="fw-bold">${esc(t.patient_name || 'Unknown')}</div>
|
||||
<div class="small">${t.abha_number ? 'ABHA: ' + fmtAbha(t.abha_number) : ''} ${t.phone ? ' | Ph: ' + t.phone : ''}</div>
|
||||
<div class="small">${t.abha_number ? 'ABHA: ' + fmtAbha(t.abha_number) : ''}${t.phone ? ' | Ph: ' + t.phone : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center">
|
||||
|
|
@ -332,97 +309,86 @@
|
|||
</div>
|
||||
<p class="text-center text-muted small mt-2">Finding / creating patient record…</p>`;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('modalProcessToken'));
|
||||
modal.show();
|
||||
new bootstrap.Modal(document.getElementById('abdmQueueModalProcess')).show();
|
||||
|
||||
post(BASE + 'AbdmOpdQueue/process_token/' + t.id, {
|
||||
abha_number: t.abha_number ?? '',
|
||||
abha_address: t.abha_address ?? '',
|
||||
patient_name: t.patient_name ?? '',
|
||||
phone: t.phone ?? '',
|
||||
gender: t.gender ?? '',
|
||||
dob: t.dob ?? '',
|
||||
abha_number: t.abha_number ?? '', abha_address: t.abha_address ?? '',
|
||||
patient_name: t.patient_name ?? '', phone: t.phone ?? '',
|
||||
gender: t.gender ?? '', dob: t.dob ?? '',
|
||||
}).then(r => {
|
||||
if (r.ok) {
|
||||
body.innerHTML = `
|
||||
<div class="alert alert-success py-2 mb-2">
|
||||
<div class="alert alert-success py-2 small mb-2">
|
||||
${r.is_new ? '<strong>New patient created.</strong>' : '<strong>Existing patient found.</strong>'}
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="fw-bold">${esc(t.patient_name || '—')}</div>
|
||||
<div class="small text-muted">HMS ID: <strong>${esc(r.p_code)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="${r.redirect_url}" class="btn btn-primary w-100">Open OPD Registration →</a>
|
||||
<button class="btn btn-outline-secondary w-100 mt-2" data-bs-dismiss="modal" onclick="loadQueue()">Back to Queue</button>`;
|
||||
<div class="card mb-3"><div class="card-body py-2 small">
|
||||
<div class="fw-bold">${esc(t.patient_name || '—')}</div>
|
||||
<div class="text-muted">HMS ID: <strong>${esc(r.p_code)}</strong></div>
|
||||
</div></div>
|
||||
<a href="${r.redirect_url}" class="btn btn-sm btn-primary w-100">Open OPD Registration →</a>
|
||||
<button class="btn btn-sm btn-outline-secondary w-100 mt-2" data-bs-dismiss="modal" onclick="loadQueue()">Back to Queue</button>`;
|
||||
loadQueue();
|
||||
} else {
|
||||
body.innerHTML = `<div class="alert alert-danger">${esc(r.error_text ?? 'Failed to process token')}</div>
|
||||
<button class="btn btn-secondary w-100" data-bs-dismiss="modal">Close</button>`;
|
||||
body.innerHTML = `<div class="alert alert-danger small">${esc(r.error_text ?? 'Failed to process token')}</div>
|
||||
<button class="btn btn-sm btn-secondary w-100" data-bs-dismiss="modal">Close</button>`;
|
||||
}
|
||||
}).catch(err => {
|
||||
body.innerHTML = `<div class="alert alert-danger">Request error: ${esc(err.message)}</div>
|
||||
<button class="btn btn-secondary w-100" data-bs-dismiss="modal">Close</button>`;
|
||||
body.innerHTML = `<div class="alert alert-danger small">Request error: ${esc(err.message)}</div>
|
||||
<button class="btn btn-sm btn-secondary w-100" data-bs-dismiss="modal">Close</button>`;
|
||||
});
|
||||
};
|
||||
|
||||
// ---- Add walk-in token ----
|
||||
document.getElementById('btnAddTokenSubmit').addEventListener('click', function () {
|
||||
const name = document.getElementById('at_name').value.trim();
|
||||
const name = document.getElementById('at_name').value.trim();
|
||||
const alertBox = document.getElementById('abdmAddTokenAlert');
|
||||
if (!name) {
|
||||
document.getElementById('addTokenAlert').textContent = 'Patient name is required.';
|
||||
document.getElementById('addTokenAlert').classList.remove('d-none');
|
||||
alertBox.textContent = 'Patient name is required.';
|
||||
alertBox.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
document.getElementById('addTokenAlert').classList.add('d-none');
|
||||
alertBox.classList.add('d-none');
|
||||
|
||||
post(BASE + 'AbdmOpdQueue/token', {
|
||||
patient_name: name,
|
||||
phone: document.getElementById('at_phone').value.trim(),
|
||||
gender: document.getElementById('at_gender').value,
|
||||
abha_number: document.getElementById('at_abha').value.replace(/\D/g,''),
|
||||
department: document.getElementById('at_dept').value.trim() || 'General OPD',
|
||||
date: document.getElementById('at_date').value,
|
||||
patient_name : name,
|
||||
phone : document.getElementById('at_phone').value.trim(),
|
||||
gender : document.getElementById('at_gender').value,
|
||||
abha_number : document.getElementById('at_abha').value.replace(/\D/g, ''),
|
||||
department : document.getElementById('at_dept').value.trim() || 'General OPD',
|
||||
date : document.getElementById('at_date').value,
|
||||
}).then(r => {
|
||||
if (r.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('modalAddToken')).hide();
|
||||
document.getElementById('at_name').value = '';
|
||||
bootstrap.Modal.getInstance(document.getElementById('abdmQueueModalAddToken')).hide();
|
||||
document.getElementById('at_name').value = '';
|
||||
document.getElementById('at_phone').value = '';
|
||||
document.getElementById('at_abha').value = '';
|
||||
document.getElementById('at_abha').value = '';
|
||||
loadQueue();
|
||||
} else {
|
||||
document.getElementById('addTokenAlert').textContent = r.error_text ?? r.message ?? 'Error creating token.';
|
||||
document.getElementById('addTokenAlert').classList.remove('d-none');
|
||||
alertBox.textContent = r.error_text ?? r.message ?? 'Error creating token.';
|
||||
alertBox.classList.remove('d-none');
|
||||
}
|
||||
}).catch(err => {
|
||||
document.getElementById('addTokenAlert').textContent = err.message;
|
||||
document.getElementById('addTokenAlert').classList.remove('d-none');
|
||||
alertBox.textContent = err.message;
|
||||
alertBox.classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- auto-refresh ----
|
||||
function resetRefreshBar() {
|
||||
const bar = document.getElementById('refreshBar');
|
||||
function resetBar() {
|
||||
const bar = document.getElementById('abdmQRefreshBar');
|
||||
bar.style.transition = 'none';
|
||||
bar.style.width = '100%';
|
||||
// trigger reflow
|
||||
void bar.offsetWidth;
|
||||
bar.style.transition = `width ${REFRESH_SECS}s linear`;
|
||||
bar.style.width = '0%';
|
||||
|
||||
clearTimeout(autoRefreshTimer);
|
||||
autoRefreshTimer = setTimeout(loadQueue, REFRESH_SECS * 1000);
|
||||
clearTimeout(autoTimer);
|
||||
autoTimer = setTimeout(loadQueue, REFRESH_SECS * 1000);
|
||||
}
|
||||
|
||||
document.getElementById('btnRefresh').addEventListener('click', loadQueue);
|
||||
document.getElementById('queueDate').addEventListener('change', loadQueue);
|
||||
document.getElementById('queueStatus').addEventListener('change', loadQueue);
|
||||
|
||||
// Initial load
|
||||
loadQueue();
|
||||
window.pageCleanup = function () { clearTimeout(autoTimer); };
|
||||
|
||||
loadQueue();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<th>Age</th>
|
||||
<th>Last Visit</th>
|
||||
<th>Insurance</th>
|
||||
<th>ABHA</th>
|
||||
<th>Patient History</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -26,6 +27,19 @@
|
|||
<td><?= esc(get_age_1($data[$i]->dob ?? null, $data[$i]->age ?? '', $data[$i]->age_in_month ?? '', $data[$i]->estimate_dob ?? '', $data[$i]->Last_Visit ?? null)) ?></td>
|
||||
<td><?=$data[$i]->Last_Visit ?></td>
|
||||
<td><?php echo ($data[$i]->insurance_id==0 ? 'Self': 'Insuranced'); ?></td>
|
||||
<td><?php
|
||||
$abhaId = trim((string)($data[$i]->abha_id ?? ''));
|
||||
$linkedAt = $data[$i]->abdm_linked_at ?? null;
|
||||
if ($abhaId !== '') {
|
||||
if (!empty($linkedAt)) {
|
||||
echo '<span class="label label-success"><i class="fa fa-check-circle"></i> Verified ABHA</span>';
|
||||
} else {
|
||||
echo '<span class="label label-warning"><i class="fa fa-exclamation-triangle"></i> Has ABHA</span>';
|
||||
}
|
||||
} else {
|
||||
echo '<span class="label label-default">No ABHA</span>';
|
||||
}
|
||||
?></td>
|
||||
<td>
|
||||
<a href="javascript:load_form('<?= base_url('billing/patient/show_profile_opd') ?>/<?=$data[$i]->id ?>/1');" class="btn btn-info btn-xs">
|
||||
<span class="fa fa-history"></span> Patient History
|
||||
|
|
@ -42,6 +56,7 @@
|
|||
<th>Age</th>
|
||||
<th>Last Visit</th>
|
||||
<th>Insurance</th>
|
||||
<th>ABHA</th>
|
||||
<th>Patient History</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ $med_specs = $med_specs ?? [];
|
|||
<tr>
|
||||
<th width="32"></th>
|
||||
<th>Name</th>
|
||||
<th width="100">SNOMED CT</th>
|
||||
<th width="70">LOINC</th>
|
||||
<th width="90">Code</th>
|
||||
<th width="80">Short</th>
|
||||
<th width="110">Category</th>
|
||||
|
|
@ -47,7 +49,7 @@ $med_specs = $med_specs ?? [];
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="7" class="text-muted text-center py-3">Loading…</td></tr>
|
||||
<tr><td colspan="9" class="text-muted text-center py-3">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -67,6 +69,24 @@ $med_specs = $med_specs ?? [];
|
|||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="inv_name" class="form-control form-control-sm" placeholder="e.g. CBC, LFT, ECG">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">SNOMED CT <small class="text-muted">(procedure / observable)</small></label>
|
||||
<div class="position-relative">
|
||||
<input type="text" id="inv_snomed_search" class="form-control form-control-sm" placeholder="Type to search SNOMED CT…" autocomplete="off">
|
||||
<div id="inv_snomed_dropdown" class="list-group shadow position-absolute w-100" style="z-index:1060;display:none;max-height:200px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div id="inv_snomed_selected" class="mt-1 small text-success" style="display:none;"></div>
|
||||
<input type="hidden" id="inv_snomed_concept_id" value="">
|
||||
<input type="hidden" id="inv_snomed_term" value="">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 mt-1" id="btn_inv_snomed_clear" style="display:none;font-size:.75rem;">✕ Clear SNOMED</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">LOINC Code <small class="text-muted">(optional)</small></label>
|
||||
<input type="text" id="inv_loinc_code" class="form-control form-control-sm" placeholder="e.g. 58410-2">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Code <small class="text-muted">(auto if blank)</small></label>
|
||||
<input type="text" id="inv_code" class="form-control form-control-sm" placeholder="e.g. CBC01">
|
||||
|
|
@ -180,6 +200,9 @@ $med_specs = $med_specs ?? [];
|
|||
function clearForm() {
|
||||
$('#inv_id').val('0');
|
||||
$('#inv_name,#inv_code,#inv_short_name,#inv_category_name').val('');
|
||||
$('#inv_snomed_concept_id,#inv_snomed_term,#inv_snomed_search,#inv_loinc_code').val('');
|
||||
$('#inv_snomed_selected').hide().text('');
|
||||
$('#btn_inv_snomed_clear').hide();
|
||||
$('.inv-spec-cb').prop('checked', false);
|
||||
$('#inv_is_favourite').prop('checked', false);
|
||||
$('#inv_form_title').text('New Investigation');
|
||||
|
|
@ -193,6 +216,21 @@ $med_specs = $med_specs ?? [];
|
|||
$('#inv_code').val(row.code || '');
|
||||
$('#inv_short_name').val(row.short_name || '');
|
||||
$('#inv_category_name').val(row.category_name || '');
|
||||
$('#inv_loinc_code').val(row.loinc_code || '');
|
||||
// SNOMED
|
||||
var scid = row.snomed_concept_id || '';
|
||||
var sterm = row.snomed_term || '';
|
||||
$('#inv_snomed_concept_id').val(scid);
|
||||
$('#inv_snomed_term').val(sterm);
|
||||
if (scid) {
|
||||
$('#inv_snomed_search').val(scid);
|
||||
$('#inv_snomed_selected').show().text(sterm || scid);
|
||||
$('#btn_inv_snomed_clear').show();
|
||||
} else {
|
||||
$('#inv_snomed_search').val('');
|
||||
$('#inv_snomed_selected').hide().text('');
|
||||
$('#btn_inv_snomed_clear').hide();
|
||||
}
|
||||
$('#inv_is_favourite').prop('checked', parseInt(row.is_favourite || 0) === 1);
|
||||
$('.inv-spec-cb').prop('checked', false);
|
||||
var specIds = (row.spec_ids || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||
|
|
@ -204,13 +242,16 @@ $med_specs = $med_specs ?? [];
|
|||
|
||||
function buildPayload() {
|
||||
return {
|
||||
id: parseInt($('#inv_id').val() || '0', 10),
|
||||
name: ($('#inv_name').val() || '').trim(),
|
||||
code: ($('#inv_code').val() || '').trim(),
|
||||
short_name: ($('#inv_short_name').val() || '').trim(),
|
||||
category_name: ($('#inv_category_name').val() || '').trim(),
|
||||
spec_ids: getCheckedSpecIds(),
|
||||
is_favourite: $('#inv_is_favourite').is(':checked') ? 1 : 0
|
||||
id: parseInt($('#inv_id').val() || '0', 10),
|
||||
name: ($('#inv_name').val() || '').trim(),
|
||||
code: ($('#inv_code').val() || '').trim(),
|
||||
short_name: ($('#inv_short_name').val() || '').trim(),
|
||||
category_name: ($('#inv_category_name').val() || '').trim(),
|
||||
snomed_concept_id: ($('#inv_snomed_concept_id').val() || '').trim(),
|
||||
snomed_term: ($('#inv_snomed_term').val() || '').trim(),
|
||||
loinc_code: ($('#inv_loinc_code').val() || '').trim(),
|
||||
spec_ids: getCheckedSpecIds(),
|
||||
is_favourite: $('#inv_is_favourite').is(':checked') ? 1 : 0
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -255,6 +296,23 @@ $med_specs = $med_specs ?? [];
|
|||
}
|
||||
},
|
||||
{ data: 'name' },
|
||||
{
|
||||
data: 'snomed_concept_id', defaultContent: '', orderable: false, className: 'text-center',
|
||||
render: function(val, type, row) {
|
||||
if (!val) return '<span class="text-muted small">—</span>';
|
||||
var tip = row.snomed_term ? ' title="' + $('<div>').text(row.snomed_term).html() + '"' : '';
|
||||
return '<span class="badge text-bg-light border font-monospace small" style="font-size:.68rem;cursor:default"' + tip + '>'
|
||||
+ $('<div>').text(val).html() + '</span>';
|
||||
}
|
||||
},
|
||||
{
|
||||
data: 'loinc_code', defaultContent: '', orderable: false, className: 'text-center',
|
||||
render: function(val) {
|
||||
if (!val) return '<span class="text-muted small">—</span>';
|
||||
return '<span class="badge text-bg-info text-white font-monospace small" style="font-size:.68rem;">'
|
||||
+ $('<div>').text(val).html() + '</span>';
|
||||
}
|
||||
},
|
||||
{ data: 'code', orderable: false },
|
||||
{ data: 'short_name', defaultContent: '', orderable: false },
|
||||
{ data: 'category_name', defaultContent: '', orderable: false },
|
||||
|
|
@ -379,9 +437,9 @@ $med_specs = $med_specs ?? [];
|
|||
$.get(url, function(data) {
|
||||
var rows = (data && data.data) ? data.data : [];
|
||||
if (!rows.length) { showToast('No data to export', 'err'); return; }
|
||||
var csv = 'Name,Code,Short Name,Category,Specialization IDs,Favourite\n';
|
||||
var csv = 'Name,SNOMED Code,SNOMED Term,LOINC,Code,Short Name,Category,Specialization IDs,Favourite\n';
|
||||
rows.forEach(function(r) {
|
||||
csv += [r.name, r.code, r.short_name || '', r.category_name || '', r.spec_ids || '', r.is_favourite || 0]
|
||||
csv += [r.name, r.snomed_concept_id || '', r.snomed_term || '', r.loinc_code || '', r.code, r.short_name || '', r.category_name || '', r.spec_ids || '', r.is_favourite || 0]
|
||||
.map(function(v) { return '"' + String(v).replace(/"/g, '""') + '"'; })
|
||||
.join(',') + '\n';
|
||||
});
|
||||
|
|
@ -393,6 +451,69 @@ $med_specs = $med_specs ?? [];
|
|||
}, 'json');
|
||||
});
|
||||
|
||||
/* ── SNOMED CT search autocomplete ─────────────────────────────── */
|
||||
(function() {
|
||||
var snomedTimer = null;
|
||||
var SNOMED_URL = '<?= base_url('Opd_prescription/opd_invest_snomed_search') ?>';
|
||||
|
||||
$('#inv_snomed_search').on('input', function() {
|
||||
var q = $(this).val().trim();
|
||||
$('#inv_snomed_dropdown').hide().empty();
|
||||
clearTimeout(snomedTimer);
|
||||
if (q.length < 2) return;
|
||||
snomedTimer = setTimeout(function() {
|
||||
$.get(SNOMED_URL, { q: q }, function(data) {
|
||||
var rows = (data && data.rows) ? data.rows : [];
|
||||
var $dd = $('#inv_snomed_dropdown').empty();
|
||||
if (!rows.length) {
|
||||
$dd.append('<button type="button" class="list-group-item list-group-item-action small text-muted disabled">No SNOMED results</button>');
|
||||
} else {
|
||||
rows.forEach(function(r) {
|
||||
var cid = r.concept_id || '';
|
||||
var term = r.term || r.fsn || cid;
|
||||
var $btn = $('<button type="button" class="list-group-item list-group-item-action small">')
|
||||
.attr('data-cid', cid)
|
||||
.attr('data-term', term);
|
||||
var label = $('<span>').text(term);
|
||||
if (cid) {
|
||||
label = $('<span>').append($('<span>').text(term)).append(
|
||||
$('<span class="text-muted font-monospace ms-1" style="font-size:.7rem;">').text('(' + cid + ')')
|
||||
);
|
||||
}
|
||||
$btn.append(label);
|
||||
$dd.append($btn);
|
||||
});
|
||||
}
|
||||
$dd.show();
|
||||
}, 'json').fail(function() { $('#inv_snomed_dropdown').hide(); });
|
||||
}, 320);
|
||||
});
|
||||
|
||||
$(document).on('click', '#inv_snomed_dropdown button[data-cid]', function() {
|
||||
var cid = $(this).attr('data-cid') || '';
|
||||
var term = $(this).attr('data-term') || '';
|
||||
$('#inv_snomed_concept_id').val(cid);
|
||||
$('#inv_snomed_term').val(term);
|
||||
$('#inv_snomed_search').val(cid ? cid : term);
|
||||
$('#inv_snomed_selected').show().text(term || cid);
|
||||
$('#btn_inv_snomed_clear').show();
|
||||
$('#inv_snomed_dropdown').hide().empty();
|
||||
});
|
||||
|
||||
$('#btn_inv_snomed_clear').on('click', function() {
|
||||
$('#inv_snomed_concept_id,#inv_snomed_term').val('');
|
||||
$('#inv_snomed_search').val('');
|
||||
$('#inv_snomed_selected').hide().text('');
|
||||
$(this).hide();
|
||||
});
|
||||
|
||||
$(document).on('click.snomedDd', function(e) {
|
||||
if (!$(e.target).closest('#inv_snomed_search, #inv_snomed_dropdown').length) {
|
||||
$('#inv_snomed_dropdown').hide();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* ── Init ───────────────────────────────────────────────────────── */
|
||||
$(function() {
|
||||
initTable();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -204,7 +204,7 @@
|
|||
/* ---- save ABHA to patient ---- */
|
||||
function saveAbhaToPatient(abhaId) {
|
||||
var csrf = csrfPair();
|
||||
var payload = { p_id: _patientId, abha_id: abhaId };
|
||||
var payload = { p_id: _patientId, abha_id: abhaId, verified: 1 };
|
||||
payload[csrf.name] = csrf.value;
|
||||
$.post('<?= base_url('billing/patient/update_abha') ?>', payload, function (res) {
|
||||
updateCsrf(res);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user