From 52f8d9691bc847e6e3844a4d519c7eff15341729 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sat, 5 Sep 2026 15:29:11 +0530 Subject: [PATCH] feat(abdm): enhance IPD discharge FHIR generator with NRCES v6.5.0 single-coding and structured dosage instructions --- app/Config/Routes.php | 1 + app/Controllers/AbdmGateway.php | 311 ++++++++--- app/Controllers/Ipd_discharge.php | 79 ++- .../Abdm/Fhir/FhirDocumentBuilder.php | 20 +- .../AbstractModuleFhirGenerator.php | 37 +- .../Generators/DischargeFhirGenerator.php | 518 +++++++++++++++--- app/Views/abdm/task_board.php | 366 +++++++++---- tests/unit/Abdm/FhirGeneratorsTest.php | 2 +- 8 files changed, 1049 insertions(+), 285 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 1df18ee..5f4ba29 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -768,6 +768,7 @@ $routes->post('AbdmGateway/share_diagnosis_report_bundle', 'AbdmGateway::shareDi $routes->post('AbdmGateway/shareDiagnosisReportBundle', 'AbdmGateway::shareDiagnosisReportBundle', ['filter' => $abdmPermFilter]); $routes->get('AbdmGateway/diagnosis_report_fhir_preview', 'AbdmGateway::diagnosisReportFhirPreview', ['filter' => $abdmPermFilter]); $routes->match(['GET', 'POST'], 'AbdmGateway/ipd_discharge_fhir_preview', 'AbdmGateway::ipdDischargeFhirPreview', ['filter' => $abdmPermFilter]); +$routes->post('AbdmGateway/save_ipd_discharge_fhir', 'AbdmGateway::saveIpdDischargeFhir', ['filter' => $abdmPermFilter]); $routes->get('AbdmGateway/immunization_fhir_preview', 'AbdmGateway::immunizationFhirPreview', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmunizationBundle', ['filter' => $abdmPermFilter]); $routes->post('AbdmGateway/share_wellness_bundle', 'AbdmGateway::shareWellnessBundle', ['filter' => $abdmPermFilter]); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 000054f..a463ebc 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -874,8 +874,8 @@ class AbdmGateway extends BaseController } // ========================================================================= - // HIP Consent Notify Callback — gateway forwards consent notify updates. - // POST /AbdmGateway/hip_consent_notify_callback (no auth filter — public webhook) + // HIP Consent Notify Callback - gateway forwards consent notify updates. + // POST /AbdmGateway/hip_consent_notify_callback (no auth filter - public webhook) // ========================================================================= public function hipConsentNotifyCallback() @@ -1711,11 +1711,34 @@ class AbdmGateway extends BaseController } $effectiveConsent = (string) ($consent['consent_handle'] ?? $consentHandle); - $fhirPayload = $this->buildIpdDischargeGatewayPayload($ipdId, $patientId, $abhaId); + $forceNewRecord = (int) $this->request->getPost('force_new_record'); + + $fhirPayload = $this->buildIpdDischargeGatewayPayload($ipdId, $patientId, $abhaId, $forceNewRecord === 1); if ($fhirPayload === null) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'Unable to prepare IPD discharge FHIR payload']); } + $bundle = (array) ($fhirPayload['bundle'] ?? []); + + // Override bundle with user edits if provided + $overrideJson = trim((string) $this->request->getPost('fhir_override_json')); + if ($overrideJson !== '') { + $parsedOverride = json_decode($overrideJson, true); + if (is_array($parsedOverride)) { + if (($parsedOverride['resourceType'] ?? '') === 'Bundle') { + $bundle = $parsedOverride; + $fhirPayload['bundle'] = $bundle; + } elseif (isset($parsedOverride['bundle']) && is_array($parsedOverride['bundle']) && ($parsedOverride['bundle']['resourceType'] ?? '') === 'Bundle') { + $bundle = $parsedOverride['bundle']; + $fhirPayload['bundle'] = $bundle; + } else { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON provided in the override editor.']); + } + } else { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON provided in the override editor.']); + } + } + $ipdRow = (array) ($fhirPayload['ipd_row'] ?? []); $patName = (string) ($fhirPayload['patient_name'] ?? ''); $doctorName = (string) ($fhirPayload['doctor_name'] ?? ''); @@ -1723,11 +1746,10 @@ class AbdmGateway extends BaseController $birthYear = (int) ($fhirPayload['year_of_birth'] ?? 0); $ccRef = (string) ($fhirPayload['care_context_reference'] ?? ('IPD-' . $ipdId . '-' . $visitDate)); $ccDisplay = (string) ($fhirPayload['care_context_display'] ?? ('Discharge Summary ' . $visitDate)); - $bundle = (array) ($fhirPayload['bundle'] ?? []); $attachmentPath = trim((string) ($fhirPayload['attachment_path'] ?? '')); $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - // ── Store health_record ─────────────────────────────────────────────── + // -- Store health_record ----------------------------------------------- $healthRecordId = $this->storeHealthRecord([ 'patient_id' => $patientId, 'abha_id' => $abhaId, @@ -1769,7 +1791,7 @@ class AbdmGateway extends BaseController ]); } - // ── Push via pushRecord() (POST /v3/records/push) ───────────────────── + // -- Push via pushRecord() (POST /v3/records/push) --------------------- $queueId = null; $bridgeRecordId = 0; $connectorError = null; @@ -1846,7 +1868,7 @@ class AbdmGateway extends BaseController return $this->response->setJSON(['ok' => 0, 'error' => 'lab_req_id and patient_id are required']); } - // ── Load lab request ────────────────────────────────────────────────── + // -- Load lab request -------------------------------------------------- $labReq = $this->db->table('lab_request') ->select('id, patient_name, lab_type, charge_id, Report_Data, report_data_Impression, status, reported_time') ->where('id', $labReqId) @@ -1873,7 +1895,7 @@ class AbdmGateway extends BaseController } $effectiveConsent = (string) ($consentRecord['consent_handle'] ?? $consentHandle); - // ── Load patient ────────────────────────────────────────────────────── + // -- Load patient ------------------------------------------------------ $patientRow = []; if ($this->db->tableExists('patient_master')) { $patientRow = $this->db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? []; @@ -1884,7 +1906,7 @@ class AbdmGateway extends BaseController } $patientBirthYear = $this->resolvePatientBirthYear($patientRow, str_contains($abhaId, '@') ? $abhaId : '', str_contains($abhaId, '@') ? '' : $abhaId); - // ── Load test / charge name ──────────────────────────────────────────── + // -- Load test / charge name -------------------------------------------- $testTitle = ''; $chargeId = (int) ($labReq->charge_id ?? 0); if ($chargeId > 0 && $this->db->tableExists('charge_master')) { @@ -1895,15 +1917,15 @@ class AbdmGateway extends BaseController $testTitle = $this->mapLabTypeToTitle((int) ($labReq->lab_type ?? 0)); } - // ── Load hospital profile ───────────────────────────────────────────── + // -- Load hospital profile --------------------------------------------- $hospitalProfile = $this->getHospitalProfileForFhir(); - // ── Parse reported time ─────────────────────────────────────────────── + // -- Parse reported time ----------------------------------------------- $reportedRaw = trim((string) ($labReq->reported_time ?? '')); $reportedAt = $reportedRaw !== '' ? (new \DateTime($reportedRaw, new \DateTimeZone('Asia/Kolkata')))->format('Y-m-d\TH:i:sP') : ''; $visitDate = $reportedRaw !== '' ? date('Y-m-d', strtotime($reportedRaw)) : date('Y-m-d'); - // ── Build FHIR DiagnosticReportRecord bundle ─────────────────────────── + // -- Build FHIR DiagnosticReportRecord bundle --------------------------- $fhir = new FhirR4Builder(); $patient = [ @@ -1931,7 +1953,7 @@ class AbdmGateway extends BaseController $diagnosticReport['section_snomed_display'] = 'Computed tomography imaging report'; } - // ── Load LOINC code for the panel from lab_repo ─────────────────────── + // -- Load LOINC code for the panel from lab_repo ----------------------- $labRepoRow = $this->db->table('lab_request lr') ->select('lr.lab_repo_id, repo.loinc_code AS repo_loinc_code, repo.Title') ->join('lab_repo repo', 'repo.mstRepoKey = lr.lab_repo_id', 'left') @@ -1944,7 +1966,7 @@ class AbdmGateway extends BaseController $diagnosticReport['loinc_code'] = $repoLoincCode; } - // ── Build structured observations from lab_request_item + lab_tests ─── + // -- Build structured observations from lab_request_item + lab_tests --- $observations = $this->buildLabObservations($labReqId, (string) ($labReq->status ?? '0')); $organization = $hospitalProfile['name'] !== '' @@ -1972,7 +1994,7 @@ class AbdmGateway extends BaseController $bundle = $fhir->buildLabReportBundle($patient, $diagnosticReport, $observations, $practitioner, $organization, $encounter, $pdfAttachment); $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - // ── Store health_record ─────────────────────────────────────────────── + // -- Store health_record ----------------------------------------------- $ccRef = 'LAB-' . $labReqId . '-' . $visitDate; $healthRecordId = $this->storeHealthRecord([ 'patient_id' => $patientId, @@ -2014,7 +2036,7 @@ class AbdmGateway extends BaseController ]); } - // ── Push via pushRecord() (POST /v3/records/push) ───────────────────── + // -- Push via pushRecord() (POST /v3/records/push) --------------------- $queueId = null; $bridgeRecordId = 0; $connectorError = null; @@ -2609,8 +2631,39 @@ class AbdmGateway extends BaseController $abhaId = $ident['abha_address'] ?: $ident['abha_id']; } + $forceNewRecord = (int) ($this->request->getGet('force_rebuild') ?? 0); try { - $fhirPayload = $this->buildIpdDischargeGatewayPayload($ipdId, $patientId, $abhaId); + if ($forceNewRecord !== 1 && $this->db && $this->db->tableExists('health_records')) { + $existing = $this->db->table('health_records') + ->where('entity_type', 'ipd') + ->where('entity_id', (string) $ipdId) + ->whereIn('hi_type', ['DischargeSummaryRecord', 'DischargeSummary']) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if (! empty($existing)) { + $raw = (string) ($existing['record_data'] ?? ''); + if ($raw === '' && ! empty($existing['fhir_bundle_enc'])) { + $enc = new FhirEncryptionService(); + $raw = $enc->decrypt((string) $existing['fhir_bundle_enc']); + } + if ($raw !== '') { + $savedBundle = json_decode($raw, true); + if (is_array($savedBundle) && ($savedBundle['resourceType'] ?? '') === 'Bundle') { + return $this->response->setJSON([ + 'status' => 'ok', + 'ipd_id' => $ipdId, + 'patient_id' => $patientId, + 'abha_id' => $abhaId, + 'bundle' => $savedBundle, + 'source' => 'saved_record', + ]); + } + } + } + } + + $fhirPayload = $this->buildIpdDischargeGatewayPayload($ipdId, $patientId, $abhaId, $forceNewRecord === 1); if ($fhirPayload === null) { return $this->response->setStatusCode(404)->setJSON([ 'status' => 'error', @@ -2636,10 +2689,113 @@ class AbdmGateway extends BaseController } } + public function saveIpdDischargeFhir() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $ipdId = (int) ($this->request->getPost('ipd_id') ?? 0); + $patientId = (int) ($this->request->getPost('patient_id') ?? 0); + $abhaId = trim((string) ($this->request->getPost('abha_id') ?? '')); + $rawJson = trim((string) ($this->request->getPost('fhir_json') ?? '')); + + if ($ipdId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'ipd_id is required']); + } + + if ($rawJson === '') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'fhir_json is required']); + } + + $parsed = json_decode($rawJson, true); + if (! is_array($parsed)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid JSON syntax: ' . json_last_error_msg()]); + } + + $bundle = ($parsed['resourceType'] ?? '') === 'Bundle' ? $parsed : ($parsed['bundle'] ?? null); + if (! is_array($bundle) || ($bundle['resourceType'] ?? '') !== 'Bundle') { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'The provided JSON is not a valid FHIR Bundle']); + } + + if ($patientId <= 0 && $this->db && $this->db->tableExists('ipd_master')) { + $ipdRow = $this->db->table('ipd_master')->where('id', $ipdId)->get(1)->getRowArray() ?? []; + $patientId = (int) ($ipdRow['p_id'] ?? $ipdRow['patient_id'] ?? 0); + } + + if ($abhaId === '' && $patientId > 0) { + $ident = $this->resolvePatientAbhaIdentity($patientId); + $abhaId = $ident['abha_address'] ?: $ident['abha_id']; + } + + $bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $ccRef = 'IPD-' . $ipdId . '-' . date('Y-m-d'); + + $healthRecordId = $this->storeHealthRecord([ + 'patient_id' => $patientId > 0 ? $patientId : null, + 'abha_id' => $abhaId, + 'hi_type' => 'DischargeSummaryRecord', + 'entity_type' => 'ipd', + 'entity_id' => (string) $ipdId, + 'fhir_bundle' => $bundleJson, + 'care_context_reference' => $ccRef, + ]); + + $abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId); + $abhaNumber = $abhaIdentity['abha_id']; + $abhaAddress = $abhaIdentity['abha_address']; + + $patName = ''; + if ($this->db && $this->db->tableExists('patient_master')) { + $pRow = $this->db->table('patient_master')->where('p_id', $patientId)->get(1)->getRowArray() ?? []; + $patName = trim((string) (($pRow['p_fname'] ?? '') . ' ' . ($pRow['p_lname'] ?? ''))); + } + + $queueId = null; + $bridgeRecordId = 0; + $connectorError = null; + if ($abhaNumber !== '' || $abhaAddress !== '') { + try { + $pushResult = $this->connector->pushRecord([ + 'patient_id' => (string) $patientId, + 'patient_name' => $patName !== '' ? $patName : ('PATIENT-' . $patientId), + 'abha_id' => $abhaNumber, + 'abha_address' => $abhaAddress, + 'hi_type' => 'DischargeSummaryRecord', + 'record_type' => 'DischargeSummaryRecord', + 'visit_date' => date('Y-m-d'), + 'care_context_reference' => $ccRef, + 'care_context_display' => 'Discharge Summary ' . date('Y-m-d'), + 'notes' => 'Discharge Summary ' . date('Y-m-d'), + 'queue_id' => 'IPD-' . $ipdId . '-' . date('Y-m-d'), + 'record_data' => $bundle, + ]); + $this->logGatewayPushResolution('ipd_discharge', $pushResult); + $queueId = $this->extractGatewayPushQueueId($pushResult); + $bridgeRecordId = $this->extractGatewayPushRecordId($pushResult); + } catch (\Throwable $e) { + $connectorError = $e->getMessage(); + log_message('error', 'saveIpdDischargeFhir pushRecord error: ' . $e->getMessage()); + } + if ($healthRecordId > 0) { + $this->updateHealthRecordTxn($healthRecordId, $queueId ?? '', $connectorError, $bridgeRecordId); + } + } + + return $this->response->setJSON([ + 'ok' => 1, + 'status' => 'ok', + 'message' => 'IPD discharge FHIR record updated, saved and pushed to bridge.', + 'health_record_id' => $healthRecordId, + 'queue_id' => $queueId, + 'bundle' => $bundle, + ]); + } + /** * @return array|null */ - private function buildIpdDischargeGatewayPayload(int $ipdId, int $patientId, string $preferredAbhaId = ''): ?array + private function buildIpdDischargeGatewayPayload(int $ipdId, int $patientId, string $preferredAbhaId = '', bool $isNewRecord = false): ?array { $ipdRow = ($this->db && $this->db->tableExists('ipd_master')) ? ($this->db->table('ipd_master')->where('id', $ipdId)->get(1)->getRowArray() ?? []) @@ -2985,9 +3141,12 @@ class AbdmGateway extends BaseController $documents = $this->buildIpdPdfDocuments($ipdId, $dischargeRaw); $locationInfo = $this->resolveIpdLocation($ipdId); + $uniqueSuffix = $isNewRecord ? ('-v' . time()) : ''; + $source = [ 'record_id' => (string) $ipdId, - 'bundle_identifier' => 'discharge-' . (trim((string) ($ipdRow['ipd_code'] ?? '')) ?: (string) $ipdId), + 'care_context_reference' => 'DISCHARGE-' . $ipdId . '-S' . $ipdId . '-' . $visitDate . $uniqueSuffix, + 'bundle_identifier' => 'discharge-' . (trim((string) ($ipdRow['ipd_code'] ?? '')) ?: (string) $ipdId) . $uniqueSuffix, 'session_id' => (string) $ipdId, 'visit_date' => $visitDate, 'completed_at' => $this->toIsoDateTimeOrNow($dischargeRaw), @@ -3013,8 +3172,8 @@ class AbdmGateway extends BaseController 'abha_address' => $abhaAddress, ], 'encounter' => [ - 'id' => trim((string) ($ipdRow['ipd_code'] ?? '')) ?: (string) $ipdId, - 'ipd_no' => trim((string) ($ipdRow['ipd_code'] ?? '')) ?: ('IPD-' . $ipdId), + 'id' => (trim((string) ($ipdRow['ipd_code'] ?? '')) ?: (string) $ipdId) . $uniqueSuffix, + 'ipd_no' => (trim((string) ($ipdRow['ipd_code'] ?? '')) ?: ('IPD-' . $ipdId)) . $uniqueSuffix, 'class_code' => 'IMP', 'start' => $this->toIsoDateTimeOrNow($admissionRaw), 'end' => $this->toIsoDateTimeOrNow($dischargeRaw), @@ -3446,41 +3605,41 @@ class AbdmGateway extends BaseController 'BT' => 'BT (BED TIME)', ]; static $whenHindiMap = [ - 'BF' => 'भोजन से पहले', 'BEFORE FOOD' => 'भोजन से पहले', - 'AF' => 'भोजन के बाद', 'AFTER FOOD' => 'भोजन के बाद', - 'WF' => 'भोजन के साथ', 'WITH FOOD' => 'भोजन के साथ', - 'ES' => 'सुबह खाली पेट', 'EMPTY STOMACH' => 'सुबह खाली पेट', - 'BBF'=> 'नाश्ते से पहले', 'BEFORE BREAKFAST' => 'नाश्ते से पहले', - 'ABF'=> 'नाश्ते के बाद', 'AFTER BREAKFAST' => 'नाश्ते के बाद', - 'BL' => 'दोपहर के भोजन से पहले', 'BEFORE LUNCH' => 'दोपहर के भोजन से पहले', - 'AL' => 'दोपहर के भोजन के बाद', 'AFTER LUNCH' => 'दोपहर के भोजन के बाद', - 'BD' => 'रात के भोजन से पहले', 'BEFORE DINNER'=> 'रात के भोजन से पहले', - 'AD' => 'रात के भोजन के बाद', 'AFTER DINNER' => 'रात के भोजन के बाद', - 'BT' => 'रात को सोते समय', 'BED TIME' => 'रात को सोते समय', + 'BF' => ' ', 'BEFORE FOOD' => ' ', + 'AF' => ' ', 'AFTER FOOD' => ' ', + 'WF' => ' ', 'WITH FOOD' => ' ', + 'ES' => ' ', 'EMPTY STOMACH' => ' ', + 'BBF'=> ' ', 'BEFORE BREAKFAST' => ' ', + 'ABF'=> ' ', 'AFTER BREAKFAST' => ' ', + 'BL' => ' ', 'BEFORE LUNCH' => ' ', + 'AL' => ' ', 'AFTER LUNCH' => ' ', + 'BD' => ' ', 'BEFORE DINNER'=> ' ', + 'AD' => ' ', 'AFTER DINNER' => ' ', + 'BT' => ' ', 'BED TIME' => ' ', ]; static $freqHindiMap = [ - 'OD' => 'दिन में एक बार (OD)', 'BD' => 'दिन में दो बार (BD)', - 'TDS' => 'दिन में तीन बार (TDS)', 'TID' => 'दिन में तीन बार (TID)', - 'QID' => 'दिन में चार बार (QID)', 'HS' => 'रात को सोते समय (HS)', - 'SOS' => 'ज़रूरत पड़ने पर (SOS)', 'STAT'=> 'तुरंत एक बार (STAT)', - 'ALTERNATE DAY' => 'एक दिन छोड़कर', - 'DAILY' => 'प्रतिदिन', - 'WEEKLY' => 'हफ़्ते में एक बार', - 'MONTHLY' => 'महीने में एक बार', + 'OD' => ' (OD)', 'BD' => ' (BD)', + 'TDS' => ' (TDS)', 'TID' => ' (TID)', + 'QID' => ' (QID)', 'HS' => ' (HS)', + 'SOS' => ' (SOS)', 'STAT'=> ' (STAT)', + 'ALTERNATE DAY' => ' ', + 'DAILY' => '', + 'WEEKLY' => ' ', + 'MONTHLY' => ' ', ]; static $remarkHindiMap = [ - 'TAKE WITH MILK' => 'दूध के साथ लें', - 'TAKE WITH WARM WATER' => 'गुनगुने पानी के साथ लें', - 'AVOID SOUR FOOD AND DAIRY PRODUCTS'=> 'खट्टा और डेयरी उत्पाद न लें', - 'TAKE AFTER MEALS' => 'भोजन के बाद लें', - 'TAKE ON AN EMPTY STOMACH EARLY MORNING' => 'सुबह खाली पेट लें', - 'CHEW WELL BEFORE SWALLOWING' => 'चबाकर खाएं', - 'DISSOLVE IN HALF GLASS OF WATER' => 'आधे गिलास पानी में घोलकर लें', - 'APPLY LOCALLY TWICE DAILY' => 'दिन में दो बार लगाएं', - 'DO NOT CRUSH OR CHEW TABLET' => 'गोली को तोड़े या चबाएं नहीं', - 'AVOID ALCOHOL WHILE TAKING THIS MEDICINE' => 'शराब का सेवन न करें', - 'COMPLETE FULL COURSE OF ANTIBIOTICS' => 'एंटीबायोटिक का पूरा कोर्स लें', - 'DRINK PLENTY OF FLUIDS / WATER' => 'प्रचुर मात्रा में पानी पिएं', + 'TAKE WITH MILK' => ' ', + 'TAKE WITH WARM WATER' => ' ', + 'AVOID SOUR FOOD AND DAIRY PRODUCTS'=> ' ', + 'TAKE AFTER MEALS' => ' ', + 'TAKE ON AN EMPTY STOMACH EARLY MORNING' => ' ', + 'CHEW WELL BEFORE SWALLOWING' => ' ', + 'DISSOLVE IN HALF GLASS OF WATER' => ' ', + 'APPLY LOCALLY TWICE DAILY' => ' ', + 'DO NOT CRUSH OR CHEW TABLET' => ' ', + 'AVOID ALCOHOL WHILE TAKING THIS MEDICINE' => ' ', + 'COMPLETE FULL COURSE OF ANTIBIOTICS' => ' ', + 'DRINK PLENTY OF FLUIDS / WATER' => ' ', ]; static $doseCache = null; @@ -3618,7 +3777,7 @@ class AbdmGateway extends BaseController $daysNum = is_numeric($daysClean) ? (int) $daysClean : 0; $dirParts[] = $daysNum > 0 ? ('for ' . $daysNum . ' days') : ('for ' . $daysClean); if ($daysNum > 0) { - $dirLocalParts[] = $daysNum . ' दिन'; + $dirLocalParts[] = $daysNum . ' '; } } @@ -3881,9 +4040,9 @@ class AbdmGateway extends BaseController } // ========================================================================= - // Record Linked Callback — ABDM notifies HMS when a record is successfully + // 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) + // POST /AbdmGateway/record_linked_callback (no auth filter - public webhook) // ========================================================================= public function recordLinkedCallback() @@ -3987,8 +4146,8 @@ class AbdmGateway extends BaseController } // ========================================================================= - // Consent Revoked Callback — ABDM notifies HMS when consent is revoked. - // POST /AbdmGateway/consent_revoked_callback (no auth filter — public webhook) + // Consent Revoked Callback - ABDM notifies HMS when consent is revoked. + // POST /AbdmGateway/consent_revoked_callback (no auth filter - public webhook) // ========================================================================= public function consentRevokedCallback() @@ -4044,7 +4203,7 @@ class AbdmGateway extends BaseController return $this->response->setStatusCode(404)->setJSON(['ok' => 0, 'error_text' => 'consent_handle not found']); } - // Idempotency — already revoked + // Idempotency - already revoked if (strtolower((string) ($existing['consent_status'] ?? '')) === 'revoked') { return $this->response->setJSON(['ok' => 1, 'consent_handle' => $consentHandle, 'status' => 'revoked', 'duplicate' => 1]); } @@ -4183,7 +4342,7 @@ class AbdmGateway extends BaseController ); // 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. + // No consent needed - bridge stores record and serves it when ABDM requests data. $result = $this->connector->pushRecord([ 'patient_id' => (string) $patientId, 'patient_name' => $sanitizedPatientName !== '' ? $sanitizedPatientName : ('PATIENT-' . $patientId), @@ -4238,7 +4397,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // List health_records for a patient (AJAX — used by ABDM task board) + // List health_records for a patient (AJAX - used by ABDM task board) // GET /AbdmGateway/health_records_list?patient_id=X&abha_id=Y // ========================================================================= @@ -4516,7 +4675,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // Bridge record status — proxy to GET /api/v3/records/{id} + // Bridge record status - proxy to GET /api/v3/records/{id} // GET /AbdmGateway/bridge_record_status/:id // ========================================================================= @@ -4536,7 +4695,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // Trigger HIP-initiated ABDM linking — POST /api/v3/records/{id}/share + // Trigger HIP-initiated ABDM linking - POST /api/v3/records/{id}/share // POST /AbdmGateway/bridge_record_share/:id // ========================================================================= @@ -4570,7 +4729,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // OPD running token status — GET /api/v3/opd/running-token-status + // OPD running token status - GET /api/v3/opd/running-token-status // GET /AbdmGateway/opd_running_token_status // ========================================================================= @@ -5084,7 +5243,7 @@ class AbdmGateway extends BaseController $append($rows, 'ABHA', 4); } - // Demographic matches — primary criteria + // Demographic matches - primary criteria $nameUpper = strtoupper(trim((string) ($identity['patient_name'] ?? ''))); $rawGender = strtoupper(trim((string) ($identity['gender'] ?? ''))); $genderDb = ($rawGender === 'F' || $rawGender === '2') ? 2 : ($rawGender !== '' ? 1 : null); @@ -5096,7 +5255,7 @@ class AbdmGateway extends BaseController $ageYears = preg_match('/^\d{1,3}$/', $age) ? (int) $age : null; if ($nameUpper !== '' && $dobValid !== '') { - // Name + exact DOB [+ gender] — high confidence (score 3) + // Name + exact DOB [+ gender] - high confidence (score 3) $q = $this->db->table('patient_master')->select($select) ->where('UPPER(p_fname)', $nameUpper) ->where('dob', $dobValid); @@ -5111,7 +5270,7 @@ class AbdmGateway extends BaseController } if ($nameUpper !== '' && $yearOnly > 0) { - // Name + year of birth [+ gender] — medium confidence (score 2) + // Name + year of birth [+ gender] - medium confidence (score 2) $q = $this->db->table('patient_master')->select($select) ->where('UPPER(p_fname)', $nameUpper) ->where('YEAR(dob)', $yearOnly); @@ -5132,7 +5291,7 @@ class AbdmGateway extends BaseController $append([$row], $reason, $score); } - // Phone match — supplementary only (score 1), ambiguous for families + // Phone match - supplementary only (score 1), ambiguous for families if (($identity['phone'] ?? '') !== '') { $rows = $this->db->table('patient_master')->select($select)->where('mphone1', $identity['phone'])->limit(10)->get()->getResultArray(); $append($rows, 'Phone', 1); @@ -5506,7 +5665,7 @@ class AbdmGateway extends BaseController } /** - * HMS sits behind NAT, so ABDM/bridge discovery callbacks can never reach it — + * HMS sits behind NAT, so ABDM/bridge discovery callbacks can never reach it - * records must be pushed to the gateway to become discoverable in the PHR. * Set ABDM_GATEWAY_PUSH_MODE=0 only for a callback-reachable deployment. */ @@ -8211,7 +8370,7 @@ class AbdmGateway extends BaseController // ========================================================================= // M1 ABHA OTP Flows - // Calls EAtriaBridgeConnector synchronously — requires eatria_bridge connector. + // Calls EAtriaBridgeConnector synchronously - requires eatria_bridge connector. // ========================================================================= /** @@ -8777,7 +8936,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // Gateway status — hospital info + ABDM connectivity + // Gateway status - hospital info + ABDM connectivity // GET /AbdmGateway/gateway_status // ========================================================================= @@ -8792,7 +8951,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // Bridge records — list stored records from bridge + // Bridge records - list stored records from bridge // GET /AbdmGateway/bridge_records_list // ========================================================================= @@ -8816,7 +8975,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // HIP-Initiated Linking — Step 1: request link token + // HIP-Initiated Linking - Step 1: request link token // POST /AbdmGateway/hip_link_token // Body: { abha_address, name, gender, year_of_birth [, abha_number] } // ========================================================================= @@ -8841,7 +9000,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // HIP-Initiated Linking — Step 2: link care contexts + // HIP-Initiated Linking - Step 2: link care contexts // POST /AbdmGateway/hip_link_carecontext // Body: { abha_address, link_token_id, patient_ref, display, hi_type, care_contexts[] } // ========================================================================= @@ -8866,7 +9025,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // HIP-Initiated Linking — fetch all linked care contexts for a patient + // HIP-Initiated Linking - fetch all linked care contexts for a patient // GET /AbdmGateway/hip_patient_links?abha_address=xxx&limit=20 // ========================================================================= @@ -8890,7 +9049,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // HIP-Initiated Linking — notify ABDM of care-context update + // HIP-Initiated Linking - notify ABDM of care-context update // POST /AbdmGateway/hip_link_notify // Body: { abha_address, care_context_reference, hi_type, date_of_record } // ========================================================================= @@ -8915,7 +9074,7 @@ class AbdmGateway extends BaseController } // ========================================================================= - // HIP-Initiated Linking — send ABDM deep-link SMS to patient + // HIP-Initiated Linking - send ABDM deep-link SMS to patient // POST /AbdmGateway/hip_sms_notify // Body: { phone_number [, hip_name] } // ========================================================================= diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index 22a6386..bcaa24c 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -9189,6 +9189,13 @@ class Ipd_discharge extends BaseController } } + + $uiSummaryInvestigation = ''; + if ($this->tableHasColumns('ipd_discharge_investigtions_inhos', ['ipd_id', 'comp_remark'])) { + $inhosRow = $this->firstRowByIpd('ipd_discharge_investigtions_inhos', $ipdId); + $uiSummaryInvestigation = trim((string) ($inhosRow['comp_remark'] ?? '')); + } + $investigationRows = []; if ($this->tableHasColumns('ipd_discharge_1_d', ['ipd_d_id'])) { $cols = $this->db->getFieldNames('ipd_discharge_1_d') ?? []; @@ -9329,6 +9336,59 @@ class Ipd_discharge extends BaseController } } + + $uiClinicalHistoryText = ''; + $historyList = []; + + $opdHistorySnapshot = []; + if ($patientId > 0 && $this->db->tableExists('patient_master')) { + $historyFields = [ + 'is_smoking' => 'Smoking', + 'is_alcohol' => 'Alcohol', + 'is_drug_abuse' => 'Drug Abuse', + 'is_tobacoo' => 'Tobacco', + 'is_hypertesion' => 'Hypertension', + 'is_diabetes' => 'Diabetes', + 'is_ischaemic_heart_ds' => 'Ischemic Heart Disease', + 'is_asthma_copd' => 'Asthma/COPD', + 'is_bleeding_disorder' => 'Bleeding Disorder', + 'is_heapatitis_type' => 'Hepatitis', + 'is_blood_transfusion' => 'Blood Transfusion', + 'is_hiv_std' => 'HIV/STD', + 'is_epilepsy' => 'Epilepsy', + 'is_tb' => 'TB', + 'is_psychiatric_illness' => 'Psychiatric Illness', + 'is_thyroid' => 'Thyroid', + ]; + foreach ($historyFields as $f => $label) { + if ((int) ($patientRow[$f] ?? 0) === 1) { + $historyList[] = $label . ': Yes'; + } + } + } + + if (!empty($instructionRows)) { + $instructionRow = $instructionRows[0]; + $instructionMeta = $this->parseInstructionMetaPayload((string) ($instructionRow['comp_report'] ?? '')); + $nabhMeta = is_array($instructionMeta['nabh'] ?? null) ? $instructionMeta['nabh'] : []; + + $historyLabels = [ + 'drug_allergy_status' => 'Drug Allergy Status', + 'drug_allergy_details' => 'Drug Allergy Details', + 'adr_history' => 'ADR History', + 'current_medications' => 'Current Medications', + 'co_morbidities' => 'Comorbidities', + 'hpi_note' => 'HPI Note' + ]; + foreach ($historyLabels as $key => $label) { + $val = trim((string) ($nabhMeta[$key] ?? '')); + if ($val !== '') { + $historyList[] = $label . ': ' . $val; + } + } + } + $uiClinicalHistoryText = implode("\n", $historyList); + $documentsList = $this->buildIpdPdfDocumentsList($ipdId, $dischargeIso); $locationInfo = $this->resolveIpdLocation($ipdId); @@ -9377,15 +9437,18 @@ class Ipd_discharge extends BaseController 'room' => (string) ($locationInfo['room'] ?? ''), 'bed' => (string) ($locationInfo['bed'] ?? ''), ], - 'chief_complaints' => $chiefComplaintsList, - 'chief_complaint_narrative' => $complaintRemarkText, - 'conditions' => $conditionRows, - 'procedures' => $procedureRows, - 'medications' => $medicationRows, - 'observations' => $observationRows, - 'investigations' => $investigationRows, + 'ui_complaints' => $chiefComplaintsList, + 'ui_complaint_narrative' => $complaintRemarkText, + 'ui_clinical_history' => $uiClinicalHistoryText, + 'ui_physical_exam' => $observationRows, + 'ui_investigations' => $investigationRows, + 'ui_surgeries' => $procedureRows, + 'ui_final_diagnosis' => $conditionRows, + 'ui_summary_investigation' => $uiSummaryInvestigation, + 'ui_course_treatment' => $courseRows, + 'ui_discharge_medicine' => $medicationRows, + 'ui_discharge_summary' => $carePlanRows, 'allergies' => $allergyRows, - 'care_plans' => $carePlanRows, 'documents' => $documentsList, 'template' => $this->resolveAbdmDischargeTemplate(), ]; diff --git a/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php b/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php index 51bc3e7..6f8f501 100644 --- a/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php +++ b/app/Libraries/Abdm/Fhir/FhirDocumentBuilder.php @@ -20,6 +20,15 @@ class FhirDocumentBuilder { $this->bundleMeta = [ 'resourceType' => 'Bundle', + 'id' => $identifierValue, + 'meta' => [ + 'profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle'], + 'security' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-Confidentiality', + 'code' => 'V', + 'display' => 'very restricted' + ]] + ], 'identifier' => [ 'system' => 'https://hms.local/fhir/document', 'value' => $identifierValue, @@ -174,13 +183,16 @@ class FhirDocumentBuilder private function normalizeEntryReferences(array $entries): array { $referenceMap = []; + $idMap = []; foreach ($entries as $entry) { $resource = (array) ($entry['resource'] ?? []); $id = (string) ($resource['id'] ?? ''); if ($id === '') { continue; } - $referenceMap['urn:uuid:' . $id] = 'urn:uuid:' . $this->uuidForResource($resource); + $newUuid = $this->uuidForResource($resource); + $referenceMap['urn:uuid:' . $id] = 'urn:uuid:' . $newUuid; + $idMap[$id] = $newUuid; } foreach ($entries as &$entry) { @@ -189,7 +201,11 @@ class FhirDocumentBuilder if ($id !== '') { $entry['fullUrl'] = $referenceMap['urn:uuid:' . $id]; } - $entry['resource'] = $this->rewriteReferences($resource, $referenceMap); + $resource = $this->rewriteReferences($resource, $referenceMap); + if ($id !== '') { + $resource['id'] = $idMap[$id]; + } + $entry['resource'] = $resource; } unset($entry); diff --git a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php index 4c6124e..96bc0be 100644 --- a/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/AbstractModuleFhirGenerator.php @@ -59,7 +59,7 @@ abstract class AbstractModuleFhirGenerator ]], 'text' => [ 'status' => 'generated', - 'div' => '

