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:
DevSoftTech Server 2026-08-16 17:35:42 +05:30
parent 625987955e
commit 2e8304636b
12 changed files with 694 additions and 102 deletions

View File

@ -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.

View File

@ -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;
}

View File

@ -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)

View File

@ -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);

View File

@ -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
*/

View File

@ -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',

View File

@ -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 '';
}
}

View File

@ -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'] ?? [];

View File

@ -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;
}

View File

@ -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),
]],
]];

View File

@ -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;

View File

@ -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(