feat(abdm): add digital share records
Generate standardized Digital Share PDFs for invoice and diagnostic records, strengthen FHIR document validation and sync handling, and keep task-board submissions AJAX-only.
This commit is contained in:
parent
625987955e
commit
2e8304636b
|
|
@ -4,6 +4,23 @@ Date: 2026-02-08
|
|||
|
||||
---
|
||||
|
||||
## ABDM Immunization Duplicate Control — 2026-08-15
|
||||
|
||||
- Made ImmunizationRecord care-context references stable per source record (`IMM-{record_id}`), while preserving previously stored references and deterministic aggregate fallbacks.
|
||||
- Removed the timestamp-based Immunization fallback from `FhirR4Builder` so retries cannot create a new gateway identity.
|
||||
- Normalized direct and outbox push responses: HTTP 201 succeeds; HTTP 409 succeeds only for `DUPLICATE_RECORD`; HTTP 422 remains a non-retryable validation failure with field details.
|
||||
- Preserved returned gateway `record_id`, `queue_id`, and `first_pushed_at`, persisting each when the deployed schema provides a matching column.
|
||||
- Added focused regression tests for stable Immunization references, accepted duplicate responses, generic conflicts, HTTP 201, and detailed HTTP 422 errors.
|
||||
|
||||
## ABDM M2 Link Status + Immunization Task Board — 2026-08-15
|
||||
|
||||
- Fixed M2 discovery/fetch to use canonical `health_records` care-context references and HI types first, with `patient_records` retained as a backward-compatible fallback.
|
||||
- Fixed link callbacks to accept snake_case and camelCase request/care-context identifiers, persist request IDs as transaction metadata, update both `record_links` and `health_records`, and complete source-keyed work tasks.
|
||||
- Made link synchronization idempotent: linked records cannot be downgraded, stale pending callbacks cannot erase failures, and duplicate callbacks preserve the confirmed link timestamp.
|
||||
- Added the `Immunization Record` task-board tab for completed vaccinations and local M2 registration using the exact `ImmunizationRecord` HI type.
|
||||
- Task-board Immunization registration forces `m2_only=1`; it stores the FHIR record for M2 discovery/linking and does not call a Push API.
|
||||
- Added focused tests for callback identifier parsing and status transition behavior.
|
||||
|
||||
## Storestock Module — 2026-04-19
|
||||
|
||||
Ported Hospital Stock Management (Storestock) module from CI3 to CI4.
|
||||
|
|
|
|||
|
|
@ -1955,20 +1955,16 @@ class AbdmGateway extends BaseController
|
|||
'period_start' => $reportedAt,
|
||||
];
|
||||
|
||||
$pdfAttachment = $this->loadLatestLabPdfAttachment(
|
||||
(int) ($labReq->charge_id ?? 0),
|
||||
(int) ($labReq->lab_type ?? 0),
|
||||
$labReqId,
|
||||
$testTitle !== '' ? ($testTitle . ' PDF Report') : 'Lab Report PDF'
|
||||
$pdfAttachment = $this->buildDiagnosticDigitalSharePdf(
|
||||
$patientRow,
|
||||
$abhaIdentity,
|
||||
$diagnosticReport,
|
||||
$observations,
|
||||
$practitioner,
|
||||
$hospitalProfile,
|
||||
$labReqId
|
||||
);
|
||||
|
||||
if ($pdfAttachment === null) {
|
||||
return $this->response->setStatusCode(422)->setJSON([
|
||||
'ok' => 0,
|
||||
'error' => 'PDF attachment missing for this lab request. Compile and store the report first, then retry ABDM submit.',
|
||||
]);
|
||||
}
|
||||
|
||||
$bundle = $fhir->buildLabReportBundle($patient, $diagnosticReport, $observations, $practitioner, $organization, $encounter, $pdfAttachment);
|
||||
$bundleJson = (string) json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
|
|
@ -2180,20 +2176,17 @@ class AbdmGateway extends BaseController
|
|||
];
|
||||
|
||||
$fhir = new FhirR4Builder();
|
||||
$pdfAttachment = $this->loadLatestLabPdfAttachment(
|
||||
(int) ($labReq->charge_id ?? 0),
|
||||
(int) ($labReq->lab_type ?? 0),
|
||||
$labReqId,
|
||||
$testTitle !== '' ? ($testTitle . ' PDF Report') : 'Lab Report PDF'
|
||||
$abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId);
|
||||
$pdfAttachment = $this->buildDiagnosticDigitalSharePdf(
|
||||
$patientRow,
|
||||
$abhaIdentity,
|
||||
$diagnosticReport,
|
||||
$observations,
|
||||
$practitioner,
|
||||
$hospitalProfile,
|
||||
$labReqId
|
||||
);
|
||||
|
||||
if ($pdfAttachment === null) {
|
||||
return $this->response->setStatusCode(422)->setJSON([
|
||||
'status' => 'error',
|
||||
'message' => 'PDF attachment missing for this lab request. Compile and store the report first, then retry preview.',
|
||||
]);
|
||||
}
|
||||
|
||||
$bundle = $fhir->buildLabReportBundle($patient, $diagnosticReport, $observations, $practitioner, $organization, $encounter, $pdfAttachment);
|
||||
|
||||
return $this->response->setJSON([
|
||||
|
|
@ -4260,6 +4253,186 @@ class AbdmGateway extends BaseController
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $patientRow
|
||||
* @param array{abha_id:string,abha_address:string} $abhaIdentity
|
||||
* @param array<string,mixed> $diagnosticReport
|
||||
* @param array<int,array<string,mixed>> $observations
|
||||
* @param array<string,mixed>|null $practitioner
|
||||
* @param array<string,mixed> $hospitalProfile
|
||||
*/
|
||||
private function buildDiagnosticDigitalSharePdf(
|
||||
array $patientRow,
|
||||
array $abhaIdentity,
|
||||
array $diagnosticReport,
|
||||
array $observations,
|
||||
?array $practitioner,
|
||||
array $hospitalProfile,
|
||||
int $labReqId
|
||||
): ?array {
|
||||
$escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$contentHtml = trim((string) ($diagnosticReport['report_html'] ?? ''));
|
||||
if ($contentHtml === '' && $observations !== []) {
|
||||
$rows = '';
|
||||
foreach ($observations as $observation) {
|
||||
$value = trim((string) ($observation['value'] ?? ''));
|
||||
$unit = trim((string) ($observation['unit'] ?? ''));
|
||||
$range = trim(implode(' - ', array_filter([
|
||||
trim((string) ($observation['ref_low'] ?? '')),
|
||||
trim((string) ($observation['ref_high'] ?? '')),
|
||||
], static fn (string $part): bool => $part !== '')));
|
||||
$rows .= '<tr><td>' . $escape((string) ($observation['test_name'] ?? 'Test')) . '</td>'
|
||||
. '<td>' . $escape(trim($value . ' ' . $unit)) . '</td>'
|
||||
. '<td>' . $escape($range) . '</td>'
|
||||
. '<td>' . $escape((string) ($observation['interpretation'] ?? '')) . '</td></tr>';
|
||||
}
|
||||
$contentHtml = '<table class="share-content-table"><thead><tr><th>Test</th><th>Result</th><th>Reference</th>'
|
||||
. '<th>Flag</th></tr></thead><tbody>' . $rows . '</tbody></table>';
|
||||
}
|
||||
if ($contentHtml === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$conclusion = trim((string) ($diagnosticReport['conclusion'] ?? ''));
|
||||
if ($conclusion !== '') {
|
||||
$contentHtml .= '<div class="share-conclusion"><strong>Conclusion:</strong> ' . $escape($conclusion) . '</div>';
|
||||
}
|
||||
$title = trim((string) ($diagnosticReport['title'] ?? 'Diagnostic Report')) ?: 'Diagnostic Report';
|
||||
$abha = trim(implode(' / ', array_filter([
|
||||
$abhaIdentity['abha_id'],
|
||||
$abhaIdentity['abha_address'],
|
||||
], static fn (string $value): bool => $value !== '')));
|
||||
|
||||
return $this->renderDigitalSharePdf($contentHtml, 'Digital-Share-LAB-' . $labReqId, [
|
||||
'document_title' => $title,
|
||||
'patient_name' => $this->patientDisplayName($patientRow),
|
||||
'uhid' => trim((string) ($patientRow['p_code'] ?? '')),
|
||||
'abha' => $abha,
|
||||
'record_label' => 'Diagnostic Report',
|
||||
'record_code' => 'LAB-' . $labReqId,
|
||||
'reported_at' => (string) ($diagnosticReport['reported_at'] ?? ''),
|
||||
'practitioner' => trim((string) ($practitioner['name'] ?? '')),
|
||||
'facility_name' => trim((string) ($hospitalProfile['name'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stable PDF layout used for digital/PHR sharing.
|
||||
*
|
||||
* @param array<string,string> $metadata
|
||||
* @return array{content_type:string,data_base64:string,title:string,size:int,hash:string}|null
|
||||
*/
|
||||
private function renderDigitalSharePdf(string $contentHtml, string $fileStem, array $metadata): ?array
|
||||
{
|
||||
$contentHtml = trim($contentHtml);
|
||||
if ($contentHtml === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$hospital = $this->getHospitalProfileForFhir();
|
||||
$hospitalName = trim((string) ($metadata['facility_name'] ?? $hospital['name'] ?? '')) ?: 'Healthcare Facility';
|
||||
$hospitalAddress = trim((string) ($hospital['address'] ?? ''));
|
||||
$hospitalContact = trim(implode(' | ', array_filter([
|
||||
trim((string) ($hospital['phone'] ?? '')),
|
||||
trim((string) ($hospital['email'] ?? '')),
|
||||
], static fn (string $value): bool => $value !== '')));
|
||||
$logoHtml = '';
|
||||
$logoName = trim((string) ($hospital['logo'] ?? ''));
|
||||
foreach (array_filter([
|
||||
$logoName !== '' ? FCPATH . 'assets/images/' . ltrim($logoName, '/\\') : '',
|
||||
$logoName !== '' ? FCPATH . 'assets/img/' . ltrim($logoName, '/\\') : '',
|
||||
FCPATH . 'assets/img/logo.png',
|
||||
]) as $logoPath) {
|
||||
if (is_file($logoPath)) {
|
||||
$logoHtml = '<img src="' . $escape(str_replace('\\', '/', $logoPath)) . '" style="height:52px;max-width:110px">';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$patientName = trim((string) ($metadata['patient_name'] ?? '')) ?: 'Patient';
|
||||
$uhid = trim((string) ($metadata['uhid'] ?? ''));
|
||||
$abha = trim((string) ($metadata['abha'] ?? ''));
|
||||
$recordCode = trim((string) ($metadata['record_code'] ?? ''));
|
||||
$footerIdentity = trim(implode(' | ', array_filter([$patientName, $uhid !== '' ? 'UHID: ' . $uhid : ''])));
|
||||
$identityFields = [
|
||||
['Patient', $patientName],
|
||||
['UHID', $uhid],
|
||||
['ABHA', $abha],
|
||||
[trim((string) ($metadata['record_label'] ?? 'Record')) ?: 'Record', $recordCode],
|
||||
['Reported / Issued', trim((string) ($metadata['reported_at'] ?? date('Y-m-d H:i:s')))],
|
||||
['Practitioner', trim((string) ($metadata['practitioner'] ?? ''))],
|
||||
];
|
||||
$identityFields = array_values(array_filter(
|
||||
$identityFields,
|
||||
static fn (array $field): bool => trim((string) ($field[1] ?? '')) !== ''
|
||||
));
|
||||
$identityRows = '';
|
||||
for ($index = 0, $count = count($identityFields); $index < $count; $index += 2) {
|
||||
$identityRows .= '<tr>';
|
||||
for ($column = 0; $column < 2; $column++) {
|
||||
$field = $identityFields[$index + $column] ?? null;
|
||||
if ($field === null) {
|
||||
$identityRows .= '<td></td>';
|
||||
continue;
|
||||
}
|
||||
$identityRows .= '<td><span class="field-label">' . $escape((string) $field[0]) . ':</span> '
|
||||
. '<strong>' . $escape((string) $field[1]) . '</strong></td>';
|
||||
}
|
||||
$identityRows .= '</tr>';
|
||||
}
|
||||
|
||||
$css = 'body{font-family:DejaVu Sans,sans-serif;color:#17212b;font-size:10.5px}'
|
||||
. '.brand{width:100%;border-collapse:collapse;border-bottom:2px solid #176b87;margin-bottom:10px}.brand td{vertical-align:middle;padding:0 0 9px 0}'
|
||||
. '.brand-logo{width:125px}.facility{font-size:19px;font-weight:bold;color:#123b4a}.facility-meta{font-size:9px;color:#52606d;margin-top:2px}'
|
||||
. '.document-title{font-size:14px;font-weight:bold;text-align:right;color:#176b87}.share-identity{width:100%;border-collapse:collapse;margin-bottom:14px}'
|
||||
. '.share-identity td{width:50%;border:1px solid #cad7de;background:#f5f9fb;padding:6px;vertical-align:top}.field-label{color:#52606d}'
|
||||
. '.share-content-table{width:100%;border-collapse:collapse}'
|
||||
. '.share-content-table th,.share-content-table td{border:1px solid #cbd5df;padding:6px}.share-content-table th{background:#eaf2f5;text-align:left}'
|
||||
. '.items{width:100%;border-collapse:collapse}.items th,.items td{border:1px solid #cbd5df;padding:7px}.items th{background:#eef2f6;text-align:left}'
|
||||
. '.num{text-align:right}.totals{margin-top:12px;text-align:right;font-size:12px;line-height:1.6}'
|
||||
. '.share-conclusion{margin-top:14px;padding:9px;border-left:3px solid #176b87;background:#f5f9fb}table{border-collapse:collapse;max-width:100%}img{max-width:100%}'
|
||||
. 'h1,h2,h3{margin:8px 0}p{margin:5px 0}';
|
||||
$bodyHtml = '<table class="brand"><tr><td class="brand-logo">' . $logoHtml . '</td><td><div class="facility">' . $escape($hospitalName) . '</div>'
|
||||
. '<div class="facility-meta">' . $escape($hospitalAddress) . '</div><div class="facility-meta">' . $escape($hospitalContact) . '</div></td>'
|
||||
. '<td class="document-title">' . $escape((string) ($metadata['document_title'] ?? 'Digital Health Record')) . '</td></tr></table>'
|
||||
. '<table class="share-identity">' . $identityRows . '</table>' . $contentHtml;
|
||||
|
||||
$mpdfTempDir = WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'mpdf';
|
||||
if (! is_dir($mpdfTempDir)) {
|
||||
@mkdir($mpdfTempDir, 0755, true);
|
||||
}
|
||||
$mpdf = new Mpdf([
|
||||
'format' => 'A4',
|
||||
'margin_top' => 10,
|
||||
'margin_bottom' => 16,
|
||||
'margin_left' => 10,
|
||||
'margin_right' => 10,
|
||||
'tempDir' => $mpdfTempDir,
|
||||
'default_font' => 'freeserif',
|
||||
]);
|
||||
$mpdf->SetHTMLFooter('<div style="border-top:1px solid #9fb3bd;padding-top:5px;font-size:8px;color:#52606d">'
|
||||
. $escape($footerIdentity) . '<span style="float:right">Page {PAGENO} of {nbpg}</span></div>');
|
||||
$mpdf->WriteHTML($css, HTMLParserMode::HEADER_CSS);
|
||||
$mpdf->WriteHTML($bodyHtml, HTMLParserMode::HTML_BODY);
|
||||
$bytes = $mpdf->Output($fileStem . '.pdf', 'S');
|
||||
if (! is_string($bytes) || ! str_starts_with($bytes, '%PDF-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'content_type' => 'application/pdf',
|
||||
'data_base64' => base64_encode($bytes),
|
||||
'title' => $fileStem . '.pdf',
|
||||
'size' => strlen($bytes),
|
||||
'hash' => base64_encode(sha1($bytes, true)),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveStoredFileAbsolutePath(string $fullPath): string
|
||||
{
|
||||
$normalized = str_replace('\\', '/', trim($fullPath));
|
||||
|
|
@ -5444,23 +5617,44 @@ class AbdmGateway extends BaseController
|
|||
/**
|
||||
* Query hospital_setting for FHIR-required hospital profile fields.
|
||||
*
|
||||
* @return array{name: string, hfr_id: string}
|
||||
* @return array{name:string,hfr_id:string,address:string,phone:string,email:string,logo:string}
|
||||
*/
|
||||
private function getHospitalProfileForFhir(): array
|
||||
{
|
||||
try {
|
||||
$rows = $this->db->table('hospital_setting')
|
||||
->select('s_name, s_value')
|
||||
->whereIn('s_name', ['ABDM_HMS_NAME', 'ABDM_HFR_ID', 'H_Name'])
|
||||
->whereIn('s_name', [
|
||||
'ABDM_HMS_NAME', 'ABDM_HFR_ID', 'H_Name', 'H_address_1', 'H_address_2',
|
||||
'H_phone_No', 'H_Email', 'H_logo',
|
||||
])
|
||||
->get()->getResultArray();
|
||||
$map = array_column($rows, 's_value', 's_name');
|
||||
$name = trim((string) ($map['ABDM_HMS_NAME'] ?? $map['H_Name'] ?? ''));
|
||||
$hfrId = trim((string) ($map['ABDM_HFR_ID'] ?? ''));
|
||||
$address = trim(implode(', ', array_filter([
|
||||
trim((string) ($map['H_address_1'] ?? '')),
|
||||
trim((string) ($map['H_address_2'] ?? '')),
|
||||
], static fn (string $value): bool => $value !== '')));
|
||||
$phone = trim((string) ($map['H_phone_No'] ?? ''));
|
||||
$email = trim((string) ($map['H_Email'] ?? ''));
|
||||
$logo = trim((string) ($map['H_logo'] ?? ''));
|
||||
} catch (\Throwable $e) {
|
||||
$name = '';
|
||||
$hfrId = '';
|
||||
$address = '';
|
||||
$phone = '';
|
||||
$email = '';
|
||||
$logo = '';
|
||||
}
|
||||
return ['name' => $name, 'hfr_id' => $hfrId];
|
||||
return [
|
||||
'name' => $name,
|
||||
'hfr_id' => $hfrId,
|
||||
'address' => $address,
|
||||
'phone' => $phone,
|
||||
'email' => $email,
|
||||
'logo' => $logo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -6476,11 +6670,29 @@ class AbdmGateway extends BaseController
|
|||
if (! empty($patientRow['dob'])) {
|
||||
$patient['birthDate'] = date('Y-m-d', strtotime((string) $patientRow['dob']));
|
||||
}
|
||||
if ($abhaId !== '') {
|
||||
$patient['identifier'] = [[
|
||||
'system' => str_contains($abhaId, '@') ? 'https://healthid.ndhm.gov.in/abha-address' : 'https://healthid.ndhm.gov.in',
|
||||
'value' => $abhaId,
|
||||
]];
|
||||
$patient['identifier'] = [];
|
||||
$uhid = trim((string) ($patientRow['p_code'] ?? ''));
|
||||
if ($uhid !== '') {
|
||||
$patient['identifier'][] = [
|
||||
'system' => 'https://hms.local/fhir/uhid',
|
||||
'value' => $uhid,
|
||||
];
|
||||
}
|
||||
$abhaIdentity = $this->resolvePatientAbhaIdentity($patientId, $abhaId);
|
||||
if ($abhaIdentity['abha_id'] !== '') {
|
||||
$patient['identifier'][] = [
|
||||
'system' => 'https://healthid.ndhm.gov.in',
|
||||
'value' => $abhaIdentity['abha_id'],
|
||||
];
|
||||
}
|
||||
if ($abhaIdentity['abha_address'] !== '') {
|
||||
$patient['identifier'][] = [
|
||||
'system' => 'https://healthid.ndhm.gov.in/abha-address',
|
||||
'value' => $abhaIdentity['abha_address'],
|
||||
];
|
||||
}
|
||||
if ($patient['identifier'] === []) {
|
||||
unset($patient['identifier']);
|
||||
}
|
||||
return $patient;
|
||||
}
|
||||
|
|
@ -6791,33 +7003,43 @@ class AbdmGateway extends BaseController
|
|||
if ($invoiceRows === '') {
|
||||
$invoiceRows = '<tr><td colspan="5">No line items</td></tr>';
|
||||
}
|
||||
$invoicePdfHtml = '<style>'
|
||||
. 'body{font-family:DejaVu Sans,sans-serif;color:#17212b;font-size:11px}'
|
||||
. 'h1{font-size:20px;margin:0 0 3px}.muted{color:#52606d}.meta{width:100%;margin:18px 0;border-collapse:collapse}'
|
||||
. '.meta td{width:50%;vertical-align:top;border:1px solid #d7dee5;padding:9px}.label{font-weight:bold}'
|
||||
. 'table.items{width:100%;border-collapse:collapse}.items th,.items td{border:1px solid #cbd5df;padding:7px}'
|
||||
. '.items th{background:#eef2f6;text-align:left}.num{text-align:right}.totals{margin-top:12px;text-align:right;font-size:12px}'
|
||||
. '</style><h1>' . $escape($hospitalName) . '</h1><div class="muted">Invoice ' . $escape($invoiceNumber) . '</div>'
|
||||
. '<table class="meta"><tr><td><span class="label">Patient:</span> ' . $escape($patientName)
|
||||
. '<br><span class="label">Gender:</span> ' . $escape((string) ($patient['gender'] ?? ''))
|
||||
. '<br><span class="label">Date of Birth:</span> ' . $escape((string) ($patient['birthDate'] ?? ''))
|
||||
. '</td><td><span class="label">Visit:</span> ' . $escape($invoiceTypeDisplay)
|
||||
. '<br><span class="label">Visit Date:</span> ' . $escape($encounterStart)
|
||||
. '<br><span class="label">Practitioner:</span> ' . $escape($practitionerName)
|
||||
. '<br><span class="label">Invoice Date:</span> ' . $escape($invoiceDate) . '</td></tr></table>'
|
||||
. '<table class="items"><thead><tr><th>#</th><th>Item</th><th class="num">Qty</th><th class="num">Rate (INR)</th>'
|
||||
$invoicePdfHtml = '<table class="items"><thead><tr><th>#</th><th>Item</th><th class="num">Qty</th><th class="num">Rate (INR)</th>'
|
||||
. '<th class="num">Amount (INR)</th></tr></thead><tbody>' . $invoiceRows . '</tbody></table>'
|
||||
. '<div class="totals"><div>Gross: INR ' . number_format($grossAmount, 2) . '</div><div><strong>Net: INR '
|
||||
. number_format($netAmount, 2) . '</strong></div></div>';
|
||||
$invoicePdf = $this->renderReportHtmlToPdfAttachment($invoicePdfHtml, $pdfFileStem);
|
||||
if ($invoicePdf === null) {
|
||||
throw new \RuntimeException('Unable to render invoice PDF attachment.');
|
||||
$patientUhid = '';
|
||||
$patientAbhaValues = [];
|
||||
foreach ((array) ($patient['identifier'] ?? []) as $identifier) {
|
||||
$system = (string) ($identifier['system'] ?? '');
|
||||
if (str_contains($system, '/uhid')) {
|
||||
$patientUhid = trim((string) ($identifier['value'] ?? ''));
|
||||
} elseif (str_contains($system, 'healthid.ndhm.gov.in')) {
|
||||
$value = trim((string) ($identifier['value'] ?? ''));
|
||||
if ($value !== '') {
|
||||
$patientAbhaValues[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
$invoicePdfBytes = base64_decode((string) $invoicePdf['data_base64'], true);
|
||||
if (! is_string($invoicePdfBytes) || ! str_starts_with($invoicePdfBytes, '%PDF-')) {
|
||||
throw new \RuntimeException('Generated invoice PDF attachment is invalid.');
|
||||
}
|
||||
$documentReference = [
|
||||
$patientAbha = implode(' / ', array_values(array_unique($patientAbhaValues)));
|
||||
$invoicePdf = $this->renderDigitalSharePdf(
|
||||
$invoicePdfHtml,
|
||||
$pdfFileStem,
|
||||
[
|
||||
'document_title' => 'Invoice ' . $invoiceNumber,
|
||||
'patient_name' => $patientName,
|
||||
'uhid' => $patientUhid,
|
||||
'abha' => $patientAbha,
|
||||
'record_label' => $invoiceTypeDisplay . ' / Invoice',
|
||||
'record_code' => $invoiceNumber,
|
||||
'reported_at' => $invoiceDate,
|
||||
'practitioner' => $practitionerName,
|
||||
]
|
||||
);
|
||||
$documentReference = null;
|
||||
if ($invoicePdf !== null) {
|
||||
$invoicePdfBytes = base64_decode((string) $invoicePdf['data_base64'], true);
|
||||
if (is_string($invoicePdfBytes) && str_starts_with($invoicePdfBytes, '%PDF-')) {
|
||||
$documentReference = [
|
||||
'resourceType' => 'DocumentReference',
|
||||
'id' => $documentReferenceId,
|
||||
'meta' => [
|
||||
|
|
@ -6854,7 +7076,9 @@ class AbdmGateway extends BaseController
|
|||
'encounter' => [['reference' => $encounterRef]],
|
||||
'related' => [['reference' => $invoiceRef, 'display' => 'Invoice ' . $invoiceNumber]],
|
||||
],
|
||||
];
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$encounter = [
|
||||
'resourceType' => 'Encounter',
|
||||
|
|
@ -6956,7 +7180,10 @@ class AbdmGateway extends BaseController
|
|||
]],
|
||||
];
|
||||
|
||||
$resources = [$composition, $patient, $organization, $encounter, $invoiceResource, $documentReference];
|
||||
$resources = [$composition, $patient, $organization, $encounter, $invoiceResource];
|
||||
if ($documentReference !== null) {
|
||||
$resources[] = $documentReference;
|
||||
}
|
||||
if ($practitioner !== null) {
|
||||
$resources[] = $practitioner;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -988,13 +988,30 @@ class Ipd extends BaseController
|
|||
$ipdCode = (string) ($data['ipd_info']->ipd_code ?? ('IPD-' . $ipdId));
|
||||
$fileName = 'IPD_Bill_' . preg_replace('/[^A-Za-z0-9_\-]/', '_', $ipdCode) . '_M' . $mode . '.pdf';
|
||||
|
||||
$pdfBinary = $mpdf->Output($fileName, 'S');
|
||||
$this->cacheAbdmIpdBillPdf($ipdId, $pdfBinary);
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'application/pdf')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $fileName . '"')
|
||||
->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
->setHeader('Pragma', 'no-cache')
|
||||
->setHeader('Expires', '0')
|
||||
->setBody($mpdf->Output($fileName, 'S'));
|
||||
->setBody($pdfBinary);
|
||||
}
|
||||
|
||||
private function cacheAbdmIpdBillPdf(int $ipdId, string $pdfBinary): void
|
||||
{
|
||||
if ($ipdId <= 0 || $pdfBinary === '' || ! str_starts_with($pdfBinary, '%PDF-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'abdm' . DIRECTORY_SEPARATOR . 'ipd' . DIRECTORY_SEPARATOR . $ipdId;
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
file_put_contents($directory . DIRECTORY_SEPARATOR . 'ipd-bill.pdf', $pdfBinary, LOCK_EX);
|
||||
}
|
||||
|
||||
public function showIpdForm(int $ipdId, int $formNo = 1)
|
||||
|
|
|
|||
|
|
@ -7140,10 +7140,14 @@ class Ipd_discharge extends BaseController
|
|||
try {
|
||||
$headerHtml = '';
|
||||
if ($withHeader) {
|
||||
$headerHtml = $this->sanitizeDischargePdfHtml(
|
||||
$this->applyDischargeTemplateTokens(trim((string) ($templateSettings['header_html'] ?? '')), $templateTokenVars),
|
||||
false
|
||||
$configuredHeader = $this->applyDischargeTemplateTokens(
|
||||
trim((string) ($templateSettings['header_html'] ?? '')),
|
||||
$templateTokenVars
|
||||
);
|
||||
if ($this->isDischargeHeaderBlank($configuredHeader)) {
|
||||
$configuredHeader = $this->buildDefaultDischargeHeader($templateTokenVars);
|
||||
}
|
||||
$headerHtml = $this->sanitizeDischargePdfHtml($configuredHeader, false);
|
||||
$headerHtml = mpdf_normalize_font_weight_css($headerHtml);
|
||||
}
|
||||
|
||||
|
|
@ -7192,6 +7196,7 @@ class Ipd_discharge extends BaseController
|
|||
|
||||
return $mpdf->Output($fileName, Destination::STRING_RETURN);
|
||||
});
|
||||
$this->cacheAbdmIpdPdf($ipdId, 'discharge-summary.pdf', $pdfBinary);
|
||||
|
||||
// Clear any output buffers to prevent corruption of binary PDF data
|
||||
while (ob_get_level() > 0) {
|
||||
|
|
@ -7719,6 +7724,46 @@ class Ipd_discharge extends BaseController
|
|||
. '</body></html>';
|
||||
}
|
||||
|
||||
private function cacheAbdmIpdPdf(int $ipdId, string $fileName, string $pdfBinary): void
|
||||
{
|
||||
if ($ipdId <= 0 || $pdfBinary === '' || ! str_starts_with($pdfBinary, '%PDF-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'abdm' . DIRECTORY_SEPARATOR . 'ipd' . DIRECTORY_SEPARATOR . $ipdId;
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
file_put_contents($directory . DIRECTORY_SEPARATOR . $fileName, $pdfBinary, LOCK_EX);
|
||||
}
|
||||
|
||||
private function isDischargeHeaderBlank(string $headerHtml): bool
|
||||
{
|
||||
$withoutMedia = (string) preg_replace('/<(img|svg)\b[^>]*>[\s\S]*?<\/\1>|<img\b[^>]*\/?\s*>/i', 'media', $headerHtml);
|
||||
$visibleText = html_entity_decode(strip_tags($withoutMedia), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
return trim((string) preg_replace('/\s+/u', ' ', $visibleText)) === '';
|
||||
}
|
||||
|
||||
/** @param array<string,string> $tokens */
|
||||
private function buildDefaultDischargeHeader(array $tokens): string
|
||||
{
|
||||
$logoPath = trim((string) ($tokens['H_logo_abs'] ?? ''));
|
||||
$logoHtml = $logoPath !== '' && is_file(html_entity_decode($logoPath, ENT_QUOTES | ENT_HTML5, 'UTF-8'))
|
||||
? '<td style="width:70px;vertical-align:middle;"><img src="' . $logoPath . '" style="max-width:60px;max-height:60px;"></td>'
|
||||
: '';
|
||||
|
||||
return '<table style="width:100%;border-collapse:collapse;border-bottom:1px solid #64748b;padding-bottom:6px;">'
|
||||
. '<tr>' . $logoHtml
|
||||
. '<td style="vertical-align:middle;text-align:center;">'
|
||||
. '<div style="font-family:freeserif,serif;font-size:18pt;font-weight:bold;">' . ($tokens['H_Name'] ?? '') . '</div>'
|
||||
. '<div style="font-family:freeserif,serif;font-size:9pt;">' . ($tokens['hospital_address'] ?? '') . '</div>'
|
||||
. '<div style="font-family:freeserif,serif;font-size:9pt;">Phone: ' . ($tokens['H_phone_No'] ?? '')
|
||||
. (($tokens['H_Email'] ?? '') !== '' ? ' | Email: ' . $tokens['H_Email'] : '') . '</div>'
|
||||
. '</td><td style="width:70px;"></td></tr></table>';
|
||||
}
|
||||
|
||||
public function show_file3(int $ipdId)
|
||||
{
|
||||
return $this->show_discharge($ipdId, 3);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ class FhirDocumentBuilder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $values */
|
||||
public function updateBundleMeta(array $values): self
|
||||
{
|
||||
$this->bundleMeta = array_replace($this->bundleMeta, $values);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $composition
|
||||
*/
|
||||
|
|
@ -40,6 +48,16 @@ class FhirDocumentBuilder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $values */
|
||||
public function updateComposition(array $values): self
|
||||
{
|
||||
if (is_array($this->composition)) {
|
||||
$this->composition = array_replace($this->composition, $values);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $resource
|
||||
*/
|
||||
|
|
@ -120,6 +138,12 @@ class FhirDocumentBuilder
|
|||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
public function addDocumentReference(array $resource): self
|
||||
{
|
||||
return $this->addResource($resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
|
|
@ -136,10 +160,71 @@ class FhirDocumentBuilder
|
|||
$entries[] = $entry;
|
||||
}
|
||||
|
||||
$bundle['entry'] = $entries;
|
||||
$bundle['entry'] = $this->normalizeEntryReferences($entries);
|
||||
return $this->stripNulls($bundle);
|
||||
}
|
||||
|
||||
/**
|
||||
* A urn:uuid fullUrl must contain an RFC 4122 UUID. Resource ids remain
|
||||
* human-readable while all document references use deterministic UUID URNs.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $entries
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function normalizeEntryReferences(array $entries): array
|
||||
{
|
||||
$referenceMap = [];
|
||||
foreach ($entries as $entry) {
|
||||
$resource = (array) ($entry['resource'] ?? []);
|
||||
$id = (string) ($resource['id'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
$referenceMap['urn:uuid:' . $id] = 'urn:uuid:' . $this->uuidForResource($resource);
|
||||
}
|
||||
|
||||
foreach ($entries as &$entry) {
|
||||
$resource = (array) ($entry['resource'] ?? []);
|
||||
$id = (string) ($resource['id'] ?? '');
|
||||
if ($id !== '') {
|
||||
$entry['fullUrl'] = $referenceMap['urn:uuid:' . $id];
|
||||
}
|
||||
$entry['resource'] = $this->rewriteReferences($resource, $referenceMap);
|
||||
}
|
||||
unset($entry);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $resource */
|
||||
private function uuidForResource(array $resource): string
|
||||
{
|
||||
$hex = md5((string) ($resource['resourceType'] ?? 'Resource') . '/' . (string) ($resource['id'] ?? ''));
|
||||
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3)
|
||||
. '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param array<string,string> $referenceMap
|
||||
* @return mixed
|
||||
*/
|
||||
private function rewriteReferences($value, array $referenceMap)
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $referenceMap[$value] ?? $value;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $nested) {
|
||||
$value[$key] = $this->rewriteReferences($nested, $referenceMap);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $resource
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ abstract class AbstractModuleFhirGenerator
|
|||
$patient = [
|
||||
'resourceType' => 'Patient',
|
||||
'id' => 'patient-' . $patientId,
|
||||
'meta' => ['profile' => [
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient',
|
||||
]],
|
||||
'identifier' => [
|
||||
[
|
||||
'system' => 'https://hms.local/patient-id',
|
||||
|
|
@ -116,11 +119,21 @@ abstract class AbstractModuleFhirGenerator
|
|||
if ($id === '') {
|
||||
return null;
|
||||
}
|
||||
$classCode = strtoupper(trim((string) ($enc['class_code'] ?? 'AMB')));
|
||||
$classDisplay = $classCode === 'IMP' ? 'inpatient encounter' : 'ambulatory';
|
||||
|
||||
return [
|
||||
'resourceType' => 'Encounter',
|
||||
'id' => 'encounter-' . $id,
|
||||
'meta' => ['profile' => [
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter',
|
||||
]],
|
||||
'status' => 'finished',
|
||||
'class' => [
|
||||
'system' => 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
|
||||
'code' => $classCode,
|
||||
'display' => $classDisplay,
|
||||
],
|
||||
'subject' => [
|
||||
'reference' => 'urn:uuid:patient-' . (string) ($source['patient']['id'] ?? ''),
|
||||
],
|
||||
|
|
@ -152,6 +165,9 @@ abstract class AbstractModuleFhirGenerator
|
|||
return [
|
||||
'resourceType' => 'Practitioner',
|
||||
'id' => 'practitioner-' . ($id !== '' ? $id : md5($name)),
|
||||
'meta' => ['profile' => [
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner',
|
||||
]],
|
||||
'name' => [[
|
||||
'text' => $name,
|
||||
]],
|
||||
|
|
@ -179,6 +195,9 @@ abstract class AbstractModuleFhirGenerator
|
|||
return [
|
||||
'resourceType' => 'Organization',
|
||||
'id' => 'organization-' . ($id !== '' ? $id : md5($name)),
|
||||
'meta' => ['profile' => [
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization',
|
||||
]],
|
||||
'name' => $name,
|
||||
'identifier' => $id !== '' ? [[
|
||||
'system' => 'https://hms.local/organization-id',
|
||||
|
|
|
|||
|
|
@ -15,14 +15,24 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
|
|||
$patientId = (string) ($source['patient']['id'] ?? '0');
|
||||
$sessionId = (string) ($source['session_id'] ?? '0');
|
||||
$visitDate = (string) ($source['visit_date'] ?? date('Y-m-d'));
|
||||
$bundleIdentifier = trim((string) ($source['bundle_identifier'] ?? ''));
|
||||
if ($bundleIdentifier === '') {
|
||||
$bundleIdentifier = 'discharge-' . $recordId . '-' . strtotime($timestamp);
|
||||
}
|
||||
|
||||
$careContextReference = 'DISCHARGE-' . $recordId . '-S' . $sessionId . '-' . $visitDate;
|
||||
$careContextDisplay = 'Discharge Summary ' . $visitDate;
|
||||
|
||||
$builder = new \App\Libraries\Abdm\Fhir\FhirDocumentBuilder();
|
||||
$builder
|
||||
->buildBundleMeta('discharge-' . $recordId . '-' . strtotime($timestamp), $timestamp)
|
||||
->buildComposition($this->buildBaseComposition($source, 'Discharge Summary', '18842-5', 'Discharge summary'))
|
||||
->buildBundleMeta($bundleIdentifier, $timestamp)
|
||||
->updateBundleMeta(['meta' => ['profile' => [
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle',
|
||||
]]])
|
||||
->buildComposition($this->buildBaseComposition($source, 'IPD Discharge Summary', '18842-5', 'Discharge summary'))
|
||||
->updateComposition(['meta' => ['profile' => [
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord',
|
||||
]]])
|
||||
->addPatient($this->buildBasePatient($source));
|
||||
|
||||
$encounter = $this->buildEncounter($source);
|
||||
|
|
@ -52,6 +62,26 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
|
|||
$builder->addOrganization($organization);
|
||||
}
|
||||
|
||||
$compositionUpdate = [];
|
||||
if (is_array($practitioner)) {
|
||||
$compositionUpdate['author'] = [[
|
||||
'reference' => 'urn:uuid:' . (string) $practitioner['id'],
|
||||
'display' => (string) ($practitioner['name'][0]['text'] ?? ''),
|
||||
]];
|
||||
} elseif (is_array($organization)) {
|
||||
$compositionUpdate['author'] = [[
|
||||
'reference' => 'urn:uuid:' . (string) $organization['id'],
|
||||
'display' => (string) ($organization['name'] ?? ''),
|
||||
]];
|
||||
}
|
||||
if (is_array($organization)) {
|
||||
$compositionUpdate['custodian'] = [
|
||||
'reference' => 'urn:uuid:' . (string) $organization['id'],
|
||||
'display' => (string) ($organization['name'] ?? ''),
|
||||
];
|
||||
}
|
||||
$builder->updateComposition($compositionUpdate);
|
||||
|
||||
$patientRef = 'urn:uuid:patient-' . $patientId;
|
||||
$encounterRef = is_array($encounter) ? 'urn:uuid:' . (string) ($encounter['id'] ?? '') : null;
|
||||
|
||||
|
|
@ -62,11 +92,12 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
|
|||
}
|
||||
|
||||
$resolved = $this->codingResolver->resolveSnomedForDiagnosisOrFinding((string) ($cond['code'] ?? ''), $text);
|
||||
$coding = $this->withoutPlaceholderCoding((array) ($resolved['coding'] ?? []));
|
||||
$builder->addCondition([
|
||||
'resourceType' => 'Condition',
|
||||
'id' => 'discharge-condition-' . $recordId . '-' . $idx,
|
||||
'code' => [
|
||||
'coding' => $resolved['coding'] ?? [],
|
||||
'coding' => $coding,
|
||||
'text' => $text,
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
|
|
@ -81,12 +112,13 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
|
|||
}
|
||||
|
||||
$resolved = $this->codingResolver->resolveSnomedForDiagnosisOrFinding((string) ($proc['code'] ?? ''), $text);
|
||||
$coding = $this->withoutPlaceholderCoding((array) ($resolved['coding'] ?? []));
|
||||
$builder->addProcedure([
|
||||
'resourceType' => 'Procedure',
|
||||
'id' => 'discharge-procedure-' . $recordId . '-' . $idx,
|
||||
'status' => 'completed',
|
||||
'code' => [
|
||||
'coding' => $resolved['coding'] ?? [],
|
||||
'coding' => $coding,
|
||||
'text' => $text,
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
|
|
@ -240,6 +272,85 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
|
|||
]);
|
||||
}
|
||||
|
||||
foreach ((array) ($source['documents'] ?? []) as $idx => $document) {
|
||||
$data = trim((string) ($document['data'] ?? ''));
|
||||
if ($data === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$builder->addDocumentReference([
|
||||
'resourceType' => 'DocumentReference',
|
||||
'id' => 'discharge-document-' . $recordId . '-' . $idx,
|
||||
'status' => 'current',
|
||||
'type' => ['coding' => [[
|
||||
'system' => 'http://loinc.org',
|
||||
'code' => (string) ($document['loinc_code'] ?? '55107-7'),
|
||||
'display' => (string) ($document['title'] ?? 'Clinical document'),
|
||||
]]],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'date' => (string) ($document['created_at'] ?? $timestamp),
|
||||
'author' => is_array($practitioner) ? [[
|
||||
'reference' => 'urn:uuid:' . (string) $practitioner['id'],
|
||||
]] : null,
|
||||
'custodian' => is_array($organization) ? [
|
||||
'reference' => 'urn:uuid:' . (string) $organization['id'],
|
||||
] : null,
|
||||
'description' => (string) ($document['title'] ?? 'Clinical document'),
|
||||
'content' => [[
|
||||
'attachment' => [
|
||||
'contentType' => (string) ($document['content_type'] ?? 'application/pdf'),
|
||||
'language' => 'en-IN',
|
||||
'data' => $data,
|
||||
'title' => (string) ($document['title'] ?? 'Clinical document'),
|
||||
'creation' => (string) ($document['created_at'] ?? $timestamp),
|
||||
],
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
$sectionDefinitions = [
|
||||
['title' => 'Diagnosis', 'code' => '11450-4', 'prefix' => 'discharge-condition-', 'items' => (array) ($source['conditions'] ?? [])],
|
||||
['title' => 'Procedures', 'code' => '47519-4', 'prefix' => 'discharge-procedure-', 'items' => (array) ($source['procedures'] ?? [])],
|
||||
['title' => 'Discharge medications', 'code' => '10183-2', 'prefix' => 'discharge-medication-', 'items' => (array) ($source['medications'] ?? [])],
|
||||
['title' => 'Observations', 'code' => '8716-3', 'prefix' => 'discharge-observation-', 'items' => (array) ($source['observations'] ?? [])],
|
||||
['title' => 'Investigations', 'code' => '18776-5', 'prefix' => 'discharge-investigation-', 'items' => (array) ($source['investigations'] ?? [])],
|
||||
['title' => 'Allergies', 'code' => '48765-2', 'prefix' => 'discharge-allergy-', 'items' => (array) ($source['allergies'] ?? [])],
|
||||
['title' => 'Discharge advice', 'code' => '8653-8', 'prefix' => 'discharge-careplan-', 'items' => (array) ($source['care_plans'] ?? [])],
|
||||
['title' => 'Attached documents', 'code' => '55107-7', 'prefix' => 'discharge-document-', 'items' => (array) ($source['documents'] ?? [])],
|
||||
];
|
||||
$sections = [];
|
||||
foreach ($sectionDefinitions as $definition) {
|
||||
$references = [];
|
||||
$narrativeParts = [];
|
||||
foreach ($definition['items'] as $idx => $item) {
|
||||
$references[] = ['reference' => 'urn:uuid:' . $definition['prefix'] . $recordId . '-' . $idx];
|
||||
$narrative = $this->sectionItemNarrative((array) $item);
|
||||
if ($narrative !== '') {
|
||||
$narrativeParts[] = $narrative;
|
||||
}
|
||||
}
|
||||
if ($references === []) {
|
||||
continue;
|
||||
}
|
||||
$sections[] = [
|
||||
'title' => $definition['title'],
|
||||
'code' => ['coding' => [[
|
||||
'system' => 'http://loinc.org',
|
||||
'code' => $definition['code'],
|
||||
]]],
|
||||
'entry' => $references,
|
||||
'text' => $narrativeParts !== [] ? [
|
||||
'status' => 'generated',
|
||||
'div' => '<div xmlns="http://www.w3.org/1999/xhtml"><ul><li>'
|
||||
. implode('</li><li>', array_map('htmlspecialchars', $narrativeParts))
|
||||
. '</li></ul></div>',
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
if ($sections !== []) {
|
||||
$builder->updateComposition(['section' => $sections]);
|
||||
}
|
||||
|
||||
$bundle = $builder->toBundle();
|
||||
$validation = $this->validator->validate($bundle, 'discharge', ['resolved' => 1, 'unresolved' => 0, 'fallback_used' => 0]);
|
||||
|
||||
|
|
@ -251,4 +362,33 @@ class DischargeFhirGenerator extends \App\Libraries\Abdm\Fhir\Generators\Abstrac
|
|||
'validation' => $validation->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $coding
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function withoutPlaceholderCoding(array $coding): array
|
||||
{
|
||||
return array_values(array_filter($coding, static function (array $item): bool {
|
||||
$code = strtoupper(trim((string) ($item['code'] ?? '')));
|
||||
return $code !== '' && $code !== 'UNMAPPED';
|
||||
}));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $item */
|
||||
private function sectionItemNarrative(array $item): string
|
||||
{
|
||||
foreach (['text', 'description', 'title', 'name', 'value'] as $field) {
|
||||
$value = trim((string) ($item[$field] ?? ''));
|
||||
if ($value !== '') {
|
||||
if ($field === 'name') {
|
||||
$dosage = trim((string) ($item['dosage'] ?? ''));
|
||||
return trim($value . ($dosage !== '' ? ' - ' . $dosage : ''));
|
||||
}
|
||||
return trim(strip_tags($value));
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ class FhirBundleValidator
|
|||
$resourceRefs[$rtype . '/' . $rid] = true;
|
||||
$resourceRefs['urn:uuid:' . $rid] = true;
|
||||
}
|
||||
$fullUrl = trim((string) ($entry['fullUrl'] ?? ''));
|
||||
if ($fullUrl !== '') {
|
||||
$resourceRefs[$fullUrl] = true;
|
||||
}
|
||||
|
||||
if ($rtype === 'DiagnosticReport') {
|
||||
$results = $resource['result'] ?? [];
|
||||
|
|
|
|||
|
|
@ -146,14 +146,20 @@ class AbdmSyncWorkerService
|
|||
|
||||
$result = $this->gatewayClient->pushRecord($recordPayload, $idempotencyKey);
|
||||
|
||||
$this->recordModel->update($syncRecordId, [
|
||||
$recordUpdate = [
|
||||
'sync_status' => (bool) ($result['ok'] ?? false) ? 'done' : ((bool) ($result['retryable'] ?? false) ? 'failed' : 'dead'),
|
||||
'gateway_record_id' => (int) ($result['gateway_record_id'] ?? 0) ?: null,
|
||||
'gateway_queue_id' => (string) ($result['gateway_queue_id'] ?? '') ?: null,
|
||||
'last_synced_at' => date('Y-m-d H:i:s'),
|
||||
'last_error' => (bool) ($result['ok'] ?? false) ? null : (string) ($result['message'] ?? 'Sync failed'),
|
||||
'retry_count' => (int) ($record['retry_count'] ?? 0) + ((bool) ($result['ok'] ?? false) ? 0 : 1),
|
||||
]);
|
||||
];
|
||||
if ($this->db->fieldExists('first_pushed_at', 'abdm_sync_record')) {
|
||||
$recordUpdate['first_pushed_at'] = (string) ($result['first_pushed_at'] ?? '') ?: null;
|
||||
$this->db->table('abdm_sync_record')->where('id', $syncRecordId)->update($recordUpdate);
|
||||
} else {
|
||||
$this->recordModel->update($syncRecordId, $recordUpdate);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -938,6 +938,9 @@ class FhirR4Builder
|
|||
$pdfDocRefRef = 'urn:uuid:' . $pdfDocRefUuid;
|
||||
$pdfContentType = trim((string) ($attachment['content_type'] ?? 'application/pdf')) ?: 'application/pdf';
|
||||
$pdfTitle = trim((string) ($attachment['title'] ?? 'Lab Report PDF')) ?: 'Lab Report PDF';
|
||||
$pdfBytes = base64_decode($pdfData, true);
|
||||
$pdfSize = (int) ($attachment['size'] ?? (is_string($pdfBytes) ? strlen($pdfBytes) : 0));
|
||||
$pdfHash = trim((string) ($attachment['hash'] ?? (is_string($pdfBytes) ? base64_encode(sha1($pdfBytes, true)) : '')));
|
||||
|
||||
$resourceEntries[] = ['fullUrl' => 'urn:uuid:' . $pdfBinaryUuid, 'resource' => [
|
||||
'resourceType' => 'Binary',
|
||||
|
|
@ -955,12 +958,14 @@ class FhirR4Builder
|
|||
'type' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnostic studies report']]],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'content' => [[
|
||||
'attachment' => [
|
||||
'attachment' => array_filter([
|
||||
'contentType' => $pdfContentType,
|
||||
'url' => 'urn:uuid:' . $pdfBinaryUuid,
|
||||
'data' => $pdfData,
|
||||
'title' => $pdfTitle,
|
||||
],
|
||||
'size' => $pdfSize > 0 ? $pdfSize : null,
|
||||
'hash' => $pdfHash !== '' ? $pdfHash : null,
|
||||
], static fn ($value): bool => $value !== null),
|
||||
]],
|
||||
]];
|
||||
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@
|
|||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" id="taskFilters">
|
||||
<button class="btn btn-sm btn-primary filter-btn" data-filter="dashboard">Dashboard</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="opd_book">OPD Book</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="opd_consult_publish">OPD Consult Publish</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="lab_report_publish">Lab Reports</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="radiology_report_publish">Radiology Reports</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="immunization_record_publish">Immunization Record</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="invoice">Invoice</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="ipd_discharge_publish">IPD Discharge</button>
|
||||
<button type="button" class="btn btn-sm btn-primary filter-btn" data-filter="dashboard">Dashboard</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="opd_book">OPD Book</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="opd_consult_publish">OPD Consult Publish</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="lab_report_publish">Lab Reports</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="radiology_report_publish">Radiology Reports</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="immunization_record_publish">Immunization Record</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="invoice">Invoice</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary filter-btn" data-filter="ipd_discharge_publish">IPD Discharge</button>
|
||||
</div>
|
||||
|
||||
<?php $dashboard = $dashboard_metrics ?? []; ?>
|
||||
|
|
@ -165,25 +165,25 @@
|
|||
<td>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<?php if ($type === 'patient_abha_create' || $type === 'patient_abha_link'): ?>
|
||||
<button class="btn btn-sm btn-outline-success action-btn" data-action="create_abha">Create ABHA</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success action-btn" data-action="create_abha">Create ABHA</button>
|
||||
<?php elseif ($type === 'patient_abha_update'): ?>
|
||||
<button class="btn btn-sm btn-outline-primary action-btn" data-action="update_abha">Update ABHA</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary action-btn" data-action="update_abha">Update ABHA</button>
|
||||
<?php elseif ($type === 'immunization_record_publish'): ?>
|
||||
<?php if ($bridgeSubmitted): ?>
|
||||
<button class="btn btn-sm btn-outline-success" disabled title="Submitted to ABDM Bridge<?= $bridgeCareContext !== '' ? ': ' . esc($bridgeCareContext) : '' ?>">Submitted to Bridge</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" disabled title="Submitted to ABDM Bridge<?= $bridgeCareContext !== '' ? ': ' . esc($bridgeCareContext) : '' ?>">Submitted to Bridge</button>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-success immunization-bridge-btn" title="Push this ImmunizationRecord FHIR bundle to ABDM Bridge.">Push to Bridge</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success immunization-bridge-btn" title="Push this ImmunizationRecord FHIR bundle to ABDM Bridge.">Push to Bridge</button>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-warning action-btn" data-action="submit">Submit</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning action-btn" data-action="submit">Submit</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($showSandbox): ?>
|
||||
<button class="btn btn-sm btn-outline-info sandbox-btn">Sandbox</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-info sandbox-btn">Sandbox</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($showPreview): ?>
|
||||
<button class="btn btn-sm btn-outline-primary preview-fhir-btn">Preview FHIR</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary preview-fhir-btn">Preview FHIR</button>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-sm btn-outline-dark close-btn">Close</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark close-btn">Close</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -757,6 +757,19 @@
|
|||
statusBox.style.color = isError ? '#dc3545' : '#6c757d';
|
||||
}
|
||||
|
||||
function setSubmissionStatus(msg, queueId) {
|
||||
setStatus(msg, false);
|
||||
if (!queueId) {
|
||||
return;
|
||||
}
|
||||
statusBox.appendChild(document.createTextNode(' '));
|
||||
var logLink = document.createElement('a');
|
||||
logLink.href = '<?= base_url('AbdmBridgeLog') ?>' + '?search=' + encodeURIComponent(queueId);
|
||||
logLink.textContent = 'View Bridge Log';
|
||||
logLink.className = 'ms-1';
|
||||
statusBox.appendChild(logLink);
|
||||
}
|
||||
|
||||
function post(url, payload, cb) {
|
||||
payload[csrfName] = csrfHash;
|
||||
var body = Object.keys(payload).map(function (key) {
|
||||
|
|
@ -1665,6 +1678,7 @@
|
|||
var _fhirInvoiceSource = '';
|
||||
var _fhirInvoiceBillId = 0;
|
||||
var _fhirInvoicePatientId = 0;
|
||||
var _fhirPreviewUrl = '';
|
||||
|
||||
// ── Inline complaint SNOMED edit (event delegation on fhirFormView) ─
|
||||
var _editTimer = null;
|
||||
|
|
@ -2535,6 +2549,7 @@
|
|||
|
||||
function openFhirModal(url, title, options) {
|
||||
options = options || {};
|
||||
_fhirPreviewUrl = url;
|
||||
_fhirSubmitMode = options.submitMode || 'opd';
|
||||
_fhirSubmitTaskRow = options.taskRow || null;
|
||||
_fhirSubmitAbha = (options.abhaId || '').trim();
|
||||
|
|
@ -2588,14 +2603,19 @@
|
|||
}
|
||||
|
||||
if (btnRegenerateFhirModal) {
|
||||
var taskType = _fhirSubmitTaskRow ? (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() : '';
|
||||
var canRebuildDiagnostic = _fhirSubmitMode === 'task'
|
||||
&& (taskType === 'lab_report_publish' || taskType === 'radiology_report_publish');
|
||||
btnRegenerateFhirModal.disabled = false;
|
||||
btnRegenerateFhirModal.style.display = _fhirSubmitMode === 'task' ? 'none' : '';
|
||||
btnRegenerateFhirModal.innerHTML = _fhirSubmitMode === 'invoice'
|
||||
? '<i class="bi bi-arrow-clockwise"></i> Rebuild Bundle'
|
||||
btnRegenerateFhirModal.style.display = _fhirSubmitMode === 'task' && !canRebuildDiagnostic ? 'none' : '';
|
||||
btnRegenerateFhirModal.innerHTML = _fhirSubmitMode === 'invoice' || canRebuildDiagnostic
|
||||
? '<i class="bi bi-arrow-clockwise"></i> Rebuild Digital Record'
|
||||
: '<i class="bi bi-arrow-clockwise"></i> Regenerate';
|
||||
btnRegenerateFhirModal.title = _fhirSubmitMode === 'invoice'
|
||||
? 'Rebuild FHIR JSON from current billing data'
|
||||
: 'Rebuild FHIR bundle from current prescription data';
|
||||
btnRegenerateFhirModal.title = canRebuildDiagnostic
|
||||
? 'Rebuild FHIR and Digital Share PDF from the latest report data'
|
||||
: (_fhirSubmitMode === 'invoice'
|
||||
? 'Rebuild FHIR and Digital Share PDF from current billing data'
|
||||
: 'Rebuild FHIR bundle from current prescription data');
|
||||
}
|
||||
|
||||
fhirPreviewModal.show();
|
||||
|
|
@ -2632,6 +2652,20 @@
|
|||
// Regenerate FHIR bundle in modal
|
||||
btnRegenerateFhirModal.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
var taskType = _fhirSubmitTaskRow ? (_fhirSubmitTaskRow.getAttribute('data-task-type') || '').toLowerCase() : '';
|
||||
if (_fhirSubmitMode === 'task' && (taskType === 'lab_report_publish' || taskType === 'radiology_report_publish')) {
|
||||
if (_fhirPreviewUrl === '') {
|
||||
alert('Diagnostic preview URL is unavailable.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Rebuild this FHIR record and Digital Share PDF from the latest report data?')) return;
|
||||
openFhirModal(_fhirPreviewUrl, fhirModalTitle ? fhirModalTitle.textContent : 'Diagnostic FHIR Preview', {
|
||||
submitMode: 'task',
|
||||
taskRow: _fhirSubmitTaskRow,
|
||||
abhaId: _fhirSubmitAbha
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_fhirSubmitMode === 'invoice') {
|
||||
if (!_fhirInvoiceSource || _fhirInvoiceBillId <= 0 || _fhirInvoicePatientId <= 0) {
|
||||
alert('Invoice source data is incomplete.');
|
||||
|
|
@ -2812,17 +2846,13 @@
|
|||
|
||||
var queueText = res.queue_id ? (' Queue ID: ' + res.queue_id) : '';
|
||||
var warningText = res.warning ? (' Warning: ' + res.warning) : '';
|
||||
setStatus('Submitted to ABDM gateway.' + queueText + warningText);
|
||||
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';
|
||||
}
|
||||
alert('Submitted to ABDM gateway.' + queueText + warningText);
|
||||
if (res.queue_id) {
|
||||
var logUrl = '<?= base_url('AbdmBridgeLog') ?>' + '?search=' + encodeURIComponent(res.queue_id);
|
||||
window.open(logUrl, '_blank');
|
||||
}
|
||||
fhirPreviewModal.hide();
|
||||
});
|
||||
return;
|
||||
|
|
@ -2858,17 +2888,13 @@
|
|||
|
||||
var queueText = res.queue_id ? (' Queue ID: ' + res.queue_id) : '';
|
||||
var warningText = res.warning ? (' Warning: ' + res.warning) : '';
|
||||
setStatus('Submitted to ABDM gateway.' + queueText + warningText);
|
||||
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';
|
||||
}
|
||||
alert('Submitted to ABDM gateway.' + queueText + warningText);
|
||||
if (res.queue_id) {
|
||||
var logUrl = '<?= base_url('AbdmBridgeLog') ?>' + '?search=' + encodeURIComponent(res.queue_id);
|
||||
window.open(logUrl, '_blank');
|
||||
}
|
||||
fhirPreviewModal.hide();
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ final class FhirGeneratorsTest extends CIUnitTestCase
|
|||
$this->assertIsString($pdfBytes);
|
||||
$this->assertSame('application/pdf', $attachment['contentType'] ?? null);
|
||||
$this->assertStringStartsWith('%PDF-', $pdfBytes);
|
||||
$this->assertStringNotContainsString('table.items{', $pdfBytes);
|
||||
$this->assertSame(strlen($pdfBytes), $attachment['size'] ?? null);
|
||||
$this->assertSame(base64_encode(sha1($pdfBytes, true)), $attachment['hash'] ?? null);
|
||||
$this->assertSame(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user