Patient: ' . htmlspecialchars((string) ($source['patient']['name'] ?? 'Patient')) . ' (' . htmlspecialchars(ucfirst($gender)) . ')

', + 'div' => '

Patient: ' . $this->escapeXhtml((string) ($source['patient']['name'] ?? 'Patient')) . ' (' . $this->escapeXhtml(ucfirst($gender)) . ')

', ], 'identifier' => [ [ @@ -185,12 +185,12 @@ abstract class AbstractModuleFhirGenerator */ protected function buildEncounter(array $source): ?array { - if (! isset($source['encounter']) || ! is_array($source['encounter'])) { - return null; + $enc = $source['encounter'] ?? []; + if (! is_array($enc)) { + $enc = []; } - $enc = $source['encounter']; - $id = (string) ($enc['id'] ?? ''); + $id = trim((string) ($enc['id'] ?? $source['record_id'] ?? '')); if ($id === '') { return null; } @@ -251,7 +251,7 @@ abstract class AbstractModuleFhirGenerator 'location' => [ 'display' => $locDisplay, ], - 'status' => 'active', + 'status' => 'completed', 'period' => [ 'start' => $start, 'end' => $end, @@ -270,7 +270,7 @@ abstract class AbstractModuleFhirGenerator ]], 'text' => [ 'status' => 'generated', - 'div' => '

