fix(abdm): align OPConsultRecord vs PrescriptionRecord bundles, separate OPD consultation from prescription discovery, and clean FHIR presentation

This commit is contained in:
DevSoftTech Server 2026-10-08 09:01:05 +05:30
parent 5130c6b18a
commit a72ab50777
6 changed files with 996 additions and 421 deletions

View File

@ -1441,19 +1441,76 @@ class AbdmGateway extends BaseController
public function sharePrescriptionBundle()
{
if (! $this->request->isAJAX()) {
if (method_exists($this->request, 'isAJAX') && ! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$opdId = (int) $this->request->getPost('opd_id');
$sessionId = (int) $this->request->getPost('opd_session_id');
$patientId = (int) $this->request->getPost('patient_id');
$abhaId = trim((string) $this->request->getPost('abha_id'));
$abhaAddressPost = trim((string) $this->request->getPost('abha_address'));
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
$jsonBody = [];
try {
$parsed = $this->request->getJSON(true);
if (is_array($parsed)) {
$jsonBody = $parsed;
}
} catch (\Throwable) {}
$opdId = (int) ($this->request->getPost('opd_id') ?? $jsonBody['opd_id'] ?? $_POST['opd_id'] ?? 0);
$sessionId = (int) ($this->request->getPost('opd_session_id') ?? $jsonBody['opd_session_id'] ?? $_POST['opd_session_id'] ?? 0);
$patientId = (int) ($this->request->getPost('patient_id') ?? $jsonBody['patient_id'] ?? $_POST['patient_id'] ?? 0);
$abhaId = trim((string) ($this->request->getPost('abha_id') ?? $jsonBody['abha_id'] ?? $_POST['abha_id'] ?? ''));
$abhaAddressPost = trim((string) ($this->request->getPost('abha_address') ?? $jsonBody['abha_address'] ?? $_POST['abha_address'] ?? ''));
$consentHandle = trim((string) ($this->request->getPost('consent_handle') ?? $jsonBody['consent_handle'] ?? $_POST['consent_handle'] ?? ''));
$pushToGateway = $this->resolveGatewayPushMode();
$now = Time::now('Asia/Kolkata')->toDateTimeString();
$ccRef = trim((string) ($this->request->getPost('care_context_reference') ?? $this->request->getPost('careContextId') ?? $jsonBody['care_context_reference'] ?? $jsonBody['careContextId'] ?? $_POST['care_context_reference'] ?? $_POST['careContextId'] ?? ''));
// If opdId passed is actually a prescription ID / session ID
if ($opdId > 0 && $this->db->tableExists('opd_master')) {
$exists = $this->db->table('opd_master')->where('opd_id', $opdId)->countAllResults();
if ($exists === 0 && $this->db->tableExists('opd_prescription')) {
$prRow = $this->db->table('opd_prescription')->select('opd_id, session_id')->where('id', $opdId)->get(1)->getRowArray();
if (! empty($prRow['opd_id'])) {
if ($sessionId <= 0) {
$sessionId = (int) ($prRow['session_id'] ?: $opdId);
}
$opdId = (int) $prRow['opd_id'];
}
}
}
// Fallback: Resolve opd_id from care_context_reference or patient_id if not supplied
if ($opdId <= 0 && $ccRef !== '') {
if (preg_match('/^OPD-\d+-S(\d+)/i', $ccRef, $m)) {
$sId = (int) $m[1];
if ($this->db->tableExists('opd_prescription')) {
$prRow = $this->db->table('opd_prescription')->select('opd_id')->where('id', $sId)->orWhere('session_id', $sId)->get(1)->getRowArray();
if (! empty($prRow['opd_id'])) {
$opdId = (int) $prRow['opd_id'];
$sessionId = $sId;
}
}
} elseif (preg_match('/^OPD-(\d+)/i', $ccRef, $m)) {
$candId = (int) $m[1];
if ($this->db->tableExists('opd_master') && $this->db->table('opd_master')->where('opd_id', $candId)->countAllResults() > 0) {
$opdId = $candId;
}
}
}
if ($opdId <= 0 && $patientId > 0 && $this->db->tableExists('opd_master')) {
$latestOpd = $this->db->table('opd_master')->select('opd_id')->where('p_id', $patientId)->orderBy('opd_id', 'DESC')->get(1)->getRowArray();
if (! empty($latestOpd['opd_id'])) {
$opdId = (int) $latestOpd['opd_id'];
}
}
// Resolve patientId if not supplied but opdId is known
if ($patientId <= 0 && $opdId > 0 && $this->db->tableExists('opd_master')) {
$opdRow = $this->db->table('opd_master')->select('p_id')->where('opd_id', $opdId)->get(1)->getRowArray();
if (! empty($opdRow['p_id'])) {
$patientId = (int) $opdRow['p_id'];
}
}
$logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGateway): void {
if (! $this->db->tableExists('abdm_api_logs')) {
return;
@ -1522,14 +1579,33 @@ class AbdmGateway extends BaseController
}
}
$requestedHiType = trim((string) ($this->request->getPost('hi_type') ?? ''));
$targetBundleType = 'OPConsultRecord';
if ($ccRef !== '' && preg_match('/^(?:PRESC|RX|PRESCRIPTION)-/i', $ccRef)) {
$targetBundleType = 'PrescriptionRecord';
} elseif (in_array($requestedHiType, ['MedicationRequestBundle', 'Prescription', 'PrescriptionRecord'], true)) {
$targetBundleType = 'PrescriptionRecord';
}
$builder = $this->db->table('opd_fhir_documents')
->where('opd_id', $opdId)
->whereIn('bundle_type', ['OPConsultRecord', 'MedicationRequestBundle', 'PrescriptionRecord']);
->where('bundle_type', $targetBundleType);
if ($sessionId > 0) {
$builder->where('opd_session_id', $sessionId);
}
$bundleRow = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
if (empty($bundleRow) && $opdId > 0) {
// Attempt on-demand FHIR bundle regeneration
try {
$opdCtrl = new \App\Controllers\Opd_prescription();
$opdCtrl->initController($this->request, $this->response, service('logger'));
$opdCtrl->regenerateFhirBundleInternal($opdId, $sessionId > 0 ? $sessionId : $opdId, $targetBundleType);
$bundleRow = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
} catch (\Throwable) {
}
}
if (empty($bundleRow)) {
$logBridge('error', ['ok' => 0, 'error_text' => 'No FHIR bundle found for selected OPD/session'], 'No FHIR bundle found for selected OPD/session', 'abdm.opd.prescription.share.validation');
return $this->response->setJSON(['ok' => 0, 'error_text' => 'No FHIR bundle found for selected OPD/session']);
@ -1554,20 +1630,7 @@ class AbdmGateway extends BaseController
]);
}
$requestedHiType = trim((string) ($this->request->getPost('hi_type') ?? ''));
$bundleType = trim((string) ($bundleRow['bundle_type'] ?? 'OPConsultRecord'));
if ($requestedHiType !== '') {
$hiType = match ($requestedHiType) {
'OPConsultation', 'OPConsultRecord' => 'OPConsultRecord',
'MedicationRequestBundle', 'Prescription', 'PrescriptionRecord' => 'PrescriptionRecord',
default => $requestedHiType,
};
} else {
$hiType = match ($bundleType) {
'OPConsultRecord', 'OPConsultation' => 'OPConsultRecord',
default => 'PrescriptionRecord',
};
}
$hiType = $targetBundleType;
$consentHandleResolved = is_array($consent) ? trim((string) ($consent['consent_handle'] ?? '')) : '';
$consentExternalId = is_array($consent) ? $this->resolveConsentExternalId($consent) : '';
$sessionForRef = $sessionId > 0 ? $sessionId : (int) ($bundleRow['opd_session_id'] ?? 0);
@ -1581,6 +1644,7 @@ class AbdmGateway extends BaseController
->select('care_context_reference')
->where('entity_type', 'opd')
->where('entity_id', (string) $opdId)
->where('hi_type', $hiType)
->where('care_context_reference !=', '')
->orderBy('id', 'DESC')
->get(1)
@ -1593,7 +1657,8 @@ class AbdmGateway extends BaseController
$careContextRef = $existingCcRef;
} else {
$dateClean = str_replace('-', '', $visitDate);
$careContextRef = 'OPD-' . $patientId . '-S' . ($sessionForRef > 0 ? $sessionForRef : 0) . '-' . $dateClean;
$prefix = $hiType === 'PrescriptionRecord' ? 'PRESC-' : 'OPD-';
$careContextRef = $prefix . $patientId . '-S' . ($sessionForRef > 0 ? $sessionForRef : 0) . '-' . $dateClean;
}
}
$careContextDisplay = $hiType === 'PrescriptionRecord'
@ -1846,7 +1911,7 @@ class AbdmGateway extends BaseController
$logBridge(
$connectorError === null ? 'success' : 'error',
$responsePayload,
$connectorError !== null ? (string) $connectorError : ($consentWarning !== '' ? $consentWarning : ''),
$connectorError !== null ? (string) $connectorError : '',
'abdm.opd.prescription.share.result'
);
@ -2151,7 +2216,7 @@ class AbdmGateway extends BaseController
$diagnosticReport = [
'id' => (string) $labReqId,
'title' => $testTitle ?: ($isImaging ? 'Radiology Report' : 'Laboratory Report'),
'status' => $labReq->status == 1 ? 'final' : 'preliminary',
'status' => in_array((int)$labReq->status, [1, 2], true) ? 'final' : 'preliminary',
'conclusion' => $cleanImpression,
'reported_at' => $reportedAt,
'report_html' => trim((string) ($labReq->Report_Data ?? '')),
@ -5022,51 +5087,60 @@ class AbdmGateway extends BaseController
continue;
}
// Strategy 0: Live On-Demand Fresh Generation for OPD Consult & Prescription Records
if (preg_match('/^(OPD|PRESCRIPTION|PRESC)-(\d+)(?:-S(\d+))?/i', $ref, $m)) {
$refPrefix = strtoupper($m[1]);
$targetPatientId = (int) $m[2];
$targetSessionId = (int) ($m[3] ?? 0);
$targetOpdId = 0;
if ($targetSessionId > 0 && $db->tableExists('opd_prescription')) {
$pRow = $db->table('opd_prescription')->select('opd_id')->where('id', $targetSessionId)->get(1)->getRowArray();
$targetOpdId = (int) ($pRow['opd_id'] ?? 0);
}
if ($targetOpdId <= 0 && $targetPatientId > 0 && $db->tableExists('opd_master')) {
$opdRow = $db->table('opd_master')->select('opd_id')->where('opd_id', $targetPatientId)->get(1)->getRowArray();
if ($opdRow) {
$targetOpdId = (int) $opdRow['opd_id'];
} else {
$opdRow = $db->table('opd_master')->select('opd_id')->where('p_id', $targetPatientId)->orderBy('opd_id', 'DESC')->get(1)->getRowArray();
$targetOpdId = (int) ($opdRow['opd_id'] ?? 0);
// Strategy 0a: Live On-Demand Fresh Generation for LAB / Diagnostic Report (LAB-xxxx or RAD-xxxx)
if (preg_match('/^(?:LAB|RAD)-/i', $ref)) {
try {
$diag = $this->buildDiagnosticReportBundle($ref);
if ($diag !== null && !empty($diag['bundle'])) {
$records[] = [
'careContextReference' => $ref,
'hiType' => $diag['hiType'] ?? 'DiagnosticReportRecord',
'display' => $diag['display'] ?? 'Diagnostic Report',
'bundle' => $diag['bundle'],
];
$this->cacheRecordBundle($ref, $diag['hiType'] ?? 'DiagnosticReportRecord', $diag['bundle'], $diag['display'] ?? 'Diagnostic Report');
continue;
}
} catch (\Throwable $ex) {
log_message('warning', 'Live DiagnosticReport FHIR generation in recordsFetch failed: ' . $ex->getMessage());
}
}
if ($targetOpdId > 0) {
try {
$isPrescriptionReq = in_array($refPrefix, ['PRESC', 'PRESCRIPTION'], true);
$targetBundleType = $isPrescriptionReq ? 'PrescriptionRecord' : 'OPConsultRecord';
$targetHiType = $isPrescriptionReq ? 'PrescriptionRecord' : 'OPConsultRecord';
$targetDisplay = $isPrescriptionReq
? ('Prescription - ' . date('d M Y'))
: ('Consultation Record - ' . date('d M Y'));
$opdCtrl = new \App\Controllers\Opd_prescription();
$opdCtrl->initController($this->request, $this->response, service('logger'));
$regen = $opdCtrl->regenerateFhirBundleInternal($targetOpdId, $targetSessionId, $targetBundleType);
if (!empty($regen['ok']) && !empty($regen['bundle'])) {
$records[] = [
'careContextReference' => $ref,
'hiType' => $targetHiType,
'display' => $targetDisplay,
'bundle' => $regen['bundle'],
];
continue;
}
} catch (\Throwable $ex) {
log_message('warning', 'Live FHIR bundle generation in recordsFetch failed: ' . $ex->getMessage());
// Strategy 0b: Live On-Demand Fresh Generation for Discharge Summary (DISCHARGE-xxxx or IPD-xxxx)
if (preg_match('/^(?:DISCHARGE|IPD)-/i', $ref)) {
try {
$discharge = $this->buildDischargeSummaryBundle($ref);
if ($discharge !== null && !empty($discharge['bundle'])) {
$records[] = [
'careContextReference' => $ref,
'hiType' => $discharge['hiType'] ?? 'DischargeSummaryRecord',
'display' => $discharge['display'] ?? 'Discharge Summary',
'bundle' => $discharge['bundle'],
];
$this->cacheRecordBundle($ref, $discharge['hiType'] ?? 'DischargeSummaryRecord', $discharge['bundle'], $discharge['display'] ?? 'Discharge Summary');
continue;
}
} catch (\Throwable $ex) {
log_message('warning', 'Live DischargeSummary FHIR generation in recordsFetch failed: ' . $ex->getMessage());
}
}
// Strategy 0c: Live On-Demand Fresh Generation for OPD Consult & Prescription Records
if (preg_match('/^(OPD|PRESCRIPTION|PRESC|RX)-(\d+)(?:-S(\d+))?/i', $ref, $m)) {
try {
$opd = $this->buildOpConsultationBundle($ref);
if ($opd !== null && !empty($opd['bundle'])) {
$records[] = [
'careContextReference' => $ref,
'hiType' => $opd['hiType'] ?? 'OPConsultRecord',
'display' => $opd['display'] ?? 'Consultation Record',
'bundle' => $opd['bundle'],
];
$this->cacheRecordBundle($ref, $opd['hiType'] ?? 'OPConsultRecord', $opd['bundle'], $opd['display'] ?? 'Consultation Record');
continue;
}
} catch (\Throwable $ex) {
log_message('warning', 'Live FHIR bundle generation in recordsFetch failed: ' . $ex->getMessage());
}
}
@ -5327,6 +5401,320 @@ class AbdmGateway extends BaseController
]);
}
/**
* Build an ABDM M3 compliant DiagnosticReportRecord FHIR Document Bundle on the fly
* Supports both numeric lab_req_id and care context references like 'LAB-51459-20261008' or 'RAD-51459-20261008'
*
* @param string|int $labReqIdOrRef
* @return array{careContextReference: string, hiType: string, display: string, bundle: array<string,mixed>}|null
*/
public function buildDiagnosticReportBundle(string|int $labReqIdOrRef): ?array
{
$labReqId = 0;
$refStr = trim((string) $labReqIdOrRef);
if (is_numeric($labReqIdOrRef)) {
$labReqId = (int) $labReqIdOrRef;
} elseif (preg_match('/^(?:LAB|RAD)-(\d+)/i', $refStr, $m)) {
$labReqId = (int) $m[1];
}
if ($labReqId <= 0) {
return null;
}
$db = $this->db ?? \Config\Database::connect();
if (! $db->tableExists('lab_request')) {
return null;
}
$selectCols = ['id', 'patient_id', 'patient_name', 'lab_type', 'charge_id', 'report_name', 'Report_Data', 'report_data_Impression', 'status', 'reported_time'];
if ($db->fieldExists('charge_item_id', 'lab_request')) {
$selectCols[] = 'charge_item_id';
}
if ($db->fieldExists('lab_repo_id', 'lab_request')) {
$selectCols[] = 'lab_repo_id';
}
$labReq = $db->table('lab_request')
->select(implode(', ', $selectCols))
->where('id', $labReqId)
->get(1)
->getRow();
if (! $labReq) {
return null;
}
$patientId = (int) ($labReq->patient_id ?? 0);
$patientRow = [];
if ($patientId > 0 && $db->tableExists('patient_master')) {
$patientRow = $db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray() ?? [];
}
$patName = $this->patientDisplayName($patientRow);
if ($patName === '') {
$patName = trim((string) ($labReq->patient_name ?? ''));
}
$abhaIdentity = $this->resolvePatientAbhaIdentity($patientId);
$abhaNumber = $abhaIdentity['abha_id'] ?? '';
$abhaAddress = $abhaIdentity['abha_address'] ?? '';
$abhaId = $abhaAddress !== '' ? $abhaAddress : $abhaNumber;
$testTitle = trim((string) ($labReq->report_name ?? ''));
$labType = (int) ($labReq->lab_type ?? 0);
$isImaging = in_array($labType, [1, 2, 3, 4, 6], true);
if ($testTitle === '') {
$labRepoId = (int) ($labReq->lab_repo_id ?? 0);
if ($labRepoId > 0 && $db->tableExists('lab_repo')) {
$repoRow = $db->table('lab_repo')->select('Repo, RepoName')->where('mstRepoKey', $labRepoId)->get(1)->getRowArray() ?? [];
$testTitle = trim((string) ($repoRow['RepoName'] ?? $repoRow['Repo'] ?? ''));
}
}
if ($testTitle === '') {
$chargeItemId = (int) ($labReq->charge_item_id ?? 0);
if ($chargeItemId > 0 && $db->tableExists('invoice_item')) {
$invItem = $db->table('invoice_item')->select('item_name')->where('id', $chargeItemId)->get(1)->getRowArray() ?? [];
$testTitle = trim((string) ($invItem['item_name'] ?? ''));
}
}
if ($testTitle === '') {
$chargeId = (int) ($labReq->charge_id ?? 0);
if ($chargeId > 0 && $db->tableExists('charge_master')) {
$chargeRow = $db->table('charge_master')->select('charge_name')->where('id', $chargeId)->get(1)->getRowArray() ?? [];
$testTitle = trim((string) ($chargeRow['charge_name'] ?? ''));
}
}
if ($testTitle === '') {
$testTitle = $this->mapLabTypeToTitle($labType) ?: ($isImaging ? 'Radiology Report' : 'Laboratory Report');
}
$hospitalProfile = $this->getHospitalProfileForFhir();
$reportedRaw = trim((string) ($labReq->reported_time ?? ''));
$reportedAt = $reportedRaw !== '' ? (new \DateTime($reportedRaw, new \DateTimeZone('Asia/Kolkata')))->format('Y-m-d\TH:i:sP') : date('Y-m-d\TH:i:sP');
$visitDate = $reportedRaw !== '' ? date('Y-m-d', strtotime($reportedRaw)) : date('Y-m-d');
$rawImpression = trim((string) ($labReq->report_data_Impression ?? ''));
$cleanImpression = trim(html_entity_decode(strip_tags($rawImpression), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
$diagnosticReport = [
'id' => (string) $labReqId,
'title' => $testTitle ?: ($isImaging ? 'Radiology Report' : 'Laboratory Report'),
'status' => in_array((int)$labReq->status, [1, 2], true) ? 'final' : 'preliminary',
'conclusion' => $cleanImpression,
'reported_at' => $reportedAt,
'report_html' => trim((string) ($labReq->Report_Data ?? '')),
'lab_type' => $labType,
];
if ($isImaging) {
$diagnosticReport['is_imaging'] = true;
$diagnosticReport['report_domain'] = 'imaging';
$modalityName = $this->mapLabTypeToTitle($labType) ?: 'Diagnostic imaging';
$diagnosticReport['section_title'] = $modalityName . ' report';
$diagnosticReport['section_snomed_code'] = '371531008';
$diagnosticReport['section_snomed_display'] = 'Diagnostic imaging report';
}
$repoLoincCode = '';
if ($db->tableExists('lab_repo')) {
$labRepoRow = $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')
->where('lr.id', $labReqId)
->get(1)
->getRowArray() ?? [];
$repoLoincCode = trim((string) ($labRepoRow['repo_loinc_code'] ?? ''));
}
if ($repoLoincCode !== '') {
$diagnosticReport['loinc_code'] = $repoLoincCode;
}
$observations = $this->buildLabObservations($labReqId, (string) ($labReq->status ?? '0'));
$organization = ! empty($hospitalProfile['name'])
? ['name' => $hospitalProfile['name'], 'hfr_id' => $hospitalProfile['hfr_id'] ?? '']
: null;
$practitioner = $this->resolveLabPractitionerForFhir($labReqId, $labReq);
$encounter = [
'id' => 'LAB-' . $labReqId,
'status' => 'finished',
'class_code' => 'AMB',
'period_start' => $reportedAt,
];
$patient = [
'id' => (string) $patientId,
'name' => $patName,
'gender' => trim((string) ($patientRow['gender'] ?? '')),
'birthDate' => ! empty($patientRow['dob']) ? date('Y-m-d', strtotime((string) $patientRow['dob'])) : '',
'abhaAddress' => $abhaId,
];
$pdfAttachment = null;
try {
$pdfAttachment = $this->buildDiagnosticDigitalSharePdf(
$patientRow,
$abhaIdentity,
$diagnosticReport,
$observations,
$practitioner,
$hospitalProfile,
$labReqId
);
} catch (\Throwable $e) {
log_message('warning', 'PDF attachment generation failed in buildDiagnosticReportBundle: ' . $e->getMessage());
}
$fhir = new FhirR4Builder();
$bundle = $fhir->buildLabReportBundle($patient, $diagnosticReport, $observations, $practitioner, $organization, $encounter, $pdfAttachment);
$cleanTestTitle = self::sanitizeCareContextDisplay($testTitle !== '' ? $testTitle : ($isImaging ? 'Radiology Report' : 'Lab Report'));
$display = self::sanitizeCareContextDisplay(($isImaging ? 'Radiology Report - ' : 'Diagnostic Report - ') . $cleanTestTitle . ' ' . date('d M Y', strtotime($visitDate)));
$ccRef = $refStr !== '' ? $refStr : (($isImaging ? 'RAD-' : 'LAB-') . $labReqId . '-' . str_replace('-', '', $visitDate));
return [
'careContextReference' => $ccRef,
'hiType' => 'DiagnosticReportRecord',
'display' => $display,
'bundle' => $bundle,
];
}
/**
* Build an ABDM M3 compliant DischargeSummaryRecord FHIR Document Bundle on the fly
* Supports both numeric ipd_id and care context references like 'DISCHARGE-3211-20260906' or 'IPD-3211'
*
* @param string|int $ipdIdOrRef
* @return array{careContextReference: string, hiType: string, display: string, bundle: array<string,mixed>}|null
*/
public function buildDischargeSummaryBundle(string|int $ipdIdOrRef): ?array
{
$ipdId = 0;
$refStr = trim((string) $ipdIdOrRef);
if (is_numeric($ipdIdOrRef)) {
$ipdId = (int) $ipdIdOrRef;
} elseif (preg_match('/^(?:DISCHARGE|IPD)-(\d+)/i', $refStr, $m)) {
$ipdId = (int) $m[1];
}
if ($ipdId <= 0) {
return null;
}
$payload = $this->buildIpdDischargeGatewayPayload($ipdId, 0, '', false);
if ($payload === null || empty($payload['bundle'])) {
return null;
}
$display = (string) ($payload['care_context_display'] ?? ('Discharge Summary - ' . date('d M Y')));
$ccRef = $refStr !== '' ? $refStr : (string) ($payload['care_context_reference'] ?? ('DISCHARGE-' . $ipdId . '-' . date('Ymd')));
return [
'careContextReference' => $ccRef,
'hiType' => 'DischargeSummaryRecord',
'display' => $display,
'bundle' => $payload['bundle'],
];
}
/**
* Build an ABDM M3 compliant OPConsultRecord or PrescriptionRecord FHIR Document Bundle on the fly
* Supports care context references like 'OPD-15355-S33729-20261008' or 'PRESC-15355-S33729-20261008'
*
* @param string $ref
* @return array{careContextReference: string, hiType: string, display: string, bundle: array<string,mixed>}|null
*/
public function buildOpConsultationBundle(string $ref): ?array
{
$db = $this->db ?? \Config\Database::connect();
if (! preg_match('/^(OPD|PRESCRIPTION|PRESC|RX)-(\d+)(?:-S(\d+))?/i', $ref, $m)) {
return null;
}
$refPrefix = strtoupper($m[1]);
$targetPatientId = (int) $m[2];
$targetSessionId = (int) ($m[3] ?? 0);
$targetOpdId = 0;
if ($targetSessionId > 0 && $db->tableExists('opd_prescription')) {
$pRow = $db->table('opd_prescription')->select('opd_id')->where('id', $targetSessionId)->get(1)->getRowArray();
$targetOpdId = (int) ($pRow['opd_id'] ?? 0);
}
if ($targetOpdId <= 0 && $targetPatientId > 0 && $db->tableExists('opd_master')) {
$opdRow = $db->table('opd_master')->select('opd_id')->where('opd_id', $targetPatientId)->get(1)->getRowArray();
if ($opdRow) {
$targetOpdId = (int) $opdRow['opd_id'];
} else {
$opdRow = $db->table('opd_master')->select('opd_id')->where('p_id', $targetPatientId)->orderBy('opd_id', 'DESC')->get(1)->getRowArray();
$targetOpdId = (int) ($opdRow['opd_id'] ?? 0);
}
}
if ($targetOpdId <= 0) {
return null;
}
try {
$isPrescriptionReq = in_array($refPrefix, ['PRESC', 'PRESCRIPTION', 'RX'], true);
$targetBundleType = $isPrescriptionReq ? 'PrescriptionRecord' : 'OPConsultRecord';
$targetHiType = $isPrescriptionReq ? 'PrescriptionRecord' : 'OPConsultRecord';
$targetDisplay = $isPrescriptionReq
? ('Prescription - ' . date('d M Y'))
: ('Consultation Record - ' . date('d M Y'));
$opdCtrl = new \App\Controllers\Opd_prescription();
$opdCtrl->initController($this->request, $this->response, service('logger'));
$regen = $opdCtrl->regenerateFhirBundleInternal($targetOpdId, $targetSessionId, $targetBundleType);
if (!empty($regen['ok']) && !empty($regen['bundle'])) {
return [
'careContextReference' => $ref,
'hiType' => $targetHiType,
'display' => $targetDisplay,
'bundle' => $regen['bundle'],
];
}
} catch (\Throwable $ex) {
log_message('warning', 'Live FHIR bundle generation in buildOpConsultationBundle failed: ' . $ex->getMessage());
}
return null;
}
/**
* Cache freshly generated bundle in health_records table for quick retrieval
*/
private function cacheRecordBundle(string $ref, string $hiType, array $bundle, string $display = ''): void
{
try {
$db = $this->db ?? \Config\Database::connect();
if (! $db->tableExists('health_records')) {
return;
}
$bundleJson = json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$exHr = $db->table('health_records')->where('care_context_reference', $ref)->get(1)->getRowArray();
if ($exHr) {
if (empty($exHr['record_data'])) {
$db->table('health_records')->where('id', (int) $exHr['id'])->update([
'record_data' => $bundleJson,
'push_status' => 'linked',
'updated_at' => date('Y-m-d H:i:s'),
]);
}
} else {
$db->table('health_records')->insert([
'care_context_reference' => $ref,
'hi_type' => $hiType,
'record_data' => $bundleJson,
'push_status' => 'linked',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
} catch (\Throwable $e) {
log_message('warning', 'cacheRecordBundle error: ' . $e->getMessage());
}
}
/**
* Helper to assemble a minimal ABDM-compliant FHIR Document Bundle on the fly
*/
@ -6872,7 +7260,7 @@ class AbdmGateway extends BaseController
$isReady = $hasStoredDoc || $hasHr || $isVisitDone || $hasFindings || $hasMeds || $hasVitals;
// Single unified care context covering consult note, wellness, prescription, and documents
// 1. Doctor Visit / OPD Consultation record
$addContext([
'careContextId' => $ccRef,
'referenceNumber' => $ccRef,
@ -6882,11 +7270,41 @@ class AbdmGateway extends BaseController
'patient_id' => $patientId,
'is_fhir_ready' => $isReady,
'is_primary' => false,
'entity_id' => (string) $opdId,
'opd_id' => $opdId,
'session_id' => $sessionId,
]);
$seenRefs[$aliasRef] = true;
$seenRefs['OPD-' . $opdId . '-S' . $sessionId . '-' . $visitDate] = true;
$seenRefs['OPD-' . $opdId . '-S' . $sessionId . '-' . $cleanDate] = true;
$seenRefs['OPD-' . $patientId . '-S' . $sessionId . '-' . $visitDate] = true;
// 2. If medicines are prescribed, also discover distinct PrescriptionRecord
if ($hasMeds) {
$prescCcRef = 'PRESC-' . $patientId . '-S' . $sessionId . '-' . $cleanDate;
$prescAliasRef = 'PRESC-' . $opdId . '-S' . $sessionId . '-' . $cleanDate;
$prescDisplay = 'Prescription - ' . ($docName !== '' ? 'Dr. ' . $docName . ' - ' : '') . $dateStr;
if (strlen($prescDisplay) > 85) {
$prescDisplay = 'Prescription - ' . $dateStr;
}
$addContext([
'careContextId' => $prescCcRef,
'referenceNumber' => $prescCcRef,
'alias_reference' => $prescAliasRef,
'display' => $prescDisplay,
'record_type' => 'PrescriptionRecord',
'patient_id' => $patientId,
'is_fhir_ready' => $isReady,
'is_primary' => false,
'entity_id' => (string) $opdId,
'opd_id' => $opdId,
'session_id' => $sessionId,
]);
$seenRefs[$prescAliasRef] = true;
$seenRefs['PRESC-' . $opdId . '-S' . $sessionId . '-' . $visitDate] = true;
$seenRefs['PRESC-' . $opdId . '-S' . $sessionId . '-' . $cleanDate] = true;
$seenRefs['PRESC-' . $patientId . '-S' . $sessionId . '-' . $visitDate] = true;
}
if ($sessionId > 0) {
$seenOpdSessions[$sessionId] = true;
}
@ -7283,6 +7701,73 @@ class AbdmGateway extends BaseController
]);
}
$extractDate = static function (array $cc): int {
foreach (['visit_date', 'date', 'created_at', 'insert_date', 'date_issue'] as $field) {
if (! empty($cc[$field])) {
$ts = strtotime((string) $cc[$field]);
if ($ts !== false && $ts > 0) {
return $ts;
}
}
}
$ref = (string) ($cc['referenceNumber'] ?? $cc['careContextId'] ?? '');
if (preg_match('/(\d{4})(\d{2})(\d{2})/', $ref, $m)) {
$check = $m[1] . '-' . $m[2] . '-' . $m[3];
$ts = strtotime($check);
if ($ts !== false && $ts > 0) {
return $ts;
}
}
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $ref, $m)) {
$ts = strtotime($m[1]);
if ($ts !== false && $ts > 0) {
return $ts;
}
}
$disp = (string) ($cc['display'] ?? '');
if (preg_match('/(\d{1,2}\s+[A-Za-z]{3}\s+\d{4})/', $disp, $m)) {
$ts = strtotime($m[1]);
if ($ts !== false && $ts > 0) {
return $ts;
}
}
if (preg_match('/(\d{4}-\d{2}-\d{2})/', $disp, $m)) {
$ts = strtotime($m[1]);
if ($ts !== false && $ts > 0) {
return $ts;
}
}
return 0;
};
usort($careContextsFull, function ($a, $b) use ($extractDate) {
$pA = ! empty($a['is_primary']) ? 1 : 0;
$pB = ! empty($b['is_primary']) ? 1 : 0;
if ($pA !== $pB) {
return $pB - $pA;
}
$tA = $extractDate($a);
$tB = $extractDate($b);
if ($tA !== $tB) {
return $tB - $tA;
}
return strcmp((string) ($b['referenceNumber'] ?? ''), (string) ($a['referenceNumber'] ?? ''));
});
$careContextsV3 = [];
foreach ($careContextsFull as $item) {
$ref = trim((string) ($item['referenceNumber'] ?? $item['careContextId'] ?? ''));
$recType = trim((string) ($item['record_type'] ?? ''));
$hiType = $item['hiType'] ?? $this->mapRecordTypeToAbdmHiType($recType);
$careContextsV3[] = [
'referenceNumber' => $ref,
'display' => $item['display'] ?? $ref,
'hiType' => $hiType,
'record_type' => $recType,
'is_fhir_ready' => ! empty($item['is_fhir_ready']),
];
}
return [$careContextsV3, $careContextsFull];
}
@ -12315,6 +12800,7 @@ class AbdmGateway extends BaseController
'is_linked' => $isLinked,
'is_fhir_ready' => (bool) ($cc['is_fhir_ready'] ?? false),
'is_primary' => (bool) ($cc['is_primary'] ?? false),
'entity_id' => (string) ($cc['entity_id'] ?? $cc['opd_id'] ?? ''),
];
}