Encounter: ' . htmlspecialchars($classDisplay) . ' ' . htmlspecialchars($id) . '

', + 'div' => '

Encounter: ' . $this->escapeXhtml($classDisplay) . ' ' . $this->escapeXhtml($id) . '

', ], 'status' => 'finished', 'class' => [ @@ -287,6 +287,16 @@ abstract class AbstractModuleFhirGenerator 'end' => $end, ], 'identifier' => $identifiers, + 'hospitalization' => [ + 'dischargeDisposition' => [ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/discharge-disposition', + 'code' => 'home', + 'display' => 'Home', + ]], + 'text' => 'Discharged to Home Care', + ] + ], ]; if (! empty($locationList)) { @@ -329,7 +339,7 @@ abstract class AbstractModuleFhirGenerator ]], 'text' => [ 'status' => 'generated', - 'div' => '

Practitioner: ' . htmlspecialchars($name) . '

', + 'div' => '

Practitioner: ' . $this->escapeXhtml($name) . '

', ], 'identifier' => [[ 'system' => 'https://doctor.ndhm.gov.in', @@ -364,7 +374,7 @@ abstract class AbstractModuleFhirGenerator ]], 'text' => [ 'status' => 'generated', - 'div' => '

Organization: ' . htmlspecialchars($name) . '

', + 'div' => '

Organization: ' . $this->escapeXhtml($name) . '

', ], 'name' => $name, 'identifier' => [[ @@ -403,4 +413,13 @@ abstract class AbstractModuleFhirGenerator return trim(implode("\n", $lines)); } + + /** + * Safely escapes text for inclusion in FHIR XHTML div sections. + * Ensures valid UTF-8 encoding so non-ASCII characters like Hindi script are preserved. + */ + protected function escapeXhtml(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } } diff --git a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php index 21a26b6..2318ad9 100644 --- a/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php +++ b/app/Libraries/Abdm/Fhir/Generators/DischargeFhirGenerator.php @@ -20,32 +20,32 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $bundleIdentifier = 'discharge-' . $recordId . '-' . strtotime($timestamp); } - $careContextReference = 'DISCHARGE-' . $recordId . '-S' . $sessionId . '-' . $visitDate; + $careContextReference = (string) ($source['care_context_reference'] ?? ('DISCHARGE-' . $recordId . '-S' . $sessionId . '-' . $visitDate)); $careContextDisplay = 'Discharge Summary ' . $visitDate; $builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder(); $builder ->buildBundleMeta($bundleIdentifier, $timestamp) - ->updateBundleMeta(['meta' => ['profile' => [ - 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', - ]]]) - ->buildComposition($this->buildBaseComposition($source, 'IPD Discharge Summary', '373942005', 'Discharge summary')) + ->buildComposition($this->buildBaseComposition($source, 'IPD Discharge Summary', '18842-5', 'Discharge summary')) ->updateComposition([ - 'meta' => ['profile' => [ - 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord', - ]], - 'type' => ['coding' => [[ - 'system' => 'http://snomed.info/sct', - 'code' => '373942005', - 'display' => 'Discharge summary', - ]], 'text' => 'Discharge Summary'], + 'meta' => [ + 'versionId' => '1', + 'lastUpdated' => $timestamp, + 'profile' => [ + 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord', + ], + ], + 'type' => ['coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => '373942005', + 'display' => 'Discharge summary', + ], + ], 'text' => 'Discharge Summary'], ]) ->addPatient($this->buildBasePatient($source)); $encounter = $this->buildEncounter($source); - if (is_array($encounter)) { - $builder->addEncounter($encounter); - } $practitioner = $this->buildPractitioner($source); if (! is_array($practitioner)) { @@ -98,18 +98,22 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $diagnosesList = $chiefComplaintsList; } + $encounterDiagnoses = []; + $conditionGroups = [ [ 'items' => $chiefComplaintsList, 'prefix' => 'discharge-chief-complaint-', 'category_code' => 'problem-list-item', 'category_display' => 'Problem List Item', + 'is_encounter_diagnosis' => false, ], [ 'items' => $diagnosesList, 'prefix' => 'discharge-diagnosis-', 'category_code' => 'encounter-diagnosis', 'category_display' => 'Encounter Diagnosis', + 'is_encounter_diagnosis' => true, ], ]; foreach ($conditionGroups as $group) { @@ -129,9 +133,25 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'display' => $fallback['display'], ]]; } + + $conditionId = $group['prefix'] . $recordId . '-' . $idx; + + if ($group['is_encounter_diagnosis']) { + $encounterDiagnoses[] = [ + 'condition' => ['reference' => 'urn:uuid:' . $conditionId, 'display' => $text], + 'use' => [ + 'coding' => [[ + 'system' => 'http://terminology.hl7.org/CodeSystem/diagnosis-role', + 'code' => 'DD', + 'display' => 'Discharge diagnosis' + ]] + ] + ]; + } + $builder->addCondition([ 'resourceType' => 'Condition', - 'id' => $group['prefix'] . $recordId . '-' . $idx, + 'id' => $conditionId, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']], 'text' => [ 'status' => 'generated', @@ -159,6 +179,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ]); } } + + if (is_array($encounter)) { + if ($encounterDiagnoses !== []) { + $encounter['diagnosis'] = $encounterDiagnoses; + } + $builder->addEncounter($encounter); + } foreach ((array) ($source['procedures'] ?? []) as $idx => $proc) { $text = trim((string) ($proc['text'] ?? $proc['name'] ?? '')); @@ -226,12 +253,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac } $dosageText = trim((string) ($med['dosage'] ?? '')); - $dosageInstruction = null; - if ($this->isMeaningfulValue($dosageText)) { - $dosageInstruction = [[ - 'text' => $dosageText, - ]]; - } + $structuredDosage = $this->buildStructuredDosageInstruction($dosageText, $name); $medResource = [ 'resourceType' => 'MedicationRequest', @@ -243,21 +265,43 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ], 'status' => 'active', 'intent' => 'order', + 'category' => [ + [ + 'coding' => [ + [ + 'system' => 'http://terminology.hl7.org/CodeSystem/medicationrequest-category', + 'code' => 'community', + 'display' => 'Discharge Medication', + ], + ], + 'text' => 'Discharge Medication', + ], + ], 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], - 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'authoredOn' => (string) ($med['authored_on'] ?? $timestamp), - 'requester' => is_array($practitioner) ? [ - 'reference' => 'urn:uuid:' . (string) $practitioner['id'], - 'display' => (string) ($practitioner['name'][0]['text'] ?? ''), - ] : null, 'medicationCodeableConcept' => [ 'coding' => $medCoding, 'text' => $name, ], ]; - if ($dosageInstruction !== null) { - $medResource['dosageInstruction'] = $dosageInstruction; + if ($encounterRef) { + $medResource['encounter'] = ['reference' => $encounterRef]; + } + + if (is_array($practitioner)) { + $medResource['requester'] = [ + 'reference' => 'urn:uuid:' . (string) $practitioner['id'], + 'display' => (string) ($practitioner['name'][0]['text'] ?? ''), + ]; + } + + if (!empty($structuredDosage['dosageInstruction'])) { + $medResource['dosageInstruction'] = $structuredDosage['dosageInstruction']; + } + + if (!empty($structuredDosage['dispenseRequest'])) { + $medResource['dispenseRequest'] = $structuredDosage['dispenseRequest']; } $builder->addMedicationRequest($medResource); @@ -274,12 +318,6 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $isAdm = (stripos($catText, 'Admission') !== false || stripos($text, 'Admission') !== false); $isDis = (stripos($catText, 'Discharge') !== false || stripos($text, 'Discharge') !== false); $obsCategoryDisplay = $isAdm ? 'Condition on Admission Time' : ($isDis ? 'Condition on Discharge Time' : 'Vital Signs'); - // ABDM PHR: use exam category for admission/discharge condition observations; - // vital-signs category only for true vital measurements. - $obsCategoryCode = ($isAdm || $isDis) ? 'exam' : 'vital-signs'; - $obsCategoryCanonicalDisplay = ($isAdm || $isDis) ? 'Exam' : 'Vital Signs'; - $obsCategorySystem = 'http://terminology.hl7.org/CodeSystem/observation-category'; - $coding = []; $loincCode = trim((string) ($obs['loinc_code'] ?? '')); $loincDisplay = trim((string) ($obs['loinc_display'] ?? '')); @@ -291,6 +329,11 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac } } + $isVital = ($loincCode !== '' && $loincCode !== '108252007'); + $obsCategoryCode = $isVital ? 'vital-signs' : 'exam'; + $obsCategoryCanonicalDisplay = $isVital ? 'Vital Signs' : 'Exam'; + $obsCategorySystem = 'http://terminology.hl7.org/CodeSystem/observation-category'; + if ($loincCode !== '') { $coding[] = [ 'system' => 'http://loinc.org', @@ -570,7 +613,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac continue; } - $builder->addCarePlan([ + $carePlanResource = [ 'resourceType' => 'CarePlan', 'id' => 'discharge-careplan-' . $recordId . '-' . $idx, 'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/CarePlan']], @@ -583,9 +626,49 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'title' => $title !== '' ? $title : 'Discharge Advice', 'description' => $description, 'subject' => ['reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')], - 'encounter' => $encounterRef ? ['reference' => $encounterRef] : null, 'created' => (string) ($cp['created_at'] ?? $timestamp), - ]); + 'category' => [ + [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => '736271009', + 'display' => 'Outpatient care plan' + ] + ] + ] + ], + 'activity' => [ + [ + 'detail' => [ + 'kind' => 'ServiceRequest', + 'code' => [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => '385676005', + 'display' => 'General advice and education' + ] + ] + ], + 'status' => 'scheduled', + 'description' => $description, + ] + ] + ] + ]; + + if ($encounterRef) { + $carePlanResource['encounter'] = ['reference' => $encounterRef]; + } + if (is_array($practitioner)) { + $carePlanResource['author'] = [ + 'reference' => 'urn:uuid:' . (string) $practitioner['id'], + 'display' => (string) ($practitioner['name'][0]['text'] ?? ''), + ]; + } + + $builder->addCarePlan($carePlanResource); } $documentsList = (array) ($source['documents'] ?? []); @@ -602,16 +685,21 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $docTitle = trim((string) ($document['title'] ?? 'Clinical document')); $snomedCode = trim((string) ($document['snomed_code'] ?? '')); $snomedDisplay = trim((string) ($document['snomed_display'] ?? '')); - if ($snomedCode === '') { - if (stripos($docTitle, 'Bill') !== false || stripos($docTitle, 'Invoice') !== false) { - $snomedCode = '823651000000106'; // Billing record - $snomedDisplay = 'Billing record'; - } else { - $snomedCode = '373942005'; // Discharge summary - $snomedDisplay = 'Discharge summary'; - } - } elseif ($snomedDisplay === '') { - $snomedDisplay = $docTitle; + $loincCode = trim((string) ($document['loinc_code'] ?? '')); + + $typeCoding = []; + if (stripos($docTitle, 'Bill') !== false || stripos($docTitle, 'Invoice') !== false) { + $typeCoding[] = [ + 'system' => 'http://snomed.info/sct', + 'code' => $snomedCode !== '' ? $snomedCode : '823651000000106', + 'display' => $snomedDisplay !== '' ? $snomedDisplay : 'Billing record', + ]; + } else { + $typeCoding[] = [ + 'system' => 'http://snomed.info/sct', + 'code' => $snomedCode !== '' ? $snomedCode : '373942005', + 'display' => $snomedDisplay !== '' ? $snomedDisplay : 'Discharge summary', + ]; } $rawBytes = base64_decode($data); @@ -628,11 +716,10 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac ], 'status' => 'current', 'docStatus' => 'final', - 'type' => ['coding' => [[ - 'system' => 'http://snomed.info/sct', - 'code' => $snomedCode, - 'display' => $snomedDisplay, - ]], 'text' => $docTitle], + 'type' => [ + 'coding' => $typeCoding, + 'text' => $docTitle, + ], 'subject' => [ 'reference' => $patientRef, 'display' => (string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient'), @@ -666,7 +753,7 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac $builder->addDocumentReference($docRef); } - $rawObservations = (array) ($source['observations'] ?? []); + $rawObservations = (array) ($source['ui_physical_exam'] ?? $source['observations'] ?? []); $admissionObsIndexed = []; $dischargeObsIndexed = []; foreach ($rawObservations as $idx => $obs) { @@ -679,40 +766,84 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac } } + $chiefComplaintsList = (array) ($source['ui_complaints'] ?? $source['chief_complaints'] ?? []); + $complaintsNarrative = trim((string) ($source['ui_complaint_narrative'] ?? $source['chief_complaint_narrative'] ?? '')); + + $diagnosesList = (array) ($source['ui_final_diagnosis'] ?? $source['diagnoses'] ?? $source['conditions'] ?? []); + $clinicalHistoryNarrative = trim((string) ($source['ui_clinical_history'] ?? '')); + + $admissionNarrative = trim((string) ($source['ui_admission_discharge'] ?? $source['admission_discharge_narrative'] ?? '')); + $sectionDefinitions = [ - ['title' => 'Chief complaints', 'code' => '422843007', 'display' => 'Chief complaint section', 'prefix' => 'discharge-chief-complaint-', 'items' => $chiefComplaintsList, 'narrative' => trim((string) ($source['chief_complaint_narrative'] ?? ''))], - ['title' => 'Medical History', 'code' => '1003642006', 'display' => 'Past medical history section', 'prefix' => 'discharge-diagnosis-', 'items' => $diagnosesList, 'narrative' => trim((string) ($source['diagnosis_narrative'] ?? ''))], - ['title' => 'Procedures', 'code' => '1003640003', 'display' => 'History of past procedure section', 'prefix' => 'discharge-procedure-', 'items' => (array) ($source['procedures'] ?? [])], - ['title' => 'Medications', 'code' => '1003606003', 'display' => 'Medication history section', 'prefix' => 'discharge-medication-', 'items' => (array) ($source['medications'] ?? [])], + // Chief complaints + ['title' => 'Chief complaints', 'code' => '422843007', 'display' => 'Chief complaint section', 'prefix' => 'discharge-chief-complaint-', 'items' => $chiefComplaintsList, 'narrative' => $complaintsNarrative], + // Discharge Diagnosis + ['title' => 'Discharge Diagnosis', 'code' => '397659008', 'display' => 'Discharge diagnosis', 'prefix' => 'discharge-diagnosis-', 'items' => $diagnosesList, 'narrative' => $clinicalHistoryNarrative], ]; - if (! empty($admissionObsIndexed) && ! empty($dischargeObsIndexed)) { - $sectionDefinitions[] = ['title' => 'Condition on Admission Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $admissionObsIndexed]; - $sectionDefinitions[] = ['title' => 'Condition on Discharge Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $dischargeObsIndexed]; - } elseif (! empty($admissionObsIndexed)) { - $sectionDefinitions[] = ['title' => 'Condition on Admission Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $admissionObsIndexed]; - if (! empty($rawObservations)) { - $otherObs = array_diff_key($rawObservations, $admissionObsIndexed); - if (! empty($otherObs)) { - $sectionDefinitions[] = ['title' => 'Physical examination', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $otherObs]; - } - } - } elseif (! empty($dischargeObsIndexed)) { - $sectionDefinitions[] = ['title' => 'Condition on Discharge Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $dischargeObsIndexed]; - if (! empty($rawObservations)) { - $otherObs = array_diff_key($rawObservations, $dischargeObsIndexed); - if (! empty($otherObs)) { - $sectionDefinitions[] = ['title' => 'Physical examination', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $otherObs]; - } - } - } else { - $sectionDefinitions[] = ['title' => 'Physical examination', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items' => $rawObservations]; + // Surgery / Procedure + $surgeries = (array) ($source['ui_surgeries'] ?? $source['procedures'] ?? []); + if (! empty($surgeries)) { + $sectionDefinitions[] = ['title' => 'Procedures', 'code' => '1003640003', 'display' => 'History of past procedure section', 'prefix' => 'discharge-procedure-', 'items' => $surgeries]; } - $sectionDefinitions[] = ['title' => 'Investigations', 'code' => '721981007', 'display' => 'Diagnostic studies report', 'prefix' => 'discharge-investigation-', 'items' => (array) ($source['investigations'] ?? [])]; - $sectionDefinitions[] = ['title' => 'Allergies', 'code' => '722446000', 'display' => 'Allergy record', 'prefix' => 'discharge-allergy-', 'items' => (array) ($source['allergies'] ?? [])]; - $sectionDefinitions[] = ['title' => 'Care Plan', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-careplan-', 'items' => (array) ($source['care_plans'] ?? [])]; - $sectionDefinitions[] = ['title' => 'Document Reference', 'code' => '373942005', 'display' => 'Discharge summary', 'prefix' => 'discharge-document-', 'items' => $documentsList]; + // Medications + $meds = (array) ($source['ui_discharge_medicine'] ?? $source['medications'] ?? []); + if (! empty($meds)) { + $sectionDefinitions[] = ['title' => 'Medications', 'code' => '1003606003', 'display' => 'Medication history section', 'prefix' => 'discharge-medication-', 'items' => $meds]; + } + + // Physical Examinations on Admission + if (! empty($admissionObsIndexed)) { + $sectionDefinitions[] = ['title' => 'Condition on Admission Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $admissionObsIndexed]; + } + + // Physical Examinations on Discharge + if (! empty($dischargeObsIndexed)) { + $sectionDefinitions[] = ['title' => 'Condition on Discharge Time', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $dischargeObsIndexed]; + } + + // Additional Observations not captured in Admission/Discharge + $otherObs = array_diff_key($rawObservations, $admissionObsIndexed, $dischargeObsIndexed); + if (! empty($otherObs)) { + $sectionDefinitions[] = ['title' => 'Other Physical examination', 'code' => '425044008', 'display' => 'Physical exam section', 'prefix' => 'discharge-observation-', 'items_indexed' => $otherObs]; + } + + // Investigations + $investigations = (array) ($source['ui_investigations'] ?? $source['investigations'] ?? []); + $invNarrative = trim((string) ($source['ui_summary_investigation'] ?? '')); + if (! empty($investigations) || $invNarrative !== '') { + $sectionDefinitions[] = ['title' => 'Clinical Investigation Reports', 'code' => '721981007', 'display' => 'Diagnostic studies report', 'prefix' => 'discharge-investigation-', 'items' => $investigations, 'narrative' => $invNarrative]; + } + + // Admission / Discharge Details + if ($admissionNarrative !== '') { + $admItems = $encounterRef ? [['name' => 'Admission and Discharge Details', 'id' => 'encounter-' . $recordId]] : []; + $sectionDefinitions[] = ['title' => 'Admission / Discharge Information', 'code' => '422838006', 'display' => 'Admission details', 'prefix' => 'discharge-admission-', 'items' => $admItems, 'narrative' => $admissionNarrative]; + } + + // Course / Treatment + $course = (array) ($source['ui_course_treatment'] ?? []); + if (! empty($course)) { + $sectionDefinitions[] = ['title' => 'Course / Treatment in the hospital', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-course-', 'items' => $course]; + } + + // Care plans / Discharge advice + $carePlans = (array) ($source['ui_discharge_summary'] ?? $source['care_plans'] ?? []); + if (! empty($carePlans)) { + $sectionDefinitions[] = ['title' => 'Care plan', 'code' => '734163000', 'display' => 'Care plan', 'prefix' => 'discharge-careplan-', 'items' => $carePlans]; + } + + // Document Reference + if (! empty($documentsList)) { + $sectionDefinitions[] = ['title' => 'Document reference', 'code' => '373942005', 'display' => 'Discharge summary', 'prefix' => 'discharge-document-', 'items' => $documentsList]; + } + + // Allergies + $allergies = (array) ($source['allergies'] ?? []); + if (! empty($allergies)) { + $sectionDefinitions[] = ['title' => 'Allergies', 'code' => '722446000', 'display' => 'Allergy record', 'prefix' => 'discharge-allergy-', 'items' => $allergies]; + } $sections = []; foreach ($sectionDefinitions as $definition) { @@ -740,21 +871,26 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac if ($sectionNarrative !== '') { array_unshift($narrativeParts, $sectionNarrative); } - $sections[] = [ + $sec = [ 'title' => $definition['title'], 'code' => ['coding' => [[ 'system' => 'http://snomed.info/sct', 'code' => $definition['code'], 'display' => $definition['display'], ]]], - 'entry' => $references, - 'text' => $narrativeParts !== [] ? [ + ]; + if ($references !== []) { + $sec['entry'] = $references; + } + if ($narrativeParts !== []) { + $sec['text'] = [ 'status' => 'generated', 'div' => '
  • ' . implode('
  • ', array_map('htmlspecialchars', $narrativeParts)) . '
', - ] : null, - ]; + ]; + } + $sections[] = $sec; } $patientNameClean = trim((string) ($source['patient']['name'] ?? $source['patient_name'] ?? 'Patient')); @@ -786,12 +922,17 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac 'div' => $fullNarrativeHtml, ], ]; + if ($sections !== []) { $compositionFinalUpdate['section'] = $sections; } + $builder->updateComposition($compositionFinalUpdate); $bundle = $builder->toBundle(); + + + $validation = $this->validator->validate($bundle, 'discharge', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]); return [ @@ -1434,4 +1575,203 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac return $html; } + + /** + * Build structured dosageInstruction and dispenseRequest from free text dosage and drug name + * + * @param string $dosageText + * @param string $medName + * @return array{dosageInstruction: array>, dispenseRequest?: array} + */ + private function buildStructuredDosageInstruction(string $dosageText, string $medName): array + { + $raw = strtoupper($dosageText . ' ' . $medName); + + // Timing detection + $timingCode = 'QD'; + $timingDisplay = 'Once daily'; + $frequency = 1; + $period = 1; + + if (preg_match('/\b(TDS|TID|THRICE|3 TIMES|THREE TIMES)\b/', $raw)) { + $timingCode = 'TID'; + $timingDisplay = 'Three times daily'; + $frequency = 3; + } elseif (preg_match('/\b(QID|4 TIMES|FOUR TIMES)\b/', $raw)) { + $timingCode = 'QID'; + $timingDisplay = 'Four times daily'; + $frequency = 4; + } elseif (preg_match('/\b(BD|BID|TWICE|2 TIMES|TWO TIMES)\b/', $raw)) { + $timingCode = 'BID'; + $timingDisplay = 'Twice daily'; + $frequency = 2; + } elseif (preg_match('/\b(SOS|PRN|AS NEEDED|WHEN REQUIRED)\b/', $raw)) { + $timingCode = 'PRN'; + $timingDisplay = 'As needed'; + $frequency = 1; + } elseif (preg_match('/\b(HS|BEDTIME|AT NIGHT)\b/', $raw)) { + $timingCode = 'HS'; + $timingDisplay = 'At bedtime'; + $frequency = 1; + } + + // Additional Instructions detection (Food relation) + $addInstruct = null; + if (preg_match('/\b(EMPTY STOMACH|BEFORE FOOD|BEFORE MEALS?|BF|AC)\b/', $raw)) { + $addInstruct = [ + [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => '307165006', + 'display' => 'Before meals', + ], + ], + 'text' => 'Before food / Empty stomach', + ], + ]; + } elseif (preg_match('/\b(AFTER FOOD|AFTER MEALS?|AF|PC)\b/', $raw)) { + $addInstruct = [ + [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => '307166007', + 'display' => 'After meals', + ], + ], + 'text' => 'After food', + ], + ]; + } elseif (preg_match('/\b(WITH FOOD|WITH MEALS?)\b/', $raw)) { + $addInstruct = [ + [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => '311504000', + 'display' => 'With or after food', + ], + ], + 'text' => 'With food', + ], + ]; + } + + // Route detection + $routeCode = '260548002'; + $routeDisplay = 'Oral route'; + $routeText = 'Oral'; + + if (preg_match('/\b(INJ|IV|INTRAVENOUS|INFUSION)\b/', $raw)) { + $routeCode = '47625008'; + $routeDisplay = 'Intravenous route'; + $routeText = 'Intravenous (IV)'; + } elseif (preg_match('/\b(IM|INTRAMUSCULAR)\b/', $raw)) { + $routeCode = '78421000'; + $routeDisplay = 'Intramuscular route'; + $routeText = 'Intramuscular (IM)'; + } elseif (preg_match('/\b(DROPS?|EYE|EAR|OPHTHALMIC)\b/', $raw)) { + $routeCode = '54485002'; + $routeDisplay = 'Ophthalmic route'; + $routeText = 'Drops'; + } elseif (preg_match('/\b(CREAM|OINTMENT|GEL|TOPICAL)\b/', $raw)) { + $routeCode = '6064005'; + $routeDisplay = 'Topical route'; + $routeText = 'Topical'; + } + + // Dose unit detection + $doseUnit = 'Tablet'; + $doseUnitCode = 'tbl'; + $doseValue = 1; + + if (preg_match('/\b(SACHET|POWDER)\b/', $raw)) { + $doseUnit = 'Sachet'; + $doseUnitCode = 'sachet'; + } elseif (preg_match('/\b(SYRUP|SUSPENSION|ML)\b/', $raw)) { + $doseUnit = 'mL'; + $doseUnitCode = 'mL'; + $doseValue = 5; + } elseif (preg_match('/\b(CAPSULE|CAP)\b/', $raw)) { + $doseUnit = 'Capsule'; + $doseUnitCode = 'capsule'; + } elseif (preg_match('/\b(DROPS?)\b/', $raw)) { + $doseUnit = 'drops'; + $doseUnitCode = '[drp]'; + $doseValue = 2; + } + + // Duration detection + $durationDays = 5; + if (preg_match('/(?:FOR\s*)?(\d+)\s*DAYS?/i', $dosageText, $dm)) { + $durationDays = (int) $dm[1]; + } + + $instructionItem = [ + 'text' => $dosageText !== '' ? $dosageText : ($doseValue . ' ' . $doseUnit . ' ' . $timingDisplay . ' for ' . $durationDays . ' days'), + 'timing' => [ + 'repeat' => [ + 'frequency' => $frequency, + 'period' => $period, + 'periodUnit' => 'd', + ], + 'code' => [ + 'coding' => [ + [ + 'system' => 'http://terminology.hl7.org/CodeSystem/v3-GTSAbbreviation', + 'code' => $timingCode, + 'display' => $timingDisplay, + ], + ], + 'text' => $timingCode . ' (' . $timingDisplay . ')', + ], + ], + 'route' => [ + 'coding' => [ + [ + 'system' => 'http://snomed.info/sct', + 'code' => $routeCode, + 'display' => $routeDisplay, + ], + ], + 'text' => $routeText, + ], + 'doseAndRate' => [ + [ + 'type' => [ + 'coding' => [ + [ + 'system' => 'http://terminology.hl7.org/CodeSystem/dose-rate-type', + 'code' => 'ordered', + 'display' => 'Ordered', + ], + ], + ], + 'doseQuantity' => [ + 'value' => $doseValue, + 'unit' => $doseUnit, + 'system' => 'http://unitsofmeasure.org', + 'code' => $doseUnitCode, + ], + ], + ], + ]; + + if ($addInstruct !== null) { + $instructionItem['additionalInstruction'] = $addInstruct; + } + + return [ + 'dosageInstruction' => [$instructionItem], + 'dispenseRequest' => [ + 'expectedSupplyDuration' => [ + 'value' => $durationDays, + 'unit' => 'days', + 'system' => 'http://unitsofmeasure.org', + 'code' => 'd', + ], + ], + ]; + } } diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index fe05360..a09847a 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -283,7 +283,7 @@
- OPD Book — ABDM Tokens + OPD Book - ABDM Tokens
@@ -308,7 +308,7 @@
HMS ABDM OPD Token List - Last 7 days · synced and manual ABDM tokens only + Last 7 days synced and manual ABDM tokens only
@@ -340,9 +340,9 @@
- OPD Consult / Prescription Records — Done Appointments with ABHA + OPD Consult / Prescription Records - Done Appointments with ABHA - Last 30 days · opd_status=Done · ABHA linked + Last 30 days opd_status=Done ABHA linked
@@ -431,7 +431,7 @@
- Invoice — ABDM Billing Records + Invoice - ABDM Billing Records OPD, Charges and IPD Billing
@@ -526,10 +526,14 @@
- - - - + + +
+ + + + +
- +
@@ -844,7 +848,7 @@ var confBadgeClass = { definitive: 'bg-success', high: 'bg-primary', medium: 'bg-warning text-dark', low: 'bg-secondary' }; var confBadge = '' + (confLabels[conf] || 'Low') + ''; var reasons = Array.isArray(m.match_reasons) ? m.match_reasons.join(', ') : ''; - var genderLabel = m.gender == 2 ? 'F' : m.gender == 1 ? 'M' : '—'; + var genderLabel = m.gender == 2 ? 'F' : m.gender == 1 ? 'M' : '-'; var borderClass = conf === 'definitive' ? 'border-success' : conf === 'high' ? 'border-primary' : conf === 'medium' ? 'border-warning' : 'border-secondary'; var mLabel = ((m.p_code || 'UHID') + ' - ' + (m.p_fname || 'Unnamed')); cardHtml += '
'; @@ -852,14 +856,14 @@ cardHtml += '
' + mLabel + '' + confBadge + '
'; cardHtml += ''; cardHtml += '
'; - cardHtml += '
DOB: ' + (m.dob || '—') + ' | Gender: ' + genderLabel + ' | Phone: ' + (m.mphone1 || '—') + '
'; + cardHtml += '
DOB: ' + (m.dob || '-') + ' | Gender: ' + genderLabel + ' | Phone: ' + (m.mphone1 || '-') + '
'; cardHtml += '
Matched by: ' + reasons + '
'; cardHtml += ''; }); // Add "Create New Patient" as a selectable card option at the end of the list cardHtml += '
'; cardHtml += '
'; - cardHtml += '
None of the above — Register as New Patient
'; + cardHtml += '
None of the above - Register as New Patient
'; cardHtml += ''; cardHtml += '
'; cardHtml += '
This ABHA does not belong to any listed record.
'; @@ -1069,7 +1073,7 @@ + '?lab_req_id=' + encodeURIComponent(labReqId) + '&patient_id=' + encodeURIComponent(patientId) + '&abha_id=' + encodeURIComponent(abhaId); - title = (taskType === 'radiology_report_publish' ? 'Radiology' : 'Lab') + ' FHIR Preview — Request #' + labReqId; + title = (taskType === 'radiology_report_publish' ? 'Radiology' : 'Lab') + ' FHIR Preview - Request #' + labReqId; } else if (taskType === 'opd_prescription_publish') { var opdId = parseInt(row.getAttribute('data-opd-id') || row.getAttribute('data-entity-id') || '0', 10) || 0; var opdSessionId = parseInt(row.getAttribute('data-opd-session-id') || '0', 10) || 0; @@ -1082,7 +1086,7 @@ previewUrl = opdSessionId > 0 ? '/' + opdId + '/' + opdSessionId : '/' + opdId; - title = 'OPD FHIR Preview — OPD #' + opdId; + title = 'OPD FHIR Preview - OPD #' + opdId; } else if (taskType === 'ipd_discharge_publish') { var ipdId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0; var ipdPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0; @@ -1096,7 +1100,7 @@ + '?ipd_id=' + encodeURIComponent(ipdId) + '&patient_id=' + encodeURIComponent(ipdPatientId) + '&abha_id=' + encodeURIComponent(abhaId); - title = 'IPD Discharge FHIR Preview — IPD #' + ipdId; + title = 'IPD Discharge FHIR Preview - IPD #' + ipdId; } else if (taskType === 'immunization_record_publish') { var immunizationRecordId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0; var immunizationPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0; @@ -1110,7 +1114,7 @@ + '?record_id=' + encodeURIComponent(immunizationRecordId) + '&patient_id=' + encodeURIComponent(immunizationPatientId) + '&abha_id=' + encodeURIComponent(abhaId); - title = 'ImmunizationRecord FHIR Preview — Record #' + immunizationRecordId; + title = 'ImmunizationRecord FHIR Preview - Record #' + immunizationRecordId; } else if (taskType === 'health_document_publish') { var healthDocId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0; var healthPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0; @@ -1121,7 +1125,7 @@ } previewUrl = '/' + healthDocId; - title = 'HealthDocumentRecord FHIR Preview — Document #' + healthDocId; + title = 'HealthDocumentRecord FHIR Preview - Document #' + healthDocId; } else if (taskType === 'wellness_record_publish') { var wellnessPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0; var wellnessOpdId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0; @@ -1132,7 +1136,7 @@ } previewUrl = '/' + wellnessPatientId + (wellnessOpdId > 0 ? ('/' + wellnessOpdId) : ''); - title = 'WellnessRecord FHIR Preview — Patient #' + wellnessPatientId; + title = 'WellnessRecord FHIR Preview - Patient #' + wellnessPatientId; } else { return false; } @@ -1609,7 +1613,7 @@ } navigator.clipboard.writeText(jsonText).then(function () { setStatus('FHIR JSON copied to clipboard.'); - btn.textContent = 'Copied ✓'; + btn.textContent = 'Copied v'; btn.className = 'btn btn-sm btn-success'; window.setTimeout(function () { btn.textContent = originalText; @@ -1645,13 +1649,13 @@ var stat = (t.status || t.queue_status || '').toString(); var token = (t.token_number || t.tokenNumber || t.gateway_token_id || t.id || '').toString(); var time = (t.created_at || t.token_time || t.arrival_time || '').toString(); - var hms = t.patient_id ? ('#' + t.patient_id + (t.p_fname ? ' ' + t.p_fname : '')) : '—'; + var hms = t.patient_id ? ('#' + t.patient_id + (t.p_fname ? ' ' + t.p_fname : '')) : '-'; return '
' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + '' + ''; }).join(''); @@ -1673,18 +1677,38 @@ var fhirModalDataBadge = document.getElementById('fhirModalDataBadge'); var fhirModalHttpBadge = document.getElementById('fhirModalHttpBadge'); var btnRegenerateFhirModal = document.getElementById('btnRegenerateFhirModal'); + var btnUpdateFhirJson = document.getElementById('btnUpdateFhirJson'); var btnSubmitFhirToAbdm = document.getElementById('btnSubmitFhirToAbdm'); var defaultSubmitBtnHtml = btnSubmitFhirToAbdm ? btnSubmitFhirToAbdm.innerHTML : ''; var defaultSubmitBtnClass = btnSubmitFhirToAbdm ? btnSubmitFhirToAbdm.className : 'btn btn-sm btn-outline-success'; var defaultSubmitBtnTitle = btnSubmitFhirToAbdm ? btnSubmitFhirToAbdm.title : 'Push FHIR bundle to ABDM bridge'; - // ── Tab switching ────────────────────────────────────────────────── + // -- Tab switching -------------------------------------------------- fhirTabForm.addEventListener('click', function () { fhirTabForm.classList.add('active'); fhirTabJson.classList.remove('active'); fhirFormView.style.display = ''; fhirModalJson.style.display = 'none'; + + // Auto-refresh Form View from JSON if valid + try { + var curText = (fhirModalJson ? fhirModalJson.value : '').trim(); + if (curText && curText !== '{\n "loading": true\n}' && curText !== '{}') { + var p = JSON.parse(curText); + var b = (p && p.resourceType === 'Bundle') ? p : ((p && p.bundle) ? p.bundle : null); + if (b && fhirFormView) { + fhirFormView.innerHTML = renderFhirBundleForm(b, _fhirLiveComplaints, _fhirLiveDiagnoses); + if (fhirModalQuality) { + var qs = fhirCodeQuality(b); + if (qs) { + fhirModalQuality.innerHTML = qs; + fhirModalQuality.style.display = 'block'; + } + } + } + } + } catch (e) {} }); fhirTabJson.addEventListener('click', function () { fhirTabJson.classList.add('active'); @@ -1693,8 +1717,9 @@ fhirFormView.style.display = 'none'; }); - // ── Track current bundle context for edits ────────────────────── + // -- Track current bundle context for edits ---------------------- var _fhirOpdId = 0, _fhirSessionId = 0; + var _fhirIpdId = 0, _fhirPatientId = 0, _fhirAbhaId = ''; var _fhirLiveComplaints = [], _fhirLiveDiagnoses = []; var _fhirSubmitMode = 'opd'; var _fhirSubmitTaskRow = null; @@ -1704,7 +1729,7 @@ var _fhirInvoicePatientId = 0; var _fhirPreviewUrl = ''; - // ── Inline complaint SNOMED edit (event delegation on fhirFormView) ─ + // -- Inline complaint SNOMED edit (event delegation on fhirFormView) - var _editTimer = null; fhirFormView.addEventListener('click', function(e) { var editBtn = e.target.closest('.fhir-complaint-edit'); @@ -1753,7 +1778,7 @@ panel2.querySelector('.fhir-snomed-search').value = ddItem.dataset.term || ''; panel2.querySelector('.fhir-snomed-dd').style.display = 'none'; var sel = panel2.querySelector('.fhir-snomed-selected'); - sel.textContent = '✓ ' + (ddItem.dataset.id || '') + ' — ' + (ddItem.dataset.term || ''); + sel.textContent = 'v ' + (ddItem.dataset.id || '') + ' - ' + (ddItem.dataset.term || ''); sel.style.display = ''; } if (saveBtn) { @@ -1776,7 +1801,7 @@ }); body.append(csrfName, csrfHash); saveBtn.disabled = true; - saveBtn.textContent = 'Saving…'; + saveBtn.textContent = 'Saving...'; fetch('', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -1935,7 +1960,7 @@ }, 200); }); - // ── Arrow-key / Enter navigation for SNOMED dropdowns ───────────── + // -- Arrow-key / Enter navigation for SNOMED dropdowns ------------- fhirFormView.addEventListener('keydown', function(e) { var key = e.key; if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') return; @@ -1995,7 +2020,7 @@ } }); - // ── Render FHIR bundle as human-readable form ────────────────────── + // -- Render FHIR bundle as human-readable form ---------------------- function hesc(s) { return String(s || '') .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); @@ -2066,7 +2091,7 @@ var html = ''; - // ── Patient header + // -- Patient header if (patient) { var pname = ((patient.name || [{}])[0].text || '').trim(); var gender = (patient.gender || '').toUpperCase().charAt(0); @@ -2089,7 +2114,7 @@ html += ''; } - // ── Invoice Record document metadata + // -- Invoice Record document metadata if (composition && invoice) { var compositionCoding = ((((composition.type || {}).coding) || [])[0]) || {}; var compositionIdentifier = (composition.identifier || {}).value || ''; @@ -2105,7 +2130,7 @@ html += ''; } - // ── Invoice details and line items + // -- Invoice details and line items if (invoice) { var invoiceIdentifier = ((invoice.identifier || [])[0] || {}).value || ''; var invoiceCoding = ((((invoice.type || {}).coding) || [])[0]) || {}; @@ -2155,7 +2180,7 @@ html += ''; } - // ── Embedded invoice PDF + // -- Embedded invoice PDF documentRefs.forEach(function(documentReference) { var attachment = (((documentReference.content || [])[0] || {}).attachment) || {}; if (attachment.contentType !== 'application/pdf') return; @@ -2172,7 +2197,7 @@ html += ''; }); - // ── Immunization Record + // -- Immunization Record if (immunizations.length) { html += '
Vaccination Details
'; html += '
' + (token || '—') + '' + (abha || '—') + '' + (name || '—') + '' + (stat || '—') + '' + (time ? time.substring(0, 16) : '—') + '' + (token || '-') + '' + (abha || '-') + '' + (name || '-') + '' + (stat || '-') + '' + (time ? time.substring(0, 16) : '-') + '' + hms + '
'; @@ -2200,7 +2225,7 @@ html += '
'; } - // ── Complaints + // -- Complaints if (complaints.length) { html += '
Chief Complaints
'; html += '
    '; @@ -2237,7 +2262,7 @@ html += '
'; } - // ── Diagnoses + // -- Diagnoses if (diagnoses.length) { html += '
Diagnosis
'; html += '
    '; @@ -2300,9 +2325,9 @@ html += '
'; } - // ── Observation Groups (Vitals vs Lab Results) + // -- Observation Groups (Vitals vs Lab Results) if (observations.length) { - var loincLabel = { '8867-4':'HR','59408-5':'SpO₂','8480-6':'BP Sys','8462-4':'BP Dia','8310-5':'Temp','9279-1':'RR','8302-2':'Height','29463-7':'Weight' }; + var loincLabel = { '8867-4':'HR','59408-5':'SpO','8480-6':'BP Sys','8462-4':'BP Dia','8310-5':'Temp','9279-1':'RR','8302-2':'Height','29463-7':'Weight' }; var vitalLoincCodes = { '8867-4':1, '59408-5':1, '8480-6':1, '8462-4':1, '8310-5':1, '9279-1':1, '8302-2':1, '29463-7':1 }; function meaningfulText(v) { @@ -2391,7 +2416,7 @@ renderObservationCard('Lab Results', labObservations); } - // ── Medications + // -- Medications if (medications.length) { html += '
Medications
'; html += '
    '; @@ -2401,13 +2426,13 @@ var dosage = di.text || ''; var method = (di.method || {}).text || ''; html += '
  1. ' + hesc(drug) + ''; - if (dosage) html += ' — ' + hesc(dosage) + (method ? ' (' + hesc(method) + ')' : '') + ''; + if (dosage) html += ' - ' + hesc(dosage) + (method ? ' (' + hesc(method) + ')' : '') + ''; html += '
  2. '; }); html += '