View File

@ -780,9 +780,9 @@ class AbdmTaskBoard extends BaseController
$linked = $this->db->tableExists('health_records') && ! empty($this->db->table('health_records')
->select('id')
->where('hi_type', 'ImmunizationRecord')
->where('entity_type', 'immunization')
->whereIn('entity_type', ['immunization', 'immunization_record_publish'])
->where('entity_id', (string) $recordId)
->where('push_status', 'linked')
->whereIn('push_status', ['linked', 'queued', 'pushed', 'done'])
->get(1)
->getRowArray());
if ($linked) {
@ -792,9 +792,9 @@ class AbdmTaskBoard extends BaseController
$exists = $this->db->table('abdm_work_tasks')
->select('id')
->where('task_type', 'immunization_record_publish')
->where('entity_type', 'immunization')
->whereIn('entity_type', ['immunization', 'immunization_records', 'immunization_record_publish'])
->where('entity_id', (string) $recordId)
->whereIn('status', ['pending', 'in_progress'])
->whereIn('status', ['pending', 'in_progress', 'completed'])
->get(1)
->getRowArray();
if (! empty($exists)) {

View File

@ -4934,7 +4934,11 @@ class Opd_prescription extends BaseController
}
$normalizedBundleType = in_array($bundleType, ['Prescription', 'PrescriptionRecord'], true) ? 'PrescriptionRecord' : 'OPConsultRecord';
$clinicalContext['bundle_type'] = $normalizedBundleType;
$bundle = $this->fhirR4Builder->buildOpConsultBundle($patient, $encounter, $medications, $conditions, $clinicalContext);
if ($normalizedBundleType === 'PrescriptionRecord') {
$bundle = $this->fhirR4Builder->buildPrescriptionBundle($patient, $encounter, $medications, [], $clinicalContext);
} else {
$bundle = $this->fhirR4Builder->buildOpConsultBundle($patient, $encounter, $medications, $conditions, $clinicalContext);
}
$bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$generatedBy = 'system';
@ -11922,12 +11926,50 @@ class Opd_prescription extends BaseController
]);
}
// Clean up redundant standalone bundles in opd_fhir_documents so only unified OPConsultRecord is kept
// If medicines are prescribed, also store a distinct, compliant PrescriptionRecord bundle
$prescJson = null;
if (! empty($medications)) {
$prescContext = $clinicalContext;
$prescContext['bundle_type'] = 'PrescriptionRecord';
$prescBundle = $builder->buildPrescriptionBundle($patient, $encounter, $medications, [], $prescContext);
$prescJson = (string) json_encode($prescBundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$existingPresc = $this->db->table('opd_fhir_documents')
->where('opd_id', $opdId)
->where('opd_session_id', $sessionId)
->where('bundle_type', 'PrescriptionRecord')
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if (! empty($existingPresc['id'])) {
$this->db->table('opd_fhir_documents')
->where('id', (int) $existingPresc['id'])
->update([
'bundle_json' => $prescJson,
'generated_by' => $generatedBy,
'generated_at' => $now,
'updated_at' => $now,
]);
} else {
$this->db->table('opd_fhir_documents')->insert([
'opd_id' => $opdId,
'opd_session_id' => $sessionId,
'patient_id' => (int) ($patient['id'] ?? 0),
'bundle_type' => 'PrescriptionRecord',
'bundle_json' => $prescJson,
'generated_by' => $generatedBy,
'generated_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
// Clean up legacy/obsolete bundle types
try {
$this->db->table('opd_fhir_documents')
->where('opd_id', $opdId)
->where('opd_session_id', $sessionId)
->whereIn('bundle_type', ['PrescriptionRecord', 'MedicationRequestBundle', 'WellnessRecord'])
->whereIn('bundle_type', ['MedicationRequestBundle', 'WellnessRecord'])
->delete();
} catch (\Throwable) {
}
@ -11944,7 +11986,7 @@ class Opd_prescription extends BaseController
$cleanDate = str_replace('-', '', $visitDate);
$careContextRef = 'OPD-' . $patientId . '-S' . $sessionId . '-' . $cleanDate;
// 1. Upsert single unified OPConsultRecord (covers Consult, Rx, Vitals, and Attachments)
// 1. Upsert OPConsultRecord (covers Doctor Visit Note)
$existingHr = $this->db->table('health_records')
->select('id')
->where('care_context_reference', $careContextRef)
@ -11970,9 +12012,35 @@ class Opd_prescription extends BaseController
$this->db->table('health_records')->insert($hrPayload);
}
// Delete redundant standalone PRESC / WELLNESS / alias OPD records so only the single unified bundle is kept
// 2. If medications exist, also upsert distinct PrescriptionRecord
if (! empty($medications) && $prescJson !== null) {
$prescCcRef = 'PRESC-' . $patientId . '-S' . $sessionId . '-' . $cleanDate;
$existingPrescHr = $this->db->table('health_records')
->select('id')
->where('care_context_reference', $prescCcRef)
->get(1)
->getRowArray();
$prescHrPayload = [
'patient_id' => $patientId > 0 ? $patientId : null,
'abha_id' => $abhaAddress !== '' ? $abhaAddress : null,
'hi_type' => 'PrescriptionRecord',
'entity_type' => 'opd',
'entity_id' => (string) $opdId,
'record_data' => $prescJson,
'care_context_reference' => $prescCcRef,
'push_status' => 'local_discovery_ready',
'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(),
];
if (! empty($existingPrescHr)) {
$this->db->table('health_records')->where('id', (int) $existingPrescHr['id'])->update($prescHrPayload);
} else {
$prescHrPayload['created_at'] = Time::now('Asia/Kolkata')->toDateTimeString();
$this->db->table('health_records')->insert($prescHrPayload);
}
}
// Delete obsolete legacy alias records
$redundantRefs = [
'PRESC-' . $patientId . '-S' . $sessionId . '-' . $cleanDate,
'WELLNESS-' . $sessionId . '-' . $cleanDate,
'OPD-' . $opdId . '-S' . $sessionId . '-' . $visitDate,
'OPD-' . $opdId . '-S' . $sessionId . '-' . $cleanDate,

View File

@ -494,7 +494,7 @@ class AbdmTaskBoardSyncService
// 3. Update health_records with final push status
if ($healthRecordId > 0 && $this->db->tableExists('health_records')) {
$hrUpdate = [
'push_status' => $isSuccess ? 'queued' : 'failed',
'push_status' => $isSuccess ? 'linked' : 'failed',
'updated_at' => $now,
];
if ($queueId !== null && $queueId !== '') {
@ -982,7 +982,7 @@ class AbdmTaskBoardSyncService
'entity_type' => $entityType,
'entity_id' => $entityId,
'care_context_reference' => $careContextRef,
'push_status' => 'queued',
'push_status' => 'linked',
'abdm_txn_id' => $queueId,
'updated_at' => $now,
];
@ -1008,7 +1008,7 @@ class AbdmTaskBoardSyncService
'care_context_reference' => $careContextRef,
'abdm_txn_id' => $queueId,
'abha_id' => $abhaId,
'link_status' => 'pending',
'link_status' => 'linked',
'created_at' => $now,
'updated_at' => $now,
]);

View File

@ -42,6 +42,9 @@ class FhirR4Builder
$appointments = is_array($context['appointments'] ?? null) ? (array) $context['appointments'] : [];
$attachments = is_array($context['attachments'] ?? null) ? (array) $context['attachments'] : [];
$requestedBundleType = trim((string) ($context['bundle_type'] ?? $context['record_type'] ?? 'PrescriptionRecord'));
$isOpConsult = in_array($requestedBundleType, ['OPConsultRecord', 'OPConsultation'], true);
// UUID-based identity for every resource (ABDM IG v6.5.0 requirement)
$bundleUuid = $this->generateUuid();
$compositionUuid = $this->generateUuid();
@ -142,221 +145,225 @@ class FhirR4Builder
}
// ── Conditions (diagnoses) ────────────────────────────────────────────
foreach ($conditions as $index => $condition) {
$text = trim((string) ($condition['text'] ?? ''));
if ($text === '') {
continue;
}
if ($isOpConsult) {
foreach ($conditions as $index => $condition) {
$text = trim((string) ($condition['text'] ?? ''));
if ($text === '') {
continue;
}
$condUuid = $this->generateUuid();
$condRef = 'urn:uuid:' . $condUuid;
$verification = trim((string) ($condition['verification_status'] ?? 'provisional'));
$verificationCode = strtolower($verification) === 'confirmed' ? 'confirmed' : 'provisional';
$useSnomedCode = $verificationCode === 'confirmed' ? '39154008' : '148006';
$useSnomedDisplay = $verificationCode === 'confirmed' ? 'Clinical diagnosis' : 'Preliminary diagnosis';
$condUuid = $this->generateUuid();
$condRef = 'urn:uuid:' . $condUuid;
$verification = trim((string) ($condition['verification_status'] ?? 'provisional'));
$verificationCode = strtolower($verification) === 'confirmed' ? 'confirmed' : 'provisional';
$useSnomedCode = $verificationCode === 'confirmed' ? '39154008' : '148006';
$useSnomedDisplay = $verificationCode === 'confirmed' ? 'Clinical diagnosis' : 'Preliminary diagnosis';
$encounterDiagnosisRefs[] = [
'condition' => ['reference' => $condRef, 'display' => 'Condition'],
'use' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => $useSnomedCode,
'display' => $useSnomedDisplay,
]]],
];
$encounterDiagnosisRefs[] = [
'condition' => ['reference' => $condRef, 'display' => 'Condition'],
'use' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => $useSnomedCode,
'display' => $useSnomedDisplay,
]]],
];
$code = ['text' => $text];
$snomedCode = trim((string) ($condition['snomed_code'] ?? ''));
if ($snomedCode !== '') {
$code['coding'] = [[
'system' => 'http://snomed.info/sct',
'code' => $snomedCode,
'display' => trim((string) ($condition['snomed_display'] ?? $text)),
$code = ['text' => $text];
$snomedCode = trim((string) ($condition['snomed_code'] ?? ''));
if ($snomedCode !== '') {
$code['coding'] = [[
'system' => 'http://snomed.info/sct',
'code' => $snomedCode,
'display' => trim((string) ($condition['snomed_display'] ?? $text)),
]];
}
$resourceEntries[] = ['fullUrl' => $condRef, 'resource' => [
'resourceType' => 'Condition',
'id' => $condUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']],
'clinicalStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical',
'code' => 'active',
'display' => 'Active',
]]],
'verificationStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status',
'code' => $verificationCode,
'display' => ucfirst($verificationCode),
]]],
'code' => $code,
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'recordedDate' => $issuedAt,
]];
}
$resourceEntries[] = ['fullUrl' => $condRef, 'resource' => [
'resourceType' => 'Condition',
'id' => $condUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']],
'clinicalStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical',
'code' => 'active',
'display' => 'Active',
]]],
'verificationStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status',
'code' => $verificationCode,
'display' => ucfirst($verificationCode),
]]],
'code' => $code,
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'recordedDate' => $issuedAt,
]];
}
// Append Encounter (now has diagnosis refs)
if (! empty($encounterDiagnosisRefs)) {
$encounterResource['diagnosis'] = $encounterDiagnosisRefs;
// Append Encounter (now has diagnosis refs)
if (! empty($encounterDiagnosisRefs)) {
$encounterResource['diagnosis'] = $encounterDiagnosisRefs;
}
}
$resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource];
// ── Complaints (Chief Complaints) ─────────────────────────────────
foreach ($complaints as $index => $complaint) {
$text = trim((string) ($complaint['text'] ?? ''));
if ($text === '') {
continue;
if ($isOpConsult) {
// ── Complaints (Chief Complaints) ─────────────────────────────────
foreach ($complaints as $index => $complaint) {
$text = trim((string) ($complaint['text'] ?? ''));
if ($text === '') {
continue;
}
$complaintUuid = $this->generateUuid();
$complaintRef = 'urn:uuid:' . $complaintUuid;
$complaintRefs[] = ['reference' => $complaintRef, 'display' => 'Condition'];
$code = ['text' => $text];
$snomedCode = trim((string) ($complaint['snomed_code'] ?? ''));
if ($snomedCode !== '') {
$code['coding'] = [[
'system' => 'http://snomed.info/sct',
'code' => $snomedCode,
'display' => trim((string) ($complaint['snomed_display'] ?? $text)),
]];
}
$complaintResource = [
'resourceType' => 'Condition',
'id' => $complaintUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']],
'clinicalStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical',
'code' => 'active',
'display' => 'Active',
]]],
'code' => $code,
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'recordedDate' => $issuedAt,
];
// Severity (High/Moderate/Low → SNOMED coded)
$severityText = trim((string) ($complaint['severity'] ?? ''));
if ($severityText !== '') {
$severityMap = [
'high' => ['24484000', 'Severe'],
'severe' => ['24484000', 'Severe'],
'moderate' => ['6736007', 'Moderate'],
'mild' => ['255604002', 'Mild'],
'low' => ['255604002', 'Mild'],
];
$severityKey = strtolower($severityText);
[$sevCode, $sevDisplay] = $severityMap[$severityKey] ?? ['', $severityText];
$severityCoding = ['text' => ucfirst($severityText)];
if ($sevCode !== '') {
$severityCoding['coding'] = [[
'system' => 'http://snomed.info/sct',
'code' => $sevCode,
'display' => $sevDisplay,
]];
}
$complaintResource['severity'] = $severityCoding;
}
// Duration → note
$durationText = trim((string) ($complaint['duration'] ?? ''));
$frequencyText = trim((string) ($complaint['frequency'] ?? ''));
$noteParts = array_filter([$durationText, $frequencyText]);
if (! empty($noteParts)) {
$complaintResource['note'] = [['text' => implode(' | ', $noteParts)]];
}
$resourceEntries[] = ['fullUrl' => $complaintRef, 'resource' => $complaintResource];
}
$complaintUuid = $this->generateUuid();
$complaintRef = 'urn:uuid:' . $complaintUuid;
$complaintRefs[] = ['reference' => $complaintRef, 'display' => 'Condition'];
$code = ['text' => $text];
// ── Observations (Vitals / Physical Examination) ──────────────────────
foreach ($observations as $index => $observation) {
$value = $observation['value'] ?? null;
if (! is_numeric($value)) {
continue;
}
$snomedCode = trim((string) ($complaint['snomed_code'] ?? ''));
if ($snomedCode !== '') {
$code['coding'] = [[
'system' => 'http://snomed.info/sct',
'code' => $snomedCode,
'display' => trim((string) ($complaint['snomed_display'] ?? $text)),
$num = (float) $value;
$loinc = (string) ($observation['loinc'] ?? '');
$display = (string) ($observation['display'] ?? '');
$unit = (string) ($observation['unit'] ?? '');
if ($loinc === '8310-5' || strcasecmp($display, 'Body temperature') === 0 || in_array($unit, ['Cel', 'degF', '[degF]'], true)) {
$num = round($num, 1);
} elseif ($loinc === '39156-5' || strcasecmp($display, 'Body mass index') === 0) {
$num = round($num, 1);
} elseif ($loinc === '29463-7' || strcasecmp($display, 'Body weight') === 0) {
$num = round($num, 2);
} elseif ($loinc === '8302-2' || strcasecmp($display, 'Body height') === 0) {
$num = round($num, 1);
} elseif (in_array($loinc, ['8867-4', '59408-5', '8480-6', '8462-4', '9279-1'], true)) {
$num = (float) round($num);
} else {
$num = round($num, 2);
}
$obsUuid = $this->generateUuid();
$observationRefs[] = ['reference' => 'urn:uuid:' . $obsUuid, 'display' => 'Observation'];
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $obsUuid, 'resource' => [
'resourceType' => 'Observation',
'id' => $obsUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ObservationVitalSigns']],
'status' => 'final',
'category' => [['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'vital-signs',
]]]],
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => $loinc,
'display' => $display,
]],
'text' => $display,
],
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'encounter' => ['reference' => $encounterRef],
'effectiveDateTime' => $issuedAt,
'valueQuantity' => [
'value' => $num,
'unit' => $unit,
'system' => 'http://unitsofmeasure.org',
'code' => (string) ($observation['ucum'] ?? ''),
],
]];
}
$complaintResource = [
'resourceType' => 'Condition',
'id' => $complaintUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']],
'clinicalStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical',
'code' => 'active',
'display' => 'Active',
]]],
'code' => $code,
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'recordedDate' => $issuedAt,
];
// Severity (High/Moderate/Low → SNOMED coded)
$severityText = trim((string) ($complaint['severity'] ?? ''));
if ($severityText !== '') {
$severityMap = [
'high' => ['24484000', 'Severe'],
'severe' => ['24484000', 'Severe'],
'moderate' => ['6736007', 'Moderate'],
'mild' => ['255604002', 'Mild'],
'low' => ['255604002', 'Mild'],
];
$severityKey = strtolower($severityText);
[$sevCode, $sevDisplay] = $severityMap[$severityKey] ?? ['', $severityText];
$severityCoding = ['text' => ucfirst($severityText)];
if ($sevCode !== '') {
$severityCoding['coding'] = [[
'system' => 'http://snomed.info/sct',
'code' => $sevCode,
'display' => $sevDisplay,
]];
foreach ($allergies as $index => $allergy) {
$codeText = trim((string) ($allergy['code_text'] ?? ''));
if ($codeText === '') {
continue;
}
$complaintResource['severity'] = $severityCoding;
$allergyUuid = $this->generateUuid();
$allergyRefs[] = ['reference' => 'urn:uuid:' . $allergyUuid, 'display' => 'AllergyIntolerance'];
$allergyResource = [
'resourceType' => 'AllergyIntolerance',
'id' => $allergyUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/AllergyIntolerance']],
'clinicalStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
'code' => (string) ($allergy['clinical_status'] ?? 'active'),
]]],
'verificationStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification',
'code' => (string) ($allergy['verification_status'] ?? 'confirmed'),
]]],
'code' => ['text' => $codeText],
'patient' => ['reference' => $patientRef, 'display' => 'Patient'],
'recordedDate' => $issuedAt,
];
$reaction = trim((string) ($allergy['reaction_text'] ?? ''));
if ($reaction !== '') {
$allergyResource['reaction'] = [['description' => $reaction]];
}
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $allergyUuid, 'resource' => $allergyResource];
}
// Duration → note
$durationText = trim((string) ($complaint['duration'] ?? ''));
$frequencyText = trim((string) ($complaint['frequency'] ?? ''));
$noteParts = array_filter([$durationText, $frequencyText]);
if (! empty($noteParts)) {
$complaintResource['note'] = [['text' => implode(' | ', $noteParts)]];
}
$resourceEntries[] = ['fullUrl' => $complaintRef, 'resource' => $complaintResource];
}
// ── Observations (Vitals / Physical Examination) ──────────────────────
foreach ($observations as $index => $observation) {
$value = $observation['value'] ?? null;
if (! is_numeric($value)) {
continue;
}
$num = (float) $value;
$loinc = (string) ($observation['loinc'] ?? '');
$display = (string) ($observation['display'] ?? '');
$unit = (string) ($observation['unit'] ?? '');
if ($loinc === '8310-5' || strcasecmp($display, 'Body temperature') === 0 || in_array($unit, ['Cel', 'degF', '[degF]'], true)) {
$num = round($num, 1);
} elseif ($loinc === '39156-5' || strcasecmp($display, 'Body mass index') === 0) {
$num = round($num, 1);
} elseif ($loinc === '29463-7' || strcasecmp($display, 'Body weight') === 0) {
$num = round($num, 2);
} elseif ($loinc === '8302-2' || strcasecmp($display, 'Body height') === 0) {
$num = round($num, 1);
} elseif (in_array($loinc, ['8867-4', '59408-5', '8480-6', '8462-4', '9279-1'], true)) {
$num = (float) round($num);
} else {
$num = round($num, 2);
}
$obsUuid = $this->generateUuid();
$observationRefs[] = ['reference' => 'urn:uuid:' . $obsUuid, 'display' => 'Observation'];
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $obsUuid, 'resource' => [
'resourceType' => 'Observation',
'id' => $obsUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ObservationVitalSigns']],
'status' => 'final',
'category' => [['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/observation-category',
'code' => 'vital-signs',
]]]],
'code' => [
'coding' => [[
'system' => 'http://loinc.org',
'code' => $loinc,
'display' => $display,
]],
'text' => $display,
],
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'encounter' => ['reference' => $encounterRef],
'effectiveDateTime' => $issuedAt,
'valueQuantity' => [
'value' => $num,
'unit' => $unit,
'system' => 'http://unitsofmeasure.org',
'code' => (string) ($observation['ucum'] ?? ''),
],
]];
}
foreach ($allergies as $index => $allergy) {
$codeText = trim((string) ($allergy['code_text'] ?? ''));
if ($codeText === '') {
continue;
}
$allergyUuid = $this->generateUuid();
$allergyRefs[] = ['reference' => 'urn:uuid:' . $allergyUuid, 'display' => 'AllergyIntolerance'];
$allergyResource = [
'resourceType' => 'AllergyIntolerance',
'id' => $allergyUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/AllergyIntolerance']],
'clinicalStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
'code' => (string) ($allergy['clinical_status'] ?? 'active'),
]]],
'verificationStatus' => ['coding' => [[
'system' => 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification',
'code' => (string) ($allergy['verification_status'] ?? 'confirmed'),
]]],
'code' => ['text' => $codeText],
'patient' => ['reference' => $patientRef, 'display' => 'Patient'],
'recordedDate' => $issuedAt,
];
$reaction = trim((string) ($allergy['reaction_text'] ?? ''));
if ($reaction !== '') {
$allergyResource['reaction'] = [['description' => $reaction]];
}
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $allergyUuid, 'resource' => $allergyResource];
}
// ── Medications ───────────────────────────────────────────────────────
@ -480,75 +487,77 @@ class FhirR4Builder
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $medUuid, 'resource' => $medRes];
}
// ── ServiceRequests (Investigation Advice) ────────────────────────────
foreach ($serviceRequests as $index => $serviceRequest) {
$codeText = trim((string) ($serviceRequest['code_text'] ?? ''));
if ($codeText === '') {
continue;
}
$svcUuid = $this->generateUuid();
$serviceRequestRefs[] = ['reference' => 'urn:uuid:' . $svcUuid, 'display' => 'ServiceRequest'];
$svcRes = [
'resourceType' => 'ServiceRequest',
'id' => $svcUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ServiceRequest']],
'status' => (string) ($serviceRequest['status'] ?? 'active'),
'intent' => (string) ($serviceRequest['intent'] ?? 'order'),
'code' => ['text' => $codeText],
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'encounter' => ['reference' => $encounterRef],
'authoredOn' => $issuedAt,
];
if ($practitionerRef !== '') {
$svcRes['requester'] = ['reference' => $practitionerRef];
}
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $svcUuid, 'resource' => $svcRes];
}
// ── Appointments (Follow Up) ──────────────────────────────────────────
foreach ($appointments as $index => $appointment) {
$description = trim((string) ($appointment['description'] ?? ''));
if ($description === '') {
continue;
}
$apptUuid = $this->generateUuid();
$appointmentRefs[] = ['reference' => 'urn:uuid:' . $apptUuid, 'display' => 'Appointment'];
$apptStartIso = $issuedAt;
if (preg_match('/(\d{2}-\d{2}-\d{4})/', $description, $m) === 1) {
$parsed = \DateTimeImmutable::createFromFormat('d-m-Y', $m[1]);
if ($parsed !== false) {
$apptStartIso = $parsed->setTime(10, 0, 0)->format(DATE_ATOM);
if ($isOpConsult) {
// ── ServiceRequests (Investigation Advice) ────────────────────────────
foreach ($serviceRequests as $index => $serviceRequest) {
$codeText = trim((string) ($serviceRequest['code_text'] ?? ''));
if ($codeText === '') {
continue;
}
} elseif (preg_match('/(\d{4}-\d{2}-\d{2})/', $description, $m) === 1) {
$parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $m[1]);
if ($parsed !== false) {
$apptStartIso = $parsed->setTime(10, 0, 0)->format(DATE_ATOM);
}
}
$apptRes = [
'resourceType' => 'Appointment',
'id' => $apptUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Appointment']],
'status' => (string) ($appointment['status'] ?? 'proposed'),
'description' => $description,
'start' => (string) ($appointment['start'] ?? $apptStartIso),
'end' => (string) ($appointment['end'] ?? $apptStartIso),
'participant' => [[
'actor' => ['reference' => $patientRef, 'display' => 'Patient'],
'status' => 'accepted',
]],
];
if ($practitionerRef !== '') {
$apptRes['participant'][] = [
'actor' => ['reference' => $practitionerRef, 'display' => $practRawName !== '' ? $practRawName : 'Practitioner'],
'status' => 'accepted',
$svcUuid = $this->generateUuid();
$serviceRequestRefs[] = ['reference' => 'urn:uuid:' . $svcUuid, 'display' => 'ServiceRequest'];
$svcRes = [
'resourceType' => 'ServiceRequest',
'id' => $svcUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/ServiceRequest']],
'status' => (string) ($serviceRequest['status'] ?? 'active'),
'intent' => (string) ($serviceRequest['intent'] ?? 'order'),
'code' => ['text' => $codeText],
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
'encounter' => ['reference' => $encounterRef],
'authoredOn' => $issuedAt,
];
if ($practitionerRef !== '') {
$svcRes['requester'] = ['reference' => $practitionerRef];
}
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $svcUuid, 'resource' => $svcRes];
}
// ── Appointments (Follow Up) ──────────────────────────────────────────
foreach ($appointments as $index => $appointment) {
$description = trim((string) ($appointment['description'] ?? ''));
if ($description === '') {
continue;
}
$apptUuid = $this->generateUuid();
$appointmentRefs[] = ['reference' => 'urn:uuid:' . $apptUuid, 'display' => 'Appointment'];
$apptStartIso = $issuedAt;
if (preg_match('/(\d{2}-\d{2}-\d{4})/', $description, $m) === 1) {
$parsed = \DateTimeImmutable::createFromFormat('d-m-Y', $m[1]);
if ($parsed !== false) {
$apptStartIso = $parsed->setTime(10, 0, 0)->format(DATE_ATOM);
}
} elseif (preg_match('/(\d{4}-\d{2}-\d{2})/', $description, $m) === 1) {
$parsed = \DateTimeImmutable::createFromFormat('Y-m-d', $m[1]);
if ($parsed !== false) {
$apptStartIso = $parsed->setTime(10, 0, 0)->format(DATE_ATOM);
}
}
$apptRes = [
'resourceType' => 'Appointment',
'id' => $apptUuid,
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Appointment']],
'status' => (string) ($appointment['status'] ?? 'proposed'),
'description' => $description,
'start' => (string) ($appointment['start'] ?? $apptStartIso),
'end' => (string) ($appointment['end'] ?? $apptStartIso),
'participant' => [[
'actor' => ['reference' => $patientRef, 'display' => 'Patient'],
'status' => 'accepted',
]],
];
if ($practitionerRef !== '') {
$apptRes['participant'][] = [
'actor' => ['reference' => $practitionerRef, 'display' => $practRawName !== '' ? $practRawName : 'Practitioner'],
'status' => 'accepted',
];
}
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $apptUuid, 'resource' => $apptRes];
}
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $apptUuid, 'resource' => $apptRes];
}
// ── OPD scan/upload attachments (DocumentReference + Binary) ─────────
@ -607,51 +616,53 @@ class FhirR4Builder
// ── Composition sections (ABDM SNOMED section codes) ─────────────────
$compositionSections = [];
if (! empty($complaintRefs)) {
$compositionSections[] = [
'title' => 'Chief complaints',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '422843007',
'display' => 'Chief complaint section',
]]],
'entry' => $complaintRefs,
];
}
if (! empty($allergyRefs)) {
$compositionSections[] = [
'title' => 'Allergies',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '722446000',
'display' => 'Allergy record',
]]],
'entry' => $allergyRefs,
];
}
if (! empty($observationRefs)) {
$compositionSections[] = [
'title' => 'Physical Examination',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '425044008',
'display' => 'Physical exam section',
]]],
'entry' => $observationRefs,
];
}
if (! empty($encounterDiagnosisRefs)) {
// Diagnoses section — conditions referenced from Encounter.diagnosis
$diagSectionEntries = array_map(static fn ($d) => $d['condition'], $encounterDiagnosisRefs);
$compositionSections[] = [
'title' => 'Problems and Diagnoses',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '439401001',
'display' => 'Diagnosis',
]]],
'entry' => $diagSectionEntries,
];
if ($isOpConsult) {
if (! empty($complaintRefs)) {
$compositionSections[] = [
'title' => 'Chief complaints',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '422843007',
'display' => 'Chief complaint section',
]]],
'entry' => $complaintRefs,
];
}
if (! empty($allergyRefs)) {
$compositionSections[] = [
'title' => 'Allergies',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '722446000',
'display' => 'Allergy record',
]]],
'entry' => $allergyRefs,
];
}
if (! empty($observationRefs)) {
$compositionSections[] = [
'title' => 'Physical Examination',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '425044008',
'display' => 'Physical exam section',
]]],
'entry' => $observationRefs,
];
}
if (! empty($encounterDiagnosisRefs)) {
// Diagnoses section — conditions referenced from Encounter.diagnosis
$diagSectionEntries = array_map(static fn ($d) => $d['condition'], $encounterDiagnosisRefs);
$compositionSections[] = [
'title' => 'Problems and Diagnoses',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '439401001',
'display' => 'Diagnosis',
]]],
'entry' => $diagSectionEntries,
];
}
}
if (! empty($medicationRefs)) {
$compositionSections[] = [
@ -664,44 +675,43 @@ class FhirR4Builder
'entry' => $medicationRefs,
];
}
if (! empty($serviceRequestRefs)) {
$compositionSections[] = [
'title' => 'Investigation Advice',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '721963009',
'display' => 'Order document',
]]],
'entry' => $serviceRequestRefs,
];
}
if (! empty($appointmentRefs)) {
$compositionSections[] = [
'title' => 'Follow Up',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '736271009',
'display' => 'Outpatient care plan',
]]],
'entry' => $appointmentRefs,
];
if ($isOpConsult) {
if (! empty($serviceRequestRefs)) {
$compositionSections[] = [
'title' => 'Investigation Advice',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '721963009',
'display' => 'Order document',
]]],
'entry' => $serviceRequestRefs,
];
}
if (! empty($appointmentRefs)) {
$compositionSections[] = [
'title' => 'Follow Up',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '736271009',
'display' => 'Outpatient care plan',
]]],
'entry' => $appointmentRefs,
];
}
}
if (! empty($documentRefs)) {
$compositionSections[] = [
'title' => 'Document Reference',
'code' => ['coding' => [[
'system' => 'http://snomed.info/sct',
'code' => '371530004',
'display' => 'Clinical consultation report',
'code' => $isOpConsult ? '371530004' : '440545006',
'display' => $isOpConsult ? 'Clinical consultation report' : 'Prescription record',
]]],
'entry' => $documentRefs,
];
}
// ── Composition (first entry per ABDM spec) ───────────────────────────
$requestedBundleType = trim((string) ($context['bundle_type'] ?? $context['record_type'] ?? 'PrescriptionRecord'));
$isOpConsult = in_array($requestedBundleType, ['OPConsultRecord', 'OPConsultation'], true);
$compositionProfile = $isOpConsult
? 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/OPConsultRecord'
: 'https://nrces.in/ndhm/fhir/r4/StructureDefinition/PrescriptionRecord';
@ -1043,7 +1053,7 @@ class FhirR4Builder
]],
]];
if ($isImaging) {
if ($isImaging && str_starts_with(strtolower($pdfContentType), 'image/')) {
$mediaUuid = $this->generateUuid();
$mediaRef = 'urn:uuid:' . $mediaUuid;
$resourceEntries[] = ['fullUrl' => $mediaRef, 'resource' => [
@ -1114,12 +1124,13 @@ class FhirR4Builder
]];
}
if ($attachment !== null && ! empty($attachment['data_base64'])) {
$pdfPresentedFormUrl = $pdfDocRefRef !== null ? $pdfDocRefRef : null;
$reportRes['presentedForm'] = [[
$pdfPresentedFormUrl = $pdfBinaryRef ?? null;
$reportRes['presentedForm'] = [array_filter([
'contentType' => trim((string) ($attachment['content_type'] ?? 'application/pdf')) ?: 'application/pdf',
'title' => trim((string) ($attachment['title'] ?? 'Lab Report PDF')) ?: 'Lab Report PDF',
'url' => $pdfPresentedFormUrl,
]];
'title' => trim((string) ($attachment['title'] ?? 'Lab Report PDF')) ?: 'Lab Report PDF',
'url' => $pdfPresentedFormUrl,
'data' => $pdfData !== '' ? $pdfData : null,
], static fn ($v): bool => $v !== null)];
}
$resourceEntries[] = ['fullUrl' => $reportRef, 'resource' => $reportRes];

View File

@ -682,7 +682,17 @@
}).catch(function() {});
} else if (ctxTaskType === 'opd_prescription_publish') {
var pushData = new URLSearchParams();
pushData.append('opd_id', ctxEntityId);
var targetOpdId = ctxEntityId;
if (!targetOpdId || targetOpdId === '0') {
var refToParse = firstRef || (window.currentHipTaskContext ? window.currentHipTaskContext.careContext : '');
if (refToParse) {
var mOpd = refToParse.match(/OPD-(?:\d+)-S(\d+)/i) || refToParse.match(/OPD-(\d+)/i);
if (mOpd && mOpd[1]) {
targetOpdId = mOpd[1];
}
}
}
pushData.append('opd_id', targetOpdId || '0');
pushData.append('patient_id', ctxPatientId);
pushData.append('abha_id', abhaAddress);
pushData.append('push_to_gateway', '1');