'; } - // ── Procedures / Surgeries + // -- Procedures / Surgeries if (procedures.length) { html += '
Procedures
'; html += '
    '; @@ -2421,7 +2446,7 @@ html += '
'; } - // ── Investigations + // -- Investigations if (serviceReqs.length) { html += '
Investigations
'; html += '
    '; @@ -2439,7 +2464,7 @@ html += '
'; } - // ── Allergy & Risk Profile + // -- Allergy & Risk Profile if (allergies.length) { html += '
Allergies / Risk Profile
'; html += '
    '; @@ -2455,7 +2480,7 @@ html += '
'; } - // ── Course In Hospital + // -- Course In Hospital if (coursePlans.length) { html += '
Course In Hospital
'; html += '
    '; @@ -2469,7 +2494,7 @@ html += '
'; } - // ── Discharge Advice / Follow-up + // -- Discharge Advice / Follow-up if (advicePlans.length) { html += '
Discharge Advice & Follow-up
'; html += '
    '; @@ -2483,7 +2508,7 @@ html += '
'; } - // ── Attached Documents & Reports (PDFs, Images, Scanned Documents, Invoices) + // -- Attached Documents & Reports (PDFs, Images, Scanned Documents, Invoices) if (documentRefs.length) { html += '
Attached Documents & Reports' + documentRefs.length + '
'; html += '
'; @@ -2536,7 +2561,7 @@ html += '
'; } - // ── Practitioner + // -- Practitioner if (practitioner) { var pName = ((practitioner.name || [{}])[0].text || '').trim(); var pGiven = (((practitioner.name || [{}])[0].given) || []).join(' ').trim(); @@ -2551,7 +2576,7 @@ html += '
'; } - // ── Organization + // -- Organization if (organization) { var oName = (organization.name || '').trim(); var oIdent = (organization.identifier || []).find(function(i){ return (i.system||'').includes('facility') || (i.system||'').includes('hfr'); }); @@ -2638,11 +2663,19 @@ var rowPushStatus = (options.pushStatus || '').toString().trim().toLowerCase(); _fhirOpdId = 0; _fhirSessionId = 0; + _fhirIpdId = 0; _fhirPatientId = 0; _fhirAbhaId = ''; + try { + var urlObj = new URL(url, window.location.origin); + if (urlObj.searchParams.has('ipd_id')) _fhirIpdId = parseInt(urlObj.searchParams.get('ipd_id') || '0', 10) || 0; + if (urlObj.searchParams.has('patient_id')) _fhirPatientId = parseInt(urlObj.searchParams.get('patient_id') || '0', 10) || 0; + if (urlObj.searchParams.has('abha_id')) _fhirAbhaId = (urlObj.searchParams.get('abha_id') || '').trim(); + } catch (e) {} + if (fhirModalTitle) fhirModalTitle.textContent = title || 'FHIR Preview'; if (fhirModalMeta) { fhirModalMeta.textContent = 'Loading...'; fhirModalMeta.className = 'flex-grow-1 small text-muted'; } if (fhirModalQuality) { fhirModalQuality.innerHTML = ''; fhirModalQuality.style.display = 'none'; } if (fhirFormView) fhirFormView.innerHTML = '

Loading...

'; - if (fhirModalJson) fhirModalJson.textContent = '{\n "loading": true\n}'; + if (fhirModalJson) fhirModalJson.value = '{\n "loading": true\n}'; // Reset to Form View tab fhirTabForm.classList.add('active'); fhirTabJson.classList.remove('active'); fhirFormView.style.display = ''; fhirModalJson.style.display = 'none'; @@ -2651,7 +2684,7 @@ if (btnSubmitFhirToAbdm) { btnSubmitFhirToAbdm.disabled = false; - if (_fhirSubmitMode === 'task') { + if (_fhirSubmitMode === 'task' || _fhirIpdId > 0 || (url && url.indexOf('ipd_discharge') !== -1)) { btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-warning'; btnSubmitFhirToAbdm.innerHTML = ' Verify & Submit to Gateway'; btnSubmitFhirToAbdm.title = 'Submit directly to ABDM gateway after FHIR verification'; @@ -2699,9 +2732,12 @@ var hasFhir = bundle !== null; _fhirOpdId = parseInt(parsed.opd_id || '0', 10) || 0; _fhirSessionId = parseInt(parsed.opd_session_id || '0', 10) || 0; + if (parsed.ipd_id) _fhirIpdId = parseInt(parsed.ipd_id, 10) || _fhirIpdId; + if (parsed.patient_id) _fhirPatientId = parseInt(parsed.patient_id, 10) || _fhirPatientId; + if (parsed.abha_id) _fhirAbhaId = (parsed.abha_id || _fhirAbhaId).trim(); _fhirLiveComplaints = Array.isArray(parsed.live_complaints) ? parsed.live_complaints : []; _fhirLiveDiagnoses = Array.isArray(parsed.live_diagnoses) ? parsed.live_diagnoses : []; - if (fhirModalJson) fhirModalJson.textContent = JSON.stringify(parsed, null, 2); + if (fhirModalJson) fhirModalJson.value = JSON.stringify(bundle || parsed, null, 2); if (fhirFormView) { if (hasFhir) { fhirFormView.innerHTML = renderFhirBundleForm(bundle, _fhirLiveComplaints, _fhirLiveDiagnoses); @@ -2728,6 +2764,121 @@ }); } + // Apply JSON changes function + function applyFhirJsonChanges(onSaved) { + var raw = (fhirModalJson ? fhirModalJson.value : '').trim(); + if (!raw) { + alert('JSON content is empty.'); + return false; + } + var parsed = null; + try { + parsed = JSON.parse(raw); + } catch (e) { + alert('Invalid JSON syntax:\n' + e.message); + if (fhirModalMeta) { + fhirModalMeta.textContent = 'JSON syntax error: ' + e.message; + fhirModalMeta.className = 'flex-grow-1 small text-danger'; + } + return false; + } + + var bundle = (parsed && parsed.resourceType === 'Bundle') ? parsed : ((parsed && parsed.bundle) ? parsed.bundle : ((parsed && parsed.fhir_bundle) ? parsed.fhir_bundle : null)); + if (!bundle || bundle.resourceType !== 'Bundle') { + alert('The JSON must be a valid FHIR Bundle (resourceType: "Bundle").'); + return false; + } + + // Format textarea neatly + if (fhirModalJson) { + fhirModalJson.value = JSON.stringify(bundle, null, 2); + } + + // Refresh Form View + if (fhirFormView) { + fhirFormView.innerHTML = renderFhirBundleForm(bundle, _fhirLiveComplaints, _fhirLiveDiagnoses); + } + + // Refresh Code Quality badges + if (fhirModalQuality) { + var qs = fhirCodeQuality(bundle); + if (qs) { + fhirModalQuality.innerHTML = qs; + fhirModalQuality.style.display = 'block'; + } else { + fhirModalQuality.innerHTML = ''; + fhirModalQuality.style.display = 'none'; + } + } + + if (fhirModalDataBadge) { + fhirModalDataBadge.className = 'badge bg-success'; + fhirModalDataBadge.textContent = 'HAS FHIR'; + } + + var targetIpdId = _fhirIpdId || (_fhirSubmitTaskRow && (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() === 'ipd_discharge_publish' ? parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) : 0); + var targetPatientId = _fhirPatientId || (_fhirSubmitTaskRow ? parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) : 0); + var targetAbha = (_fhirSubmitAbha || _fhirAbhaId || '').trim(); + + if (targetIpdId > 0) { + var origBtnHtml = btnUpdateFhirJson ? btnUpdateFhirJson.innerHTML : ''; + if (btnUpdateFhirJson) { + btnUpdateFhirJson.disabled = true; + btnUpdateFhirJson.innerHTML = 'Saving...'; + } + + post('', { + ipd_id: targetIpdId, + patient_id: targetPatientId, + abha_id: targetAbha, + fhir_json: fhirModalJson.value + }, function (res) { + if (btnUpdateFhirJson) { + btnUpdateFhirJson.disabled = false; + btnUpdateFhirJson.innerHTML = ' JSON Updated!'; + window.setTimeout(function() { + btnUpdateFhirJson.innerHTML = origBtnHtml || ' Update JSON'; + }, 2000); + } + if (!res || parseInt(res.ok || '0', 10) !== 1) { + var err = (res && (res.error_text || res.error || res.message)) || 'Failed to save updated JSON'; + if (fhirModalMeta) { + fhirModalMeta.textContent = 'Updated in preview: ' + err; + fhirModalMeta.className = 'flex-grow-1 small text-warning'; + } + if (typeof onSaved === 'function') onSaved(false); + return; + } + if (fhirModalMeta) { + fhirModalMeta.textContent = 'JSON validated & saved to database.'; + fhirModalMeta.className = 'flex-grow-1 small text-success'; + } + if (typeof onSaved === 'function') onSaved(true); + }); + return true; + } + + if (btnUpdateFhirJson) { + var prevHtml = btnUpdateFhirJson.innerHTML; + btnUpdateFhirJson.innerHTML = ' Updated!'; + window.setTimeout(function() { + btnUpdateFhirJson.innerHTML = prevHtml; + }, 2000); + } + if (fhirModalMeta) { + fhirModalMeta.textContent = 'JSON validated and applied.'; + fhirModalMeta.className = 'flex-grow-1 small text-success'; + } + if (typeof onSaved === 'function') onSaved(true); + return true; + } + + if (btnUpdateFhirJson) { + btnUpdateFhirJson.addEventListener('click', function () { + applyFhirJsonChanges(); + }); + } + // Regenerate / Recreate FHIR bundle in modal btnRegenerateFhirModal.addEventListener('click', function () { if (!_fhirPreviewUrl) { @@ -2746,7 +2897,7 @@ }); }); - // Update Codes dropdown — navigate to master workspaces + // Update Codes dropdown - navigate to master workspaces (function () { function navTo(url, label) { fhirPreviewModal.hide(); @@ -2826,28 +2977,27 @@ return; } - if (_fhirSubmitMode === 'task') { - if (!_fhirSubmitTaskRow) { - alert('No ABDM task selected for submission.'); - return; - } - if (!/^\d{14}$/.test(_fhirSubmitAbha)) { - alert('ABHA must be a 14-digit number before queueing.'); - return; - } + var isIpdMode = (_fhirSubmitTaskRow && (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() === 'ipd_discharge_publish') || (_fhirIpdId > 0 && !_fhirInvoiceSource); + + if (_fhirSubmitMode === 'task' || isIpdMode) { + var effectiveAbha = (_fhirSubmitAbha || _fhirAbhaId || (_fhirSubmitTaskRow && _fhirSubmitTaskRow.querySelector('.abha-input') ? _fhirSubmitTaskRow.querySelector('.abha-input').value : '')).trim(); + if (!confirm('FHIR verified. Submit this record to ABDM gateway now?')) { return; } - var rowInput = _fhirSubmitTaskRow.querySelector('.abha-input'); - if (rowInput) { - rowInput.value = _fhirSubmitAbha; + if (_fhirSubmitTaskRow) { + var rowInput = _fhirSubmitTaskRow.querySelector('.abha-input'); + if (rowInput && effectiveAbha) { + rowInput.value = effectiveAbha; + } } - var taskType = (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase(); + var taskType = _fhirSubmitTaskRow ? (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() : (isIpdMode ? 'ipd_discharge_publish' : ''); + if (taskType === 'lab_report_publish' || taskType === 'radiology_report_publish') { - var labReqId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0; - var patientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0; + var labReqId = _fhirSubmitTaskRow ? parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) : 0; + var patientId = _fhirSubmitTaskRow ? parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) : 0; if (labReqId <= 0 || patientId <= 0) { alert('Task data missing (lab_req_id/patient_id).'); return; @@ -2855,12 +3005,12 @@ var origHtmlTask = btn.innerHTML; btn.disabled = true; - btn.innerHTML = 'Submitting…'; + btn.innerHTML = 'Submitting...'; post('', { lab_req_id: labReqId, patient_id: patientId, - abha_id: _fhirSubmitAbha, + abha_id: effectiveAbha, consent_handle: '' }, function (res) { btn.disabled = false; @@ -2876,10 +3026,12 @@ var queueText = res.queue_id ? (' Queue ID: ' + res.queue_id) : ''; var warningText = res.warning ? (' Warning: ' + res.warning) : ''; setSubmissionStatus('Submitted to ABDM gateway.' + queueText + warningText, res.queue_id || ''); - var badge = _fhirSubmitTaskRow.querySelector('.status-pill'); - if (badge) { - badge.textContent = 'IN_PROGRESS'; - badge.className = 'badge bg-info status-pill'; + if (_fhirSubmitTaskRow) { + var badge = _fhirSubmitTaskRow.querySelector('.status-pill'); + if (badge) { + badge.textContent = 'IN_PROGRESS'; + badge.className = 'badge bg-info status-pill'; + } } alert('Submitted to ABDM gateway.' + queueText + warningText); fhirPreviewModal.hide(); @@ -2887,23 +3039,35 @@ return; } - if (taskType === 'ipd_discharge_publish') { - var ipdId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0; - var ipdPatientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0; - if (ipdId <= 0 || ipdPatientId <= 0) { - alert('Task data missing (ipd_id/patient_id).'); + if (taskType === 'ipd_discharge_publish' || isIpdMode) { + var ipdId = _fhirIpdId || (_fhirSubmitTaskRow ? parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) : 0); + var ipdPatientId = _fhirPatientId || (_fhirSubmitTaskRow ? parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) : 0); + if (ipdId <= 0) { + alert('Task data missing (ipd_id).'); return; } + var currentJson = (fhirModalJson ? fhirModalJson.value : '').trim(); + if (currentJson) { + try { + JSON.parse(currentJson); + } catch (e) { + alert('Cannot submit: JSON contains syntax errors:\n' + e.message); + return; + } + } + var origHtmlIpd = btn.innerHTML; btn.disabled = true; - btn.innerHTML = 'Submitting…'; + btn.innerHTML = 'Submitting...'; post('', { ipd_id: ipdId, patient_id: ipdPatientId, - abha_id: _fhirSubmitAbha, - consent_handle: '' + abha_id: effectiveAbha, + consent_handle: '', + fhir_override_json: currentJson, + force_new_record: document.getElementById('chkForceNewRecord') && document.getElementById('chkForceNewRecord').checked ? 1 : 0 }, function (res) { btn.disabled = false; btn.innerHTML = origHtmlIpd; @@ -2918,10 +3082,12 @@ var queueText = res.queue_id ? (' Queue ID: ' + res.queue_id) : ''; var warningText = res.warning ? (' Warning: ' + res.warning) : ''; setSubmissionStatus('Submitted to ABDM gateway.' + queueText + warningText, res.queue_id || ''); - var badge = _fhirSubmitTaskRow.querySelector('.status-pill'); - if (badge) { - badge.textContent = 'IN_PROGRESS'; - badge.className = 'badge bg-info status-pill'; + if (_fhirSubmitTaskRow) { + var badge = _fhirSubmitTaskRow.querySelector('.status-pill'); + if (badge) { + badge.textContent = 'IN_PROGRESS'; + badge.className = 'badge bg-info status-pill'; + } } alert('Submitted to ABDM gateway.' + queueText + warningText); fhirPreviewModal.hide(); @@ -3029,7 +3195,7 @@ if (!confirm(submitConfirm)) return; var origHtml = btn.innerHTML; btn.disabled = true; - btn.textContent = 'Submitting…'; + btn.textContent = 'Submitting...'; var body = new URLSearchParams({ opd_id: _fhirOpdId, opd_session_id: _fhirSessionId }); body.append('push_to_gateway', '1'); body.append(csrfName, csrfHash); @@ -3063,7 +3229,7 @@ if (res.csrfName && res.csrfHash) { csrfName = res.csrfName; csrfHash = res.csrfHash; } if (res.ok == 1) { btn.className = 'btn btn-sm btn-success'; - btn.innerHTML = ' Submitted ✓'; + btn.innerHTML = ' Submitted v'; var rowSubmitBtn = document.querySelector('.btn-opd-consult-submit-gateway[data-opd-id="' + String(_fhirOpdId) + '"]'); if (rowSubmitBtn) { @@ -3088,11 +3254,11 @@ } var qInfo = res.queue_id ? ' Queue ID: ' + res.queue_id : ''; - alert('✅ Bundle submitted to ABDM bridge.\n' + (res.message || '') + qInfo); + alert(' Bundle submitted to ABDM bridge.\n' + (res.message || '') + qInfo); } else { btn.disabled = false; btn.innerHTML = origHtml; - alert('❌ Submission failed:\n' + (res.message || res.error_text || 'Check bridge configuration.') + (res.http_code ? ' (HTTP ' + res.http_code + ')' : '')); + alert(' Submission failed:\n' + (res.message || res.error_text || 'Check bridge configuration.') + (res.http_code ? ' (HTTP ' + res.http_code + ')' : '')); } }) .catch(function (e) { @@ -3102,7 +3268,7 @@ var msg = (e && e.name === 'AbortError') ? 'Request timed out before server response. Please retry.' : (e && e.message ? e.message : 'Request failed'); - alert('❌ Error: ' + msg); + alert(' Error: ' + msg); }); }); @@ -3111,19 +3277,19 @@ var jsonText = fhirModalJson ? (fhirModalJson.textContent || '{}') : '{}'; if (! navigator.clipboard || ! navigator.clipboard.writeText) { setStatus('Clipboard API unavailable.', true); return; } navigator.clipboard.writeText(jsonText).then(function () { - var orig = btn.textContent; btn.textContent = 'Copied ✓'; btn.className = 'btn btn-sm btn-success'; + var orig = btn.textContent; btn.textContent = 'Copied v'; btn.className = 'btn btn-sm btn-success'; window.setTimeout(function () { btn.textContent = orig; btn.className = 'btn btn-sm btn-outline-secondary'; }, 1500); }).catch(function (e) { setStatus('Copy failed: ' + e.message, true); }); }); - // OPD Consult Publish — FHIR preview buttons → open in modal + // OPD Consult Publish - FHIR preview buttons open in modal document.querySelectorAll('.btn-opd-consult-fhir').forEach(function (btn) { btn.addEventListener('click', function () { var opdId = parseInt(btn.getAttribute('data-opd-id') || '0', 10); var tr = btn.closest('tr'); var pushStatus = tr ? (tr.getAttribute('data-push-status') || '') : ''; if (opdId <= 0) return; - openFhirModal('/' + opdId, 'FHIR Preview — OPD #' + opdId, { pushStatus: pushStatus }); + openFhirModal('/' + opdId, 'FHIR Preview - OPD #' + opdId, { pushStatus: pushStatus }); }); }); @@ -3134,7 +3300,7 @@ if (opdId <= 0) return; openFhirModal( '/' + opdId, - 'Gateway Submit — OPD #' + opdId, + 'Gateway Submit - OPD #' + opdId, { submitMode: 'opd_gateway', abhaId: abhaId } ); }); @@ -3153,7 +3319,7 @@ + '?source=' + encodeURIComponent(source) + '&bill_id=' + encodeURIComponent(String(billId)) + '&patient_id=' + encodeURIComponent(String(patientId)); - openFhirModal(previewUrl, (pushIntent ? 'Review & Push' : 'FHIR Preview') + ' — Invoice #' + billId, { + openFhirModal(previewUrl, (pushIntent ? 'Review & Push' : 'FHIR Preview') + ' - Invoice #' + billId, { submitMode: 'invoice', taskRow: row, pushStatus: pushStatus, diff --git a/tests/unit/Abdm/FhirGeneratorsTest.php b/tests/unit/Abdm/FhirGeneratorsTest.php index 0ced269..c3f56c7 100644 --- a/tests/unit/Abdm/FhirGeneratorsTest.php +++ b/tests/unit/Abdm/FhirGeneratorsTest.php @@ -344,7 +344,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase $this->assertSame('2026-09-04T04:58:00+05:30', $encounterRes['period']['end'] ?? null); $this->assertNotEmpty($encounterRes['location'] ?? []); $this->assertSame('Ward: General Ward, Bed: GW-05', $encounterRes['location'][0]['location']['display'] ?? null); - $this->assertSame('active', $encounterRes['location'][0]['status'] ?? null); + $this->assertSame('completed', $encounterRes['location'][0]['status'] ?? null); $this->assertSame('2026-09-01T04:53:00+05:30', $encounterRes['location'][0]['period']['start'] ?? null); $this->assertSame('2026-09-04T04:58:00+05:30', $encounterRes['location'][0]['period']['end'] ?? null);