feat(abdm): improve bridge record delivery
Add NAT-safe Immunization and InvoiceRecord Bridge workflows, stable care-context handling, ABHA address enrichment, embedded invoice PDFs, M2 status synchronization, and strict Bridge response normalization.
This commit is contained in:
parent
68d256d9ba
commit
625987955e
|
|
@ -736,6 +736,8 @@ $routes->post('AbdmGateway/share_immunization_bundle', 'AbdmGateway::shareImmuni
|
|||
$routes->post('AbdmGateway/share_wellness_bundle', 'AbdmGateway::shareWellnessBundle', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/share_health_document_bundle', 'AbdmGateway::shareHealthDocumentBundle', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/share_invoice_bundle', 'AbdmGateway::shareInvoiceBundle', ['filter' => $abdmPermFilter]);
|
||||
$routes->get('AbdmGateway/invoice_fhir_preview', 'AbdmGateway::invoiceFhirPreview', ['filter' => $abdmPermFilter]);
|
||||
$routes->post('AbdmGateway/share_invoice_source_bundle', 'AbdmGateway::shareInvoiceSourceBundle', ['filter' => $abdmPermFilter]);
|
||||
$routes->get('Immunization', 'Immunization::index', ['filter' => 'permission:doctor_work.immunization.access']);
|
||||
$routes->get('Immunization/schedule_master', 'Immunization::scheduleMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
|
||||
$routes->post('Immunization/sync_uip_master', 'Immunization::syncUipMaster', ['filter' => 'permission:doctor_work.immunization.schedule-manage']);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,13 +18,30 @@ class AbdmTaskBoard extends BaseController
|
|||
{
|
||||
$this->backfillPatientAbhaTasks();
|
||||
$this->backfillLabRadiologyTasks();
|
||||
$tasks = $this->taskService->getOpenTasks(300);
|
||||
$this->backfillImmunizationTasks();
|
||||
$tasks = $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300));
|
||||
|
||||
$dateFrom = trim((string) ($this->request->getGet('date_from') ?? date('Y-m-d')));
|
||||
$dateTo = trim((string) ($this->request->getGet('date_to') ?? date('Y-m-d')));
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) {
|
||||
$dateFrom = date('Y-m-d');
|
||||
}
|
||||
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
||||
$dateTo = date('Y-m-d');
|
||||
}
|
||||
if ($dateFrom > $dateTo) {
|
||||
[$dateFrom, $dateTo] = [$dateTo, $dateFrom];
|
||||
}
|
||||
|
||||
return view('abdm/task_board', [
|
||||
'tasks' => $tasks,
|
||||
'dashboard_metrics' => $this->getDashboardMetrics($dateFrom, $dateTo),
|
||||
'dashboard_date_from' => $dateFrom,
|
||||
'dashboard_date_to' => $dateTo,
|
||||
'today_credit_opd_rows' => $this->getTodayCreditOpdConsultRows(),
|
||||
'opd_book_rows' => $this->getOpdBookRows(),
|
||||
'opd_consult_rows' => $this->getOpdConsultPublishRows(),
|
||||
'invoice_rows' => $this->getInvoiceRows(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -32,9 +49,10 @@ class AbdmTaskBoard extends BaseController
|
|||
{
|
||||
$this->backfillPatientAbhaTasks();
|
||||
$this->backfillLabRadiologyTasks();
|
||||
$this->backfillImmunizationTasks();
|
||||
return $this->response->setJSON([
|
||||
'ok' => 1,
|
||||
'tasks' => $this->taskService->getOpenTasks(300),
|
||||
'tasks' => $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300)),
|
||||
'csrfName' => csrf_token(),
|
||||
'csrfHash' => csrf_hash(),
|
||||
]);
|
||||
|
|
@ -65,6 +83,56 @@ class AbdmTaskBoard extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $tasks */
|
||||
private function enrichImmunizationPushState(array $tasks): array
|
||||
{
|
||||
if ($tasks === [] || ! $this->db->tableExists('health_records')) {
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
$entityIds = [];
|
||||
foreach ($tasks as $task) {
|
||||
if (($task['task_type'] ?? '') === 'immunization_record_publish') {
|
||||
$entityIds[] = (string) ($task['entity_id'] ?? '');
|
||||
}
|
||||
}
|
||||
$entityIds = array_values(array_filter(array_unique($entityIds), static fn (string $id): bool => $id !== ''));
|
||||
if ($entityIds === []) {
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
$latest = [];
|
||||
$rows = $this->db->table('health_records')
|
||||
->select('id, entity_id, push_status, care_context_reference, linked_at')
|
||||
->where('hi_type', 'ImmunizationRecord')
|
||||
->where('entity_type', 'immunization')
|
||||
->whereIn('entity_id', $entityIds)
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$entityId = (string) ($row['entity_id'] ?? '');
|
||||
if ($entityId !== '' && ! isset($latest[$entityId])) {
|
||||
$latest[$entityId] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tasks as &$task) {
|
||||
if (($task['task_type'] ?? '') !== 'immunization_record_publish') {
|
||||
continue;
|
||||
}
|
||||
$healthRecord = $latest[(string) ($task['entity_id'] ?? '')] ?? null;
|
||||
$pushStatus = strtolower(trim((string) ($healthRecord['push_status'] ?? '')));
|
||||
$task['bridge_health_record_id'] = (int) ($healthRecord['id'] ?? 0);
|
||||
$task['bridge_push_status'] = $pushStatus;
|
||||
$task['bridge_care_context_reference'] = trim((string) ($healthRecord['care_context_reference'] ?? ''));
|
||||
$task['bridge_submitted'] = in_array($pushStatus, ['queued', 'pushed', 'linked'], true) ? 1 : 0;
|
||||
}
|
||||
unset($task);
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
public function performAction()
|
||||
{
|
||||
if (! $this->request->isAJAX()) {
|
||||
|
|
@ -309,6 +377,80 @@ class AbdmTaskBoard extends BaseController
|
|||
}
|
||||
}
|
||||
|
||||
private function backfillImmunizationTasks(): void
|
||||
{
|
||||
if (! $this->db->tableExists('abdm_work_tasks') || ! $this->db->tableExists('immunization_records') || ! $this->db->tableExists('patient_master')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$patientFields = $this->db->getFieldNames('patient_master') ?? [];
|
||||
$abhaField = $this->resolveExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']);
|
||||
if ($abhaField === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $this->db->table('immunization_records r')
|
||||
->select('r.id, r.patient_id, r.vaccine_name, r.given_date, p.p_fname AS patient_name, p.' . $abhaField . ' AS abha_id', false)
|
||||
->join('patient_master p', 'p.id = r.patient_id', 'left')
|
||||
->where('r.status', 'completed')
|
||||
->where('p.' . $abhaField . ' !=', '')
|
||||
->orderBy('r.id', 'DESC')
|
||||
->limit(500)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$recordId = (int) ($row['id'] ?? 0);
|
||||
$patientId = (int) ($row['patient_id'] ?? 0);
|
||||
$abhaId = trim((string) ($row['abha_id'] ?? ''));
|
||||
if ($recordId <= 0 || $patientId <= 0 || $abhaId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$linked = $this->db->tableExists('health_records') && ! empty($this->db->table('health_records')
|
||||
->select('id')
|
||||
->where('hi_type', 'ImmunizationRecord')
|
||||
->where('entity_type', 'immunization')
|
||||
->where('entity_id', (string) $recordId)
|
||||
->where('push_status', 'linked')
|
||||
->get(1)
|
||||
->getRowArray());
|
||||
if ($linked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = $this->db->table('abdm_work_tasks')
|
||||
->select('id')
|
||||
->where('task_type', 'immunization_record_publish')
|
||||
->where('entity_type', 'immunization')
|
||||
->where('entity_id', (string) $recordId)
|
||||
->whereIn('status', ['pending', 'in_progress'])
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if (! empty($exists)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->taskService->createOrRefreshTask(
|
||||
'immunization_record_publish',
|
||||
'immunization',
|
||||
'immunization',
|
||||
(string) $recordId,
|
||||
$patientId,
|
||||
trim((string) ($row['patient_name'] ?? '')),
|
||||
$abhaId,
|
||||
'register_m2',
|
||||
[
|
||||
'record_id' => $recordId,
|
||||
'vaccine_name' => (string) ($row['vaccine_name'] ?? ''),
|
||||
'given_date' => (string) ($row['given_date'] ?? ''),
|
||||
'hi_type' => 'ImmunizationRecord',
|
||||
'trigger' => 'task_board.backfill',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
|
|
@ -439,33 +581,252 @@ class AbdmTaskBoard extends BaseController
|
|||
}
|
||||
|
||||
/**
|
||||
* OPD Book rows: HMS OPD appointments (last 7 days) where patient has ABHA.
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getInvoiceRows(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
if ($this->db->tableExists('opd_master')) {
|
||||
foreach ($this->db->table('opd_master')
|
||||
->select('opd_id AS bill_id, opd_code AS bill_code, p_id AS patient_id, P_name AS patient_name, apointment_date AS bill_date, opd_fee_amount AS amount')
|
||||
->orderBy('opd_id', 'DESC')
|
||||
->limit(100)
|
||||
->get()
|
||||
->getResultArray() as $row) {
|
||||
$rows[] = array_merge($row, ['source' => 'OPD', 'source_key' => 'opd_invoice']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('invoice_master')) {
|
||||
foreach ($this->db->table('invoice_master i')
|
||||
->select('i.id AS bill_id, i.invoice_code AS bill_code, i.attach_id AS patient_id, COALESCE(NULLIF(i.inv_name, ""), p.p_fname) AS patient_name, i.inv_date AS bill_date, i.net_amount AS amount')
|
||||
->join('patient_master p', 'p.id = i.attach_id AND i.attach_type = 0', 'left')
|
||||
->orderBy('i.id', 'DESC')
|
||||
->limit(100)
|
||||
->get()
|
||||
->getResultArray() as $row) {
|
||||
$rows[] = array_merge($row, ['source' => 'Charges', 'source_key' => 'charges_invoice']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('ipd_master')) {
|
||||
foreach ($this->db->table('ipd_master i')
|
||||
->select("i.id AS bill_id, i.ipd_code AS bill_code, i.p_id AS patient_id, COALESCE(NULLIF(NULLIF(TRIM(i.P_name), ''), '0'), TRIM(CONCAT_WS(' ', p.p_fname, p.p_lname))) AS patient_name, COALESCE(i.discharge_date, i.register_date) AS bill_date, i.net_amount AS amount", false)
|
||||
->join('patient_master p', 'p.id = i.p_id', 'left')
|
||||
->orderBy('i.id', 'DESC')
|
||||
->limit(100)
|
||||
->get()
|
||||
->getResultArray() as $row) {
|
||||
$rows[] = array_merge($row, ['source' => 'IPD Billing', 'source_key' => 'ipd_invoice']);
|
||||
}
|
||||
}
|
||||
|
||||
$healthRecords = [];
|
||||
if ($this->db->tableExists('health_records')) {
|
||||
$hrFields = $this->db->getFieldNames('health_records') ?? [];
|
||||
$select = ['id', 'entity_type', 'entity_id', 'push_status', 'abdm_txn_id', 'care_context_reference', 'push_at', 'linked_at'];
|
||||
if (in_array('bridge_record_id', $hrFields, true)) {
|
||||
$select[] = 'bridge_record_id';
|
||||
}
|
||||
|
||||
$hrRows = $this->db->table('health_records')
|
||||
->select(implode(',', $select))
|
||||
->where('hi_type', 'InvoiceRecord')
|
||||
->whereIn('entity_type', ['invoice', 'charges_invoice', 'opd_invoice', 'ipd_invoice'])
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($hrRows as $hr) {
|
||||
$key = (string) ($hr['entity_type'] ?? '') . ':' . (string) ($hr['entity_id'] ?? '');
|
||||
if ($key !== ':' && ! isset($healthRecords[$key])) {
|
||||
$healthRecords[$key] = $hr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recordLinks = [];
|
||||
$healthRecordIds = array_values(array_filter(array_map(
|
||||
static fn (array $hr): int => (int) ($hr['id'] ?? 0),
|
||||
$healthRecords
|
||||
)));
|
||||
if ($healthRecordIds !== [] && $this->db->tableExists('record_links')) {
|
||||
$linkRows = $this->db->table('record_links')
|
||||
->select('health_record_id, link_status, linked_at')
|
||||
->whereIn('health_record_id', $healthRecordIds)
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($linkRows as $link) {
|
||||
$healthRecordId = (int) ($link['health_record_id'] ?? 0);
|
||||
if ($healthRecordId > 0 && ! isset($recordLinks[$healthRecordId])) {
|
||||
$recordLinks[$healthRecordId] = $link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$billId = (string) ($row['bill_id'] ?? '');
|
||||
$keys = [(string) $row['source_key'] . ':' . $billId];
|
||||
if ($row['source_key'] === 'charges_invoice') {
|
||||
$keys[] = 'invoice:' . $billId;
|
||||
}
|
||||
|
||||
$hr = null;
|
||||
foreach ($keys as $key) {
|
||||
if (isset($healthRecords[$key])) {
|
||||
$hr = $healthRecords[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$pushStatus = strtolower(trim((string) ($hr['push_status'] ?? '')));
|
||||
$link = $hr !== null ? ($recordLinks[(int) ($hr['id'] ?? 0)] ?? null) : null;
|
||||
$linkStatus = strtolower(trim((string) ($link['link_status'] ?? '')));
|
||||
$statusLabel = 'Not Pushed';
|
||||
$statusTone = 'secondary';
|
||||
|
||||
if ($pushStatus === 'queued') {
|
||||
$statusLabel = 'Submitted';
|
||||
$statusTone = 'warning';
|
||||
} elseif ($pushStatus === 'pushed') {
|
||||
$statusLabel = 'Pushed';
|
||||
$statusTone = 'primary';
|
||||
} elseif ($pushStatus === 'linked') {
|
||||
$statusLabel = 'Linked';
|
||||
$statusTone = 'success';
|
||||
} elseif ($pushStatus === 'failed') {
|
||||
$statusLabel = 'Failed';
|
||||
$statusTone = 'danger';
|
||||
} elseif ($pushStatus !== '') {
|
||||
$statusLabel = ucwords(str_replace('_', ' ', $pushStatus));
|
||||
$statusTone = 'info';
|
||||
}
|
||||
|
||||
if ($linkStatus === 'linked') {
|
||||
$statusLabel = 'Linked';
|
||||
$statusTone = 'success';
|
||||
} elseif ($linkStatus === 'failed') {
|
||||
$statusLabel = 'Link Failed';
|
||||
$statusTone = 'danger';
|
||||
}
|
||||
|
||||
$row['record_status_label'] = $statusLabel;
|
||||
$row['record_status_tone'] = $statusTone;
|
||||
$row['push_status'] = $pushStatus;
|
||||
$row['link_status'] = $linkStatus;
|
||||
$row['queue_id'] = trim((string) ($hr['abdm_txn_id'] ?? ''));
|
||||
$row['bridge_record_id'] = (int) ($hr['bridge_record_id'] ?? 0);
|
||||
$row['care_context_reference'] = trim((string) ($hr['care_context_reference'] ?? ''));
|
||||
}
|
||||
unset($row);
|
||||
|
||||
usort($rows, static function (array $left, array $right): int {
|
||||
$dateCompare = strcmp((string) ($right['bill_date'] ?? ''), (string) ($left['bill_date'] ?? ''));
|
||||
return $dateCompare !== 0 ? $dateCompare : ((int) ($right['bill_id'] ?? 0) <=> (int) ($left['bill_id'] ?? 0));
|
||||
});
|
||||
|
||||
return array_slice($rows, 0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
private function getDashboardMetrics(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
$metrics = [
|
||||
'total_patients' => 0,
|
||||
'without_abha' => 0,
|
||||
'abha_verified' => 0,
|
||||
'verified_in_range' => 0,
|
||||
'records_pushed' => 0,
|
||||
'records_pushed_in_range' => 0,
|
||||
'opd_tokens' => 0,
|
||||
'opd_tokens_in_range' => 0,
|
||||
'verification_date_source' => '',
|
||||
];
|
||||
|
||||
if ($this->db->tableExists('patient_master')) {
|
||||
$fields = $this->db->getFieldNames('patient_master') ?? [];
|
||||
$abhaCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha']);
|
||||
$verifiedCol = $this->resolveExistingColumn($fields, ['abha_verified_status']);
|
||||
$verifiedDateCol = $this->resolveExistingColumn($fields, ['abha_verified_at', 'abdm_linked_at', 'last_update']);
|
||||
|
||||
$metrics['total_patients'] = $this->db->table('patient_master')->countAllResults();
|
||||
if ($abhaCol !== null) {
|
||||
$validAbhaSql = $abhaCol . " REGEXP '^[0-9]{14}$'";
|
||||
$metrics['without_abha'] = $this->db->table('patient_master')
|
||||
->where("COALESCE(" . $abhaCol . ", '') NOT REGEXP '^[0-9]{14}$'", null, false)
|
||||
->countAllResults();
|
||||
|
||||
$verifiedBuilder = $this->db->table('patient_master')->where($validAbhaSql, null, false);
|
||||
if ($verifiedCol !== null) {
|
||||
$verifiedBuilder->where($verifiedCol, 'VERIFIED');
|
||||
}
|
||||
$metrics['abha_verified'] = $verifiedBuilder->countAllResults();
|
||||
|
||||
if ($verifiedDateCol !== null) {
|
||||
$rangeBuilder = $this->db->table('patient_master')
|
||||
->where($validAbhaSql, null, false)
|
||||
->where($verifiedDateCol . ' >=', $dateFrom . ' 00:00:00')
|
||||
->where($verifiedDateCol . ' <=', $dateTo . ' 23:59:59');
|
||||
if ($verifiedCol !== null) {
|
||||
$rangeBuilder->where($verifiedCol, 'VERIFIED');
|
||||
}
|
||||
$metrics['verified_in_range'] = $rangeBuilder->countAllResults();
|
||||
$metrics['verification_date_source'] = $verifiedDateCol;
|
||||
}
|
||||
} else {
|
||||
$metrics['without_abha'] = $metrics['total_patients'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('health_records')) {
|
||||
$fields = $this->db->getFieldNames('health_records') ?? [];
|
||||
$dateCol = $this->resolveExistingColumn($fields, ['push_at', 'linked_at', 'updated_at', 'created_at']);
|
||||
$pushedStatuses = ['queued', 'pushed', 'linked'];
|
||||
$metrics['records_pushed'] = $this->db->table('health_records')
|
||||
->whereIn('push_status', $pushedStatuses)
|
||||
->countAllResults();
|
||||
if ($dateCol !== null) {
|
||||
$metrics['records_pushed_in_range'] = $this->db->table('health_records')
|
||||
->whereIn('push_status', $pushedStatuses)
|
||||
->where($dateCol . ' >=', $dateFrom . ' 00:00:00')
|
||||
->where($dateCol . ' <=', $dateTo . ' 23:59:59')
|
||||
->countAllResults();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('abdm_opd_tokens')) {
|
||||
$metrics['opd_tokens'] = $this->db->table('abdm_opd_tokens')->countAllResults();
|
||||
$metrics['opd_tokens_in_range'] = $this->db->table('abdm_opd_tokens')
|
||||
->where('queue_date >=', $dateFrom)
|
||||
->where('queue_date <=', $dateTo)
|
||||
->countAllResults();
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPD Book rows: locally synced ABDM OPD tokens only.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function getOpdBookRows(): array
|
||||
{
|
||||
if (! $this->db->tableExists('opd_master') || ! $this->db->tableExists('patient_master')) {
|
||||
if (! $this->db->tableExists('abdm_opd_tokens')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$patientFields = $this->db->getFieldNames('patient_master') ?? [];
|
||||
$abhaCol = $this->resolveExistingColumn($patientFields, ['abha_id', 'abha_no', 'abha_address', 'abha']);
|
||||
if ($abhaCol === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->db->table('opd_master o')
|
||||
->select('o.opd_id, o.p_id, o.P_name, o.apointment_date, o.opd_status, o.doc_name, p.' . $abhaCol . ' as abha_id', false)
|
||||
->join('patient_master p', 'p.id = o.p_id', 'left')
|
||||
->where('DATE(o.apointment_date) >=', date('Y-m-d', strtotime('-7 days')), false)
|
||||
->where('p.' . $abhaCol . ' !=', '')
|
||||
->orderBy('o.opd_id', 'DESC')
|
||||
return $this->db->table('abdm_opd_tokens')
|
||||
->where('queue_date >=', date('Y-m-d', strtotime('-7 days')))
|
||||
->orderBy('queue_date', 'DESC')
|
||||
->orderBy('gateway_token_id', 'DESC')
|
||||
->limit(200)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
return array_values(array_filter($rows, static fn ($r) => preg_match('/^\d{14}$/', trim((string) ($r['abha_id'] ?? ''))) === 1));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1293,7 +1293,106 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface
|
|||
$body[$optional] = (string) $data[$optional];
|
||||
}
|
||||
|
||||
return $this->post('/v3/records/push', $body);
|
||||
return self::normalizePushRecordResponse($this->post('/v3/records/push', $body));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $result
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalizePushRecordResponse(array $result): array
|
||||
{
|
||||
$httpCode = (int) ($result['http_code'] ?? 0);
|
||||
$error = $result['error'] ?? null;
|
||||
$responseError = $result['response']['error'] ?? null;
|
||||
$dataError = $result['data']['error'] ?? null;
|
||||
$errorCode = strtoupper(trim((string) (
|
||||
$result['error_code']
|
||||
?? (is_array($error) ? ($error['code'] ?? null) : $error)
|
||||
?? ($result['response']['error_code'] ?? null)
|
||||
?? (is_array($responseError) ? ($responseError['code'] ?? null) : $responseError)
|
||||
?? ($result['data']['error_code'] ?? null)
|
||||
?? (is_array($dataError) ? ($dataError['code'] ?? null) : $dataError)
|
||||
?? ''
|
||||
)));
|
||||
$duplicate = $httpCode === 409 && $errorCode === 'DUPLICATE_RECORD';
|
||||
|
||||
$recordId = self::firstPositiveInteger([
|
||||
$result['record_id'] ?? null,
|
||||
$result['existing_record_id'] ?? null,
|
||||
$result['response']['record_id'] ?? null,
|
||||
$result['response']['existing_record_id'] ?? null,
|
||||
$result['data']['record_id'] ?? null,
|
||||
$result['data']['existing_record_id'] ?? null,
|
||||
]);
|
||||
$queueId = self::firstNonEmptyString([
|
||||
$result['queue_id'] ?? null,
|
||||
$result['existing_queue_id'] ?? null,
|
||||
$result['response']['queue_id'] ?? null,
|
||||
$result['response']['existing_queue_id'] ?? null,
|
||||
$result['data']['queue_id'] ?? null,
|
||||
$result['data']['existing_queue_id'] ?? null,
|
||||
]);
|
||||
$firstPushedAt = self::firstNonEmptyString([
|
||||
$result['first_pushed_at'] ?? null,
|
||||
$result['response']['first_pushed_at'] ?? null,
|
||||
$result['data']['first_pushed_at'] ?? null,
|
||||
]);
|
||||
|
||||
if ($recordId > 0) {
|
||||
$result['record_id'] = $recordId;
|
||||
}
|
||||
if ($queueId !== '') {
|
||||
$result['queue_id'] = $queueId;
|
||||
}
|
||||
if ($firstPushedAt !== '') {
|
||||
$result['first_pushed_at'] = $firstPushedAt;
|
||||
}
|
||||
$result['error_code'] = $errorCode !== '' ? $errorCode : ($result['error_code'] ?? null);
|
||||
$result['duplicate'] = $duplicate ? 1 : 0;
|
||||
$result['submitted'] = ($httpCode === 201 || $duplicate) ? 1 : 0;
|
||||
|
||||
if ($httpCode === 201 || $duplicate) {
|
||||
$result['ok'] = 1;
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($httpCode === 422) {
|
||||
$result['ok'] = 0;
|
||||
$message = trim((string) ($result['message'] ?? $result['error_text'] ?? 'Validation failed'));
|
||||
$details = [];
|
||||
foreach ((array) ($result['errors'] ?? []) as $error) {
|
||||
$details[] = is_array($error)
|
||||
? trim(implode(' ', array_filter([$error['field'] ?? '', $error['message'] ?? ''])))
|
||||
: trim((string) $error);
|
||||
}
|
||||
$details = array_values(array_filter($details));
|
||||
$result['error_text'] = $message . ($details !== [] ? ' | ' . implode(' ; ', $details) : '');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $values */
|
||||
private static function firstPositiveInteger(array $values): int
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if ((int) $value > 0) {
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $values */
|
||||
private static function firstNonEmptyString(array $values): string
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
45
app/Libraries/Abdm/M2LinkStatusResolver.php
Normal file
45
app/Libraries/Abdm/M2LinkStatusResolver.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm;
|
||||
|
||||
final class M2LinkStatusResolver
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{request_id:string,abha_id:string,care_context_reference:string,linked_at:string,status:string}
|
||||
*/
|
||||
public static function parse(array $payload, string $headerRequestId = ''): array
|
||||
{
|
||||
return [
|
||||
'request_id' => trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? $headerRequestId)),
|
||||
'abha_id' => trim((string) ($payload['abha_id'] ?? $payload['abha_address'] ?? '')),
|
||||
'care_context_reference' => trim((string) ($payload['care_context_reference'] ?? $payload['careContextReference'] ?? '')),
|
||||
'linked_at' => trim((string) ($payload['linked_at'] ?? $payload['linkedAt'] ?? '')),
|
||||
'status' => self::normalize((string) ($payload['status'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalize(string $status): string
|
||||
{
|
||||
$status = strtolower(trim($status));
|
||||
if (in_array($status, ['linked', 'success', 'completed'], true)) {
|
||||
return 'linked';
|
||||
}
|
||||
if (in_array($status, ['failed', 'error', 'rejected'], true)) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
public static function merge(string $currentStatus, string $incomingStatus): string
|
||||
{
|
||||
$currentStatus = self::normalize($currentStatus);
|
||||
$incomingStatus = self::normalize($incomingStatus);
|
||||
if ($currentStatus === 'linked' || ($currentStatus === 'failed' && $incomingStatus === 'pending')) {
|
||||
return $currentStatus;
|
||||
}
|
||||
|
||||
return $incomingStatus;
|
||||
}
|
||||
}
|
||||
|
|
@ -434,14 +434,26 @@ class AbdmGatewayPushClient
|
|||
}
|
||||
}
|
||||
|
||||
$error = $body['error'] ?? null;
|
||||
$responseError = $body['response']['error'] ?? null;
|
||||
$dataError = $body['data']['error'] ?? null;
|
||||
$errorCode = strtoupper(trim((string) (
|
||||
$body['error_code']
|
||||
?? (is_array($error) ? ($error['code'] ?? null) : $error)
|
||||
?? ($body['response']['error_code'] ?? null)
|
||||
?? ($body['data']['error_code'] ?? '')
|
||||
?? (is_array($responseError) ? ($responseError['code'] ?? null) : $responseError)
|
||||
?? ($body['data']['error_code'] ?? null)
|
||||
?? (is_array($dataError) ? ($dataError['code'] ?? null) : $dataError)
|
||||
?? ''
|
||||
)));
|
||||
$isDuplicate = $httpStatus === 409 || $errorCode === 'DUPLICATE_RECORD';
|
||||
$isDuplicate = $httpStatus === 409 && $errorCode === 'DUPLICATE_RECORD';
|
||||
$isSubmitted = $httpStatus === 201 || $isDuplicate;
|
||||
$message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus));
|
||||
$firstPushedAt = trim((string) (
|
||||
$body['first_pushed_at']
|
||||
?? ($body['response']['first_pushed_at'] ?? null)
|
||||
?? ($body['data']['first_pushed_at'] ?? '')
|
||||
));
|
||||
|
||||
// The bridge returns field-level FHIR errors here; without them a 422 is undiagnosable.
|
||||
$validationErrors = is_array($body['errors'] ?? null) ? $body['errors'] : [];
|
||||
|
|
@ -472,6 +484,7 @@ class AbdmGatewayPushClient
|
|||
'request_id' => $requestId,
|
||||
'gateway_record_id' => $recordId > 0 ? $recordId : null,
|
||||
'gateway_queue_id' => $queueId !== '' ? $queueId : null,
|
||||
'first_pushed_at' => $firstPushedAt !== '' ? $firstPushedAt : null,
|
||||
'gateway_record_source' => $recordSource,
|
||||
'gateway_queue_source' => $queueSource,
|
||||
'submitted' => $isSubmitted ? 1 : 0,
|
||||
|
|
@ -499,7 +512,7 @@ class AbdmGatewayPushClient
|
|||
];
|
||||
}
|
||||
|
||||
if ($httpStatus === 400 || $httpStatus === 401 || $httpStatus === 403 || $httpStatus === 422) {
|
||||
if ($httpStatus === 400 || $httpStatus === 401 || $httpStatus === 403 || $httpStatus === 409 || $httpStatus === 422) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'status' => 'failed',
|
||||
|
|
|
|||
|
|
@ -1689,6 +1689,10 @@ class FhirR4Builder
|
|||
(string) ($immunization['given_date'] ?? $immunization['occurrenceDateTime'] ?? $immunization['due_date'] ?? ''),
|
||||
$issuedAt
|
||||
);
|
||||
[$vaccineCodeSystem, $vaccineCode] = $this->normalizeVaccineCoding(
|
||||
(string) ($immunization['vaccine_code_system'] ?? ''),
|
||||
(string) ($immunization['vaccine_code'] ?? '')
|
||||
);
|
||||
|
||||
$resource = [
|
||||
'resourceType' => 'Immunization',
|
||||
|
|
@ -1700,8 +1704,8 @@ class FhirR4Builder
|
|||
]],
|
||||
'status' => $status,
|
||||
'vaccineCode' => $this->buildSimpleCodeableConcept(
|
||||
(string) ($immunization['vaccine_code_system'] ?? ''),
|
||||
(string) ($immunization['vaccine_code'] ?? ''),
|
||||
$vaccineCodeSystem,
|
||||
$vaccineCode,
|
||||
$vaccineName,
|
||||
$vaccineName
|
||||
),
|
||||
|
|
@ -1779,6 +1783,7 @@ class FhirR4Builder
|
|||
}
|
||||
|
||||
$immunizationRefs[] = ['reference' => $immunizationRef, 'display' => $vaccineName];
|
||||
$immunizationRefs[array_key_last($immunizationRefs)]['type'] = 'Immunization';
|
||||
$resourceEntries[] = ['fullUrl' => $immunizationRef, 'resource' => $resource];
|
||||
}
|
||||
|
||||
|
|
@ -1818,12 +1823,42 @@ class FhirR4Builder
|
|||
]],
|
||||
];
|
||||
if ($practitionerRef !== '') {
|
||||
$composition['author'] = [['reference' => $practitionerRef]];
|
||||
$composition['author'] = [[
|
||||
'reference' => $practitionerRef,
|
||||
'display' => trim((string) ($practitioner['name'] ?? '')),
|
||||
]];
|
||||
} elseif ($organizationRef !== '') {
|
||||
$composition['author'] = [['reference' => $organizationRef]];
|
||||
$composition['author'] = [[
|
||||
'reference' => $organizationRef,
|
||||
'display' => trim((string) ($organization['name'] ?? '')),
|
||||
]];
|
||||
}
|
||||
if ($organizationRef !== '') {
|
||||
$composition['custodian'] = [
|
||||
'reference' => $organizationRef,
|
||||
'display' => trim((string) ($organization['name'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$careContextReference = trim((string) ($context['care_context_reference'] ?? ('IMM-' . (string) ($patient['id'] ?? 'unknown') . '-' . date('YmdHis'))));
|
||||
$careContextReference = trim((string) ($context['care_context_reference'] ?? ''));
|
||||
if ($careContextReference === '') {
|
||||
$sourceIds = [];
|
||||
foreach ($immunizations as $immunization) {
|
||||
$sourceId = trim((string) ($immunization['id'] ?? ''));
|
||||
if ($sourceId !== '') {
|
||||
$sourceIds[] = $sourceId;
|
||||
}
|
||||
}
|
||||
$sourceIds = array_values(array_unique($sourceIds));
|
||||
sort($sourceIds, SORT_STRING);
|
||||
if (count($sourceIds) === 1) {
|
||||
$careContextReference = 'IMM-' . $sourceIds[0];
|
||||
} elseif ($sourceIds !== []) {
|
||||
$careContextReference = 'IMM-SET-' . substr(hash('sha256', implode(',', $sourceIds)), 0, 20);
|
||||
} else {
|
||||
$careContextReference = 'IMM-PAT-' . trim((string) ($patient['id'] ?? 'unknown'));
|
||||
}
|
||||
}
|
||||
$hfrId = trim((string) ($organization['hfr_id'] ?? $organization['id'] ?? ''));
|
||||
|
||||
return [
|
||||
|
|
@ -1857,6 +1892,34 @@ class FhirR4Builder
|
|||
};
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function normalizeVaccineCoding(string $system, string $code): array
|
||||
{
|
||||
$system = trim($system);
|
||||
$code = trim($code);
|
||||
if ($system !== 'https://hms.local/immunization/uip') {
|
||||
return [$system, $code];
|
||||
}
|
||||
|
||||
$cvxCodes = [
|
||||
'UIP-BCG' => '19',
|
||||
'UIP-OPV' => '02',
|
||||
'UIP-HEPB' => '45',
|
||||
'UIP-ROTA' => '122',
|
||||
'UIP-FIPV' => '10',
|
||||
'UIP-PCV' => '152',
|
||||
'UIP-MR' => '04',
|
||||
'UIP-JE' => '39',
|
||||
'UIP-DPT' => '01',
|
||||
'UIP-TD' => '09',
|
||||
];
|
||||
if (! isset($cvxCodes[$code])) {
|
||||
return [$system, $code];
|
||||
}
|
||||
|
||||
return ['http://hl7.org/fhir/sid/cvx', $cvxCodes[$code]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,22 +17,63 @@
|
|||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">ABDM Work Task Board</h4>
|
||||
<div class="small text-muted">Unupdated ABHA Patient List + Separate ABDM Task Queues</div>
|
||||
<div class="small text-muted">ABDM operational dashboard and work queues</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="btnRefresh">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-3" id="taskFilters">
|
||||
<button class="btn btn-sm btn-primary filter-btn" data-filter="patient_abha_create">Unupdated ABHA</button>
|
||||
<button class="btn btn-sm btn-outline-primary filter-btn" data-filter="patient_abha_update">ABHA Verify Pending</button>
|
||||
<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="ipd_admission_publish">IPD Admission</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>
|
||||
</div>
|
||||
|
||||
<?php $dashboard = $dashboard_metrics ?? []; ?>
|
||||
<div id="dashboardCard">
|
||||
<form method="get" action="<?= current_url() ?>" class="d-flex flex-wrap align-items-end gap-2 mb-3" id="dashboardRangeForm">
|
||||
<div>
|
||||
<label class="form-label small mb-1" for="dashboardDateFrom">From</label>
|
||||
<input type="date" class="form-control form-control-sm" id="dashboardDateFrom" name="date_from" value="<?= esc((string) ($dashboard_date_from ?? date('Y-m-d'))) ?>">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small mb-1" for="dashboardDateTo">To</label>
|
||||
<input type="date" class="form-control form-control-sm" id="dashboardDateTo" name="date_to" value="<?= esc((string) ($dashboard_date_to ?? date('Y-m-d'))) ?>">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Apply Date Range</button>
|
||||
</form>
|
||||
<div class="row g-3 mb-3">
|
||||
<?php foreach ([
|
||||
['Total Patients', 'total_patients', 'primary'],
|
||||
['Patients Without ABHA', 'without_abha', 'danger'],
|
||||
['ABHA Verified', 'abha_verified', 'success'],
|
||||
['Verified In Range', 'verified_in_range', 'info'],
|
||||
['Records Pushed', 'records_pushed', 'warning'],
|
||||
['Pushed In Range', 'records_pushed_in_range', 'primary'],
|
||||
['OPD Tokens', 'opd_tokens', 'secondary'],
|
||||
['Tokens In Range', 'opd_tokens_in_range', 'success'],
|
||||
] as [$label, $key, $tone]): ?>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="card h-100 border-start border-4 border-<?= esc($tone) ?>">
|
||||
<div class="card-body py-3">
|
||||
<div class="small text-muted"><?= esc($label) ?></div>
|
||||
<div class="fs-3 fw-semibold"><?= number_format((int) ($dashboard[$key] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php if (($dashboard['verification_date_source'] ?? '') === 'abdm_linked_at'): ?>
|
||||
<div class="small text-muted mb-3">Date-range verification uses the stored ABHA link/verification timestamp.</div>
|
||||
<?php elseif (($dashboard['verification_date_source'] ?? '') === 'last_update'): ?>
|
||||
<div class="small text-muted mb-3">Date-range verification uses the patient last-updated date because an ABHA timestamp is not available.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3 d-none" id="fhirDebugCard">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
|
|
@ -82,7 +123,9 @@
|
|||
$opdSessionId = (int) ($meta['opd_session_id'] ?? 0);
|
||||
$showSandbox = ($type === 'opd_prescription_publish' && $opdId > 0);
|
||||
$showPreview = ($type === 'opd_prescription_publish' && $opdId > 0)
|
||||
|| in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish'], true);
|
||||
|| in_array($type, ['lab_report_publish', 'radiology_report_publish', 'ipd_discharge_publish', 'immunization_record_publish'], true);
|
||||
$bridgeSubmitted = (int) ($t['bridge_submitted'] ?? 0) === 1;
|
||||
$bridgeCareContext = trim((string) ($t['bridge_care_context_reference'] ?? ''));
|
||||
?>
|
||||
<tr
|
||||
data-task-id="<?= (int) ($t['id'] ?? 0) ?>"
|
||||
|
|
@ -93,6 +136,7 @@
|
|||
data-opd-id="<?= $opdId ?>"
|
||||
data-opd-session-id="<?= $opdSessionId ?>"
|
||||
data-sandbox-eligible="<?= $showSandbox ? '1' : '0' ?>"
|
||||
data-bridge-submitted="<?= $bridgeSubmitted ? '1' : '0' ?>"
|
||||
>
|
||||
<td><?= (int) ($t['id'] ?? 0) ?></td>
|
||||
<td>
|
||||
|
|
@ -108,13 +152,28 @@
|
|||
<div><?= esc((string) ($t['entity_type'] ?? '')) ?></div>
|
||||
<div class="text-muted small"><?= esc((string) ($t['entity_id'] ?? '')) ?></div>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary status-pill"><?= esc((string) ($t['status'] ?? 'pending')) ?></span></td>
|
||||
<td>
|
||||
<?php if ($bridgeSubmitted && strtolower((string) ($t['bridge_push_status'] ?? '')) === 'linked'): ?>
|
||||
<span class="badge bg-success status-pill">LINKED</span>
|
||||
<?php elseif ($bridgeSubmitted): ?>
|
||||
<span class="badge bg-info status-pill">BRIDGE SUBMITTED</span>
|
||||
<?php if ($bridgeCareContext !== ''): ?><div class="small"><code><?= esc($bridgeCareContext) ?></code></div><?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary status-pill"><?= esc((string) ($t['status'] ?? 'pending')) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<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>
|
||||
<?php elseif ($type === 'patient_abha_update'): ?>
|
||||
<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>
|
||||
<?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>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-warning action-btn" data-action="submit">Submit</button>
|
||||
<?php endif; ?>
|
||||
|
|
@ -222,7 +281,7 @@
|
|||
<div class="card shadow-sm mt-3 d-none" id="opdBookCard">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<strong>OPD Book — ABDM Patients</strong>
|
||||
<strong>OPD Book — ABDM Tokens</strong>
|
||||
<span class="badge bg-primary ms-2" id="hmsOpdBadge"><?= count($opd_book_rows ?? []) ?></span>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
|
|
@ -245,29 +304,30 @@
|
|||
</table>
|
||||
</div>
|
||||
<div class="px-3 pt-2 pb-1 bg-light border-bottom border-top d-flex align-items-center gap-2 mt-1">
|
||||
<strong class="small">HMS Direct OPD with ABHA</strong>
|
||||
<strong class="small">HMS ABDM OPD Token List</strong>
|
||||
<span class="badge bg-success"><?= count($opd_book_rows ?? []) ?></span>
|
||||
<span class="text-muted small">Last 7 days · patient has 14-digit ABHA</span>
|
||||
<span class="text-muted small">Last 7 days · synced and manual ABDM tokens only</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>OPD ID</th><th>Patient</th><th>ABHA</th><th>Date</th><th>Doctor</th><th>Status</th></tr>
|
||||
<tr><th>Token</th><th>Patient</th><th>ABHA</th><th>Date</th><th>Source</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (! empty($opd_book_rows ?? [])): ?>
|
||||
<?php foreach (($opd_book_rows ?? []) as $r): ?>
|
||||
<tr>
|
||||
<td>#<?= (int) ($r['opd_id'] ?? 0) ?></td>
|
||||
<td><?= esc((string) ($r['P_name'] ?? '')) ?></td>
|
||||
<td><span class="text-primary small"><?= esc((string) ($r['abha_id'] ?? '')) ?></span></td>
|
||||
<td><?= esc(substr((string) ($r['apointment_date'] ?? ''), 0, 16)) ?></td>
|
||||
<td><?= esc((string) ($r['doc_name'] ?? '')) ?></td>
|
||||
<td><span class="badge bg-<?= (int) ($r['opd_status'] ?? 0) >= 2 ? 'success' : 'warning text-dark' ?>"><?= (int) ($r['opd_status'] ?? 0) >= 2 ? 'Done' : 'Pending' ?></span></td>
|
||||
<td><?= esc((string) (($r['token_number'] ?? '') ?: ('#' . ($r['gateway_token_id'] ?? '')))) ?></td>
|
||||
<td><?= esc((string) ($r['patient_name'] ?? '')) ?></td>
|
||||
<td><span class="text-primary small"><?= esc((string) (($r['abha_number'] ?? '') ?: ($r['abha_address'] ?? ''))) ?></span></td>
|
||||
<td><?= esc((string) ($r['queue_date'] ?? '')) ?></td>
|
||||
<td><?= esc(ucwords(str_replace('_', ' ', (string) ($r['source'] ?? '')))) ?></td>
|
||||
<?php $tokenStatus = strtoupper((string) ($r['status'] ?? 'PENDING')); ?>
|
||||
<td><span class="badge bg-<?= $tokenStatus === 'COMPLETED' ? 'success' : ($tokenStatus === 'CANCELLED' ? 'danger' : 'warning text-dark') ?>"><?= esc($tokenStatus) ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr><td colspan="6" class="text-muted px-2 py-2">No OPD with ABHA found in last 7 days.</td></tr>
|
||||
<tr><td colspan="6" class="text-muted px-2 py-2">No ABDM OPD tokens found in the last 7 days.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -366,6 +426,72 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Status Card -->
|
||||
<div class="card shadow-sm mt-3 d-none" id="invoiceCard">
|
||||
<div class="card-header py-2 d-flex align-items-center gap-2 flex-wrap">
|
||||
<strong>Invoice — ABDM Billing Records</strong>
|
||||
<span class="badge bg-primary"><?= count($invoice_rows ?? []) ?></span>
|
||||
<small class="text-muted">OPD, Charges and IPD Billing</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Source</th><th>Bill No.</th><th>Patient</th><th>Date</th><th class="text-end">Amount</th><th>Push Status</th><th>Link Status</th><th>Gateway Reference</th><th>Action</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (! empty($invoice_rows ?? [])): ?>
|
||||
<?php foreach (($invoice_rows ?? []) as $invoice): ?>
|
||||
<?php
|
||||
$tone = (string) ($invoice['record_status_tone'] ?? 'secondary');
|
||||
if (! in_array($tone, ['secondary', 'primary', 'info', 'success', 'warning', 'danger'], true)) {
|
||||
$tone = 'secondary';
|
||||
}
|
||||
$badgeClass = 'badge bg-' . $tone . ($tone === 'warning' ? ' text-dark' : '');
|
||||
$queueId = trim((string) ($invoice['queue_id'] ?? ''));
|
||||
$bridgeRecordId = (int) ($invoice['bridge_record_id'] ?? 0);
|
||||
$careContext = trim((string) ($invoice['care_context_reference'] ?? ''));
|
||||
$pushStatus = strtolower(trim((string) ($invoice['push_status'] ?? '')));
|
||||
$linkStatus = strtolower(trim((string) ($invoice['link_status'] ?? '')));
|
||||
$alreadySubmitted = in_array($pushStatus, ['queued', 'pushed', 'linked'], true) || $linkStatus === 'linked';
|
||||
?>
|
||||
<tr
|
||||
data-invoice-source="<?= esc((string) ($invoice['source_key'] ?? '')) ?>"
|
||||
data-bill-id="<?= (int) ($invoice['bill_id'] ?? 0) ?>"
|
||||
data-patient-id="<?= (int) ($invoice['patient_id'] ?? 0) ?>"
|
||||
data-push-status="<?= esc($pushStatus) ?>"
|
||||
>
|
||||
<td><?= esc((string) ($invoice['source'] ?? '')) ?></td>
|
||||
<td><strong><?= esc((string) (($invoice['bill_code'] ?? '') ?: ('#' . ($invoice['bill_id'] ?? '')))) ?></strong></td>
|
||||
<td>
|
||||
<div><?= esc((string) ($invoice['patient_name'] ?? '')) ?></div>
|
||||
<small class="text-muted">#<?= (int) ($invoice['patient_id'] ?? 0) ?></small>
|
||||
</td>
|
||||
<td><?= esc(substr((string) ($invoice['bill_date'] ?? ''), 0, 16)) ?></td>
|
||||
<td class="text-end"><?= number_format((float) ($invoice['amount'] ?? 0), 2) ?></td>
|
||||
<td><span class="<?= esc($badgeClass) ?> invoice-status-badge"><?= esc((string) ($invoice['record_status_label'] ?? 'Not Pushed')) ?></span></td>
|
||||
<td><?= $invoice['link_status'] !== '' ? esc(strtoupper((string) $invoice['link_status'])) : '<span class="text-muted">-</span>' ?></td>
|
||||
<td class="small text-muted invoice-details" style="min-width:190px;max-width:300px;">
|
||||
<?php if ($queueId !== ''): ?><div><strong>Queue:</strong> <?= esc($queueId) ?></div><?php endif; ?>
|
||||
<?php if ($bridgeRecordId > 0): ?><div><strong>Bridge:</strong> #<?= $bridgeRecordId ?></div><?php endif; ?>
|
||||
<?php if ($careContext !== ''): ?><div class="text-truncate" title="<?= esc($careContext) ?>"><strong>CC:</strong> <?= esc($careContext) ?></div><?php endif; ?>
|
||||
<?php if ($queueId === '' && $bridgeRecordId <= 0 && $careContext === ''): ?><span>-</span><?php endif; ?>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-invoice-preview">Preview</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning btn-invoice-push" <?= $alreadySubmitted ? 'disabled' : '' ?>>Push</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr><td colspan="9" class="text-muted px-2 py-2">No billing records found.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mt-2" id="statusBox">Ready</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -499,19 +625,29 @@
|
|||
|
||||
var opdBookCard = document.getElementById('opdBookCard');
|
||||
var opdConsultCard = document.getElementById('opdConsultCard');
|
||||
var invoiceCard = document.getElementById('invoiceCard');
|
||||
var taskTableCard = document.getElementById('taskTableCard');
|
||||
var dashboardCard = document.getElementById('dashboardCard');
|
||||
|
||||
function applyFilter(filter) {
|
||||
// Hide dedicated section cards
|
||||
if (opdBookCard) opdBookCard.classList.add('d-none');
|
||||
if (opdConsultCard) opdConsultCard.classList.add('d-none');
|
||||
if (invoiceCard) invoiceCard.classList.add('d-none');
|
||||
if (dashboardCard) dashboardCard.classList.add('d-none');
|
||||
|
||||
if (filter === 'opd_book') {
|
||||
if (filter === 'dashboard') {
|
||||
if (taskTableCard) taskTableCard.classList.add('d-none');
|
||||
if (dashboardCard) dashboardCard.classList.remove('d-none');
|
||||
} else if (filter === 'opd_book') {
|
||||
if (taskTableCard) taskTableCard.classList.add('d-none');
|
||||
if (opdBookCard) opdBookCard.classList.remove('d-none');
|
||||
} else if (filter === 'opd_consult_publish') {
|
||||
if (taskTableCard) taskTableCard.classList.add('d-none');
|
||||
if (opdConsultCard) opdConsultCard.classList.remove('d-none');
|
||||
} else if (filter === 'invoice') {
|
||||
if (taskTableCard) taskTableCard.classList.add('d-none');
|
||||
if (invoiceCard) invoiceCard.classList.remove('d-none');
|
||||
} else {
|
||||
if (taskTableCard) taskTableCard.classList.remove('d-none');
|
||||
var rows = document.querySelectorAll('#taskTable tbody tr');
|
||||
|
|
@ -946,6 +1082,20 @@
|
|||
+ '&patient_id=' + encodeURIComponent(ipdPatientId)
|
||||
+ '&abha_id=' + encodeURIComponent(abhaId);
|
||||
title = 'IPD Discharge FHIR Preview — IPD #' + ipdId;
|
||||
} else if (taskType === 'immunization_record_publish') {
|
||||
var immunizationRecordId = parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0;
|
||||
var immunizationPatientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0;
|
||||
|
||||
if (immunizationRecordId <= 0 || immunizationPatientId <= 0) {
|
||||
setStatus('Task data missing for Immunization FHIR preview.', true);
|
||||
return true;
|
||||
}
|
||||
|
||||
previewUrl = '<?= base_url('AbdmGateway/immunization_fhir_preview') ?>'
|
||||
+ '?record_id=' + encodeURIComponent(immunizationRecordId)
|
||||
+ '&patient_id=' + encodeURIComponent(immunizationPatientId)
|
||||
+ '&abha_id=' + encodeURIComponent(abhaId);
|
||||
title = 'ImmunizationRecord FHIR Preview — Record #' + immunizationRecordId;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -986,6 +1136,50 @@
|
|||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.immunization-bridge-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var row = btn.closest('tr');
|
||||
var abhaInput = row ? row.querySelector('.abha-input') : null;
|
||||
var abhaId = (abhaInput ? abhaInput.value : '').trim();
|
||||
var patientId = row ? (parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0) : 0;
|
||||
var recordId = row ? (parseInt(row.getAttribute('data-entity-id') || '0', 10) || 0) : 0;
|
||||
var taskId = row ? (parseInt(row.getAttribute('data-task-id') || '0', 10) || 0) : 0;
|
||||
|
||||
if (!/^\d{14}$/.test(abhaId) || patientId <= 0 || recordId <= 0) {
|
||||
setStatus('A 14-digit ABHA and valid Immunization record are required.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
var original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Pushing';
|
||||
post('<?= base_url('AbdmGateway/share_immunization_bundle') ?>', {
|
||||
task_id: taskId,
|
||||
patient_id: patientId,
|
||||
record_id: recordId,
|
||||
abha_id: abhaId,
|
||||
push_to_gateway: 1
|
||||
}, function (res) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
if (!res || parseInt(res.ok || '0', 10) !== 1) {
|
||||
setStatus((res && (res.error_text || res.error || res.message)) || 'ABDM Bridge push failed.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
var badge = row.querySelector('.status-pill');
|
||||
if (badge) {
|
||||
badge.textContent = 'BRIDGE SUBMITTED';
|
||||
badge.className = 'badge bg-info status-pill';
|
||||
}
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitted to Bridge';
|
||||
btn.title = 'Submitted to ABDM Bridge: ' + (res.care_context_reference || '-');
|
||||
setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyFilter(btn.getAttribute('data-filter') || 'all');
|
||||
|
|
@ -1032,7 +1226,7 @@
|
|||
});
|
||||
});
|
||||
|
||||
applyFilter('patient_abha_create');
|
||||
applyFilter('dashboard');
|
||||
|
||||
modalConfirmBtn.addEventListener('click', function () {
|
||||
if (!selectedRow || !selectedAction) {
|
||||
|
|
@ -1468,6 +1662,9 @@
|
|||
var _fhirSubmitMode = 'opd';
|
||||
var _fhirSubmitTaskRow = null;
|
||||
var _fhirSubmitAbha = '';
|
||||
var _fhirInvoiceSource = '';
|
||||
var _fhirInvoiceBillId = 0;
|
||||
var _fhirInvoicePatientId = 0;
|
||||
|
||||
// ── Inline complaint SNOMED edit (event delegation on fhirFormView) ─
|
||||
var _editTimer = null;
|
||||
|
|
@ -1775,10 +1972,16 @@
|
|||
liveDiagnoses = liveDiagnoses || [];
|
||||
var resources = (bundle.entry || []).map(function(e) { return e.resource; }).filter(Boolean);
|
||||
function byType(t) { return resources.filter(function(r){ return r.resourceType === t; }); }
|
||||
var resourcesByUrl = {};
|
||||
(bundle.entry || []).forEach(function(entry) {
|
||||
if (entry.fullUrl && entry.resource) resourcesByUrl[entry.fullUrl] = entry.resource;
|
||||
});
|
||||
var composition = byType('Composition')[0];
|
||||
var patient = byType('Patient')[0];
|
||||
var encounter = byType('Encounter')[0];
|
||||
var practitioner = byType('Practitioner')[0];
|
||||
var organization = byType('Organization')[0];
|
||||
var invoice = byType('Invoice')[0];
|
||||
var allConditions = byType('Condition');
|
||||
var observations = byType('Observation');
|
||||
var medications = byType('MedicationRequest');
|
||||
|
|
@ -1786,6 +1989,24 @@
|
|||
var serviceReqs = byType('ServiceRequest');
|
||||
var allergies = byType('AllergyIntolerance');
|
||||
var carePlans = byType('CarePlan');
|
||||
var documentRefs = byType('DocumentReference');
|
||||
var immunizations = byType('Immunization');
|
||||
|
||||
(window._abdmFhirObjectUrls || []).forEach(function(url) { URL.revokeObjectURL(url); });
|
||||
window._abdmFhirObjectUrls = [];
|
||||
function attachmentObjectUrl(attachment) {
|
||||
if (!attachment || !attachment.data) return '';
|
||||
try {
|
||||
var bytes = atob(attachment.data);
|
||||
var buffer = new Uint8Array(bytes.length);
|
||||
for (var i = 0; i < bytes.length; i++) buffer[i] = bytes.charCodeAt(i);
|
||||
var url = URL.createObjectURL(new Blob([buffer], { type: attachment.contentType || 'application/octet-stream' }));
|
||||
window._abdmFhirObjectUrls.push(url);
|
||||
return url;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
var coursePlans = [];
|
||||
var advicePlans = [];
|
||||
|
|
@ -1830,6 +2051,117 @@
|
|||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// ── Invoice Record document metadata
|
||||
if (composition && invoice) {
|
||||
var compositionCoding = ((((composition.type || {}).coding) || [])[0]) || {};
|
||||
var compositionIdentifier = (composition.identifier || {}).value || '';
|
||||
var bundleIdentifier = (bundle.identifier || {}).value || '';
|
||||
html += '<div class="card mb-2"><div class="card-header py-1 bg-light"><small class="fw-bold text-uppercase text-secondary">Invoice Record</small></div>';
|
||||
html += '<div class="card-body py-2 small"><div class="row g-2">';
|
||||
html += '<div class="col-md-4"><span class="text-muted">Document</span><div class="fw-semibold">' + hesc(composition.title || 'Invoice Record') + '</div></div>';
|
||||
html += '<div class="col-md-3"><span class="text-muted">Status</span><div class="text-capitalize">' + hesc(composition.status || '-') + '</div></div>';
|
||||
html += '<div class="col-md-5"><span class="text-muted">Created</span><div>' + hesc(composition.date || bundle.timestamp || '-') + '</div></div>';
|
||||
html += '<div class="col-md-4"><span class="text-muted">Composition Type</span><div>' + hesc(compositionCoding.display || (composition.type || {}).text || '-') + '</div></div>';
|
||||
html += '<div class="col-md-3"><span class="text-muted">LOINC</span><div><code>' + hesc(compositionCoding.code || '-') + '</code></div></div>';
|
||||
html += '<div class="col-md-5"><span class="text-muted">Bundle / Care Context Identifier</span><div><code>' + hesc(bundleIdentifier || compositionIdentifier || '-') + '</code></div></div>';
|
||||
html += '</div></div></div>';
|
||||
}
|
||||
|
||||
// ── Invoice details and line items
|
||||
if (invoice) {
|
||||
var invoiceIdentifier = ((invoice.identifier || [])[0] || {}).value || '';
|
||||
var invoiceCoding = ((((invoice.type || {}).coding) || [])[0]) || {};
|
||||
var totalRate = (((invoice.totalPriceComponent || [])[0] || {}).amount || {});
|
||||
var totalNet = invoice.totalNet || {};
|
||||
var totalGross = invoice.totalGross || {};
|
||||
function money(amount) {
|
||||
if (amount.value === undefined || amount.value === null || amount.value === '') return '-';
|
||||
var numeric = Number(amount.value);
|
||||
return (amount.currency || 'INR') + ' ' + (isNaN(numeric) ? hesc(amount.value) : numeric.toFixed(2));
|
||||
}
|
||||
html += '<div class="card mb-2"><div class="card-header py-1 bg-light d-flex justify-content-between align-items-center"><small class="fw-bold text-uppercase text-secondary">Invoice Details</small>';
|
||||
html += '<span class="badge bg-success text-capitalize">' + hesc(invoice.status || 'issued') + '</span></div><div class="card-body py-2">';
|
||||
html += '<div class="row g-2 small mb-3">';
|
||||
html += '<div class="col-md-4"><span class="text-muted">Invoice Number</span><div class="fw-bold">' + hesc(invoiceIdentifier || invoice.id || '-') + '</div></div>';
|
||||
html += '<div class="col-md-3"><span class="text-muted">Invoice Type</span><div>' + hesc(invoiceCoding.display || (invoice.type || {}).text || '-') + (invoiceCoding.code ? ' <code>' + hesc(invoiceCoding.code) + '</code>' : '') + '</div></div>';
|
||||
html += '<div class="col-md-2"><span class="text-muted">Invoice Date</span><div>' + hesc(fmtDate(invoice.date) || '-') + '</div></div>';
|
||||
html += '<div class="col-md-3"><span class="text-muted">Total Price Component</span><div class="fw-semibold">' + money(totalRate) + '</div></div>';
|
||||
html += '<div class="col-md-3"><span class="text-muted">Net Total</span><div class="fw-semibold">' + money(totalNet) + '</div></div>';
|
||||
html += '<div class="col-md-3"><span class="text-muted">Gross Total</span><div class="fw-semibold">' + money(totalGross) + '</div></div>';
|
||||
html += '</div>';
|
||||
var invoiceItems = invoice.lineItem || [];
|
||||
if (invoiceItems.length) {
|
||||
html += '<div class="table-responsive"><table class="table table-sm table-bordered align-middle mb-0"><thead class="table-light"><tr><th style="width:55px">#</th><th>Item</th><th>Component</th><th class="text-end">Amount</th></tr></thead><tbody>';
|
||||
invoiceItems.forEach(function(item, index) {
|
||||
var component = ((item.priceComponent || [])[0]) || {};
|
||||
var componentCoding = ((((component.code || {}).coding) || [])[0]) || {};
|
||||
var chargeReference = (item.chargeItemReference || {}).reference || '';
|
||||
var chargeItem = resourcesByUrl[chargeReference] || {};
|
||||
var itemName = (chargeItem.productCodeableConcept || {}).text
|
||||
|| (chargeItem.code || {}).text
|
||||
|| (item.chargeItemReference || {}).display
|
||||
|| (item.chargeItemCodeableConcept || {}).text
|
||||
|| 'Item';
|
||||
var itemQuantity = chargeItem.quantity || {};
|
||||
html += '<tr><td>' + hesc(item.sequence || index + 1) + '</td>';
|
||||
html += '<td><div class="fw-semibold">' + hesc(itemName) + '</div>';
|
||||
if (itemQuantity.value !== undefined) html += '<small class="text-muted">Qty: ' + hesc(itemQuantity.value) + ' ' + hesc(itemQuantity.unit || '') + '</small>';
|
||||
html += '</td>';
|
||||
html += '<td>' + hesc(componentCoding.display || component.type || '-') + (componentCoding.code ? ' <code>' + hesc(componentCoding.code) + '</code>' : '') + '</td>';
|
||||
html += '<td class="text-end">' + money(component.amount || {}) + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
} else {
|
||||
html += '<div class="text-muted small">No invoice line items found.</div>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// ── Embedded invoice PDF
|
||||
documentRefs.forEach(function(documentReference) {
|
||||
var attachment = (((documentReference.content || [])[0] || {}).attachment) || {};
|
||||
if (attachment.contentType !== 'application/pdf') return;
|
||||
var pdfUrl = attachmentObjectUrl(attachment);
|
||||
html += '<div class="card mb-2"><div class="card-body py-2 d-flex flex-wrap align-items-center gap-2">';
|
||||
html += '<div class="flex-grow-1"><div class="fw-semibold">' + hesc(attachment.title || 'Invoice PDF') + '</div>';
|
||||
html += '<small class="text-muted">Attached to this FHIR InvoiceRecord</small></div>';
|
||||
if (pdfUrl) {
|
||||
html += '<a class="btn btn-sm btn-outline-primary" href="' + hesc(pdfUrl) + '" target="_blank" rel="noopener">Open PDF</a>';
|
||||
html += '<a class="btn btn-sm btn-primary" href="' + hesc(pdfUrl) + '" download="' + hesc(attachment.title || 'invoice.pdf') + '">Download</a>';
|
||||
} else {
|
||||
html += '<span class="badge bg-danger">Invalid PDF attachment</span>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
// ── Immunization Record
|
||||
if (immunizations.length) {
|
||||
html += '<div class="card mb-2"><div class="card-header py-1 bg-light"><small class="fw-bold text-uppercase text-secondary">Vaccination Details</small></div>';
|
||||
html += '<div class="table-responsive"><table class="table table-sm table-bordered align-middle mb-0"><thead class="table-light"><tr>';
|
||||
html += '<th>Vaccine</th><th>Date</th><th>Doctor / Facility</th><th>Dose / Series</th><th>Lot</th><th>Target Disease</th></tr></thead><tbody>';
|
||||
immunizations.forEach(function(immunization) {
|
||||
var vaccineCoding = ((((immunization.vaccineCode || {}).coding) || [])[0]) || {};
|
||||
var performerReference = ((((immunization.performer || [])[0] || {}).actor) || {});
|
||||
var performerResource = resourcesByUrl[performerReference.reference || ''] || {};
|
||||
var performerName = performerReference.display || (((performerResource.name || [])[0] || {}).text) || '-';
|
||||
var locationReference = immunization.location || {};
|
||||
var locationResource = resourcesByUrl[locationReference.reference || ''] || {};
|
||||
var facilityName = locationReference.display || locationResource.name || '-';
|
||||
var protocol = ((immunization.protocolApplied || [])[0]) || {};
|
||||
var targetDisease = ((protocol.targetDisease || [])[0]) || {};
|
||||
var targetCoding = (((targetDisease.coding || [])[0]) || {});
|
||||
var dose = protocol.doseNumberPositiveInt || protocol.doseNumberString || '-';
|
||||
html += '<tr><td><div class="fw-semibold">' + hesc((immunization.vaccineCode || {}).text || vaccineCoding.display || '-') + '</div>';
|
||||
if (vaccineCoding.code) html += '<small class="text-muted">' + hesc(vaccineCoding.system || 'Code') + ': <code>' + hesc(vaccineCoding.code) + '</code></small>';
|
||||
html += '</td><td>' + hesc(fmtDate(immunization.occurrenceDateTime) || '-') + '</td>';
|
||||
html += '<td><div>' + hesc(performerName) + '</div><small class="text-muted">' + hesc(facilityName) + '</small></td>';
|
||||
html += '<td><div>Dose ' + hesc(dose) + '</div><small class="text-muted">' + hesc(protocol.series || '-') + '</small></td>';
|
||||
html += '<td>' + hesc(immunization.lotNumber || '-') + '</td>';
|
||||
html += '<td>' + hesc(targetDisease.text || targetCoding.display || '-') + (targetCoding.code ? '<br><code>' + hesc(targetCoding.code) + '</code>' : '') + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div></div>';
|
||||
}
|
||||
|
||||
// ── Complaints
|
||||
if (complaints.length) {
|
||||
html += '<div class="card mb-2"><div class="card-header py-1 bg-light"><small class="fw-bold text-uppercase text-secondary">Chief Complaints</small></div><div class="card-body py-2">';
|
||||
|
|
@ -2206,6 +2538,9 @@
|
|||
_fhirSubmitMode = options.submitMode || 'opd';
|
||||
_fhirSubmitTaskRow = options.taskRow || null;
|
||||
_fhirSubmitAbha = (options.abhaId || '').trim();
|
||||
_fhirInvoiceSource = (options.invoiceSource || '').trim();
|
||||
_fhirInvoiceBillId = parseInt(options.invoiceBillId || '0', 10) || 0;
|
||||
_fhirInvoicePatientId = parseInt(options.invoicePatientId || '0', 10) || 0;
|
||||
var rowPushStatus = (options.pushStatus || '').toString().trim().toLowerCase();
|
||||
|
||||
_fhirOpdId = 0; _fhirSessionId = 0;
|
||||
|
|
@ -2230,6 +2565,15 @@
|
|||
btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-warning';
|
||||
btnSubmitFhirToAbdm.innerHTML = '<i class="bi bi-cloud-upload"></i> Submit to Gateway';
|
||||
btnSubmitFhirToAbdm.title = 'Force gateway push for this OPD consult';
|
||||
} else if (_fhirSubmitMode === 'invoice') {
|
||||
btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-warning';
|
||||
btnSubmitFhirToAbdm.innerHTML = '<i class="bi bi-cloud-upload"></i> Push Invoice';
|
||||
btnSubmitFhirToAbdm.title = 'Push this InvoiceRecord to the ABDM gateway';
|
||||
if (['linked', 'queued', 'pushed'].indexOf(rowPushStatus) !== -1) {
|
||||
btnSubmitFhirToAbdm.disabled = true;
|
||||
btnSubmitFhirToAbdm.className = 'btn btn-sm btn-outline-secondary';
|
||||
btnSubmitFhirToAbdm.innerHTML = '<i class="bi bi-check2-circle"></i> Already Submitted';
|
||||
}
|
||||
} else {
|
||||
btnSubmitFhirToAbdm.className = defaultSubmitBtnClass;
|
||||
btnSubmitFhirToAbdm.innerHTML = defaultSubmitBtnHtml;
|
||||
|
|
@ -2244,7 +2588,14 @@
|
|||
}
|
||||
|
||||
if (btnRegenerateFhirModal) {
|
||||
btnRegenerateFhirModal.disabled = false;
|
||||
btnRegenerateFhirModal.style.display = _fhirSubmitMode === 'task' ? 'none' : '';
|
||||
btnRegenerateFhirModal.innerHTML = _fhirSubmitMode === 'invoice'
|
||||
? '<i class="bi bi-arrow-clockwise"></i> Rebuild Bundle'
|
||||
: '<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';
|
||||
}
|
||||
|
||||
fhirPreviewModal.show();
|
||||
|
|
@ -2281,6 +2632,25 @@
|
|||
// Regenerate FHIR bundle in modal
|
||||
btnRegenerateFhirModal.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
if (_fhirSubmitMode === 'invoice') {
|
||||
if (!_fhirInvoiceSource || _fhirInvoiceBillId <= 0 || _fhirInvoicePatientId <= 0) {
|
||||
alert('Invoice source data is incomplete.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Rebuild this FHIR InvoiceRecord from current billing data?')) return;
|
||||
var invoiceUrl = '<?= base_url('AbdmGateway/invoice_fhir_preview') ?>'
|
||||
+ '?source=' + encodeURIComponent(_fhirInvoiceSource)
|
||||
+ '&bill_id=' + encodeURIComponent(String(_fhirInvoiceBillId))
|
||||
+ '&patient_id=' + encodeURIComponent(String(_fhirInvoicePatientId));
|
||||
openFhirModal(invoiceUrl, fhirModalTitle ? fhirModalTitle.textContent : 'FHIR Invoice Preview', {
|
||||
submitMode: 'invoice',
|
||||
taskRow: _fhirSubmitTaskRow,
|
||||
invoiceSource: _fhirInvoiceSource,
|
||||
invoiceBillId: _fhirInvoiceBillId,
|
||||
invoicePatientId: _fhirInvoicePatientId
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_fhirOpdId <= 0) { alert('No FHIR bundle loaded.'); return; }
|
||||
if (!confirm('Rebuild FHIR bundle for OPD #' + _fhirOpdId + '?\nThis overwrites the stored bundle with fresh data.')) return;
|
||||
var origHtml = btn.innerHTML;
|
||||
|
|
@ -2338,6 +2708,61 @@
|
|||
btnSubmitFhirToAbdm.addEventListener('click', function () {
|
||||
var btn = this;
|
||||
|
||||
if (_fhirSubmitMode === 'invoice') {
|
||||
if (!_fhirInvoiceSource || _fhirInvoiceBillId <= 0 || _fhirInvoicePatientId <= 0) {
|
||||
alert('Invoice source data is incomplete.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Push this verified InvoiceRecord to the ABDM gateway now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var originalInvoiceHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Pushing...';
|
||||
post('<?= base_url('AbdmGateway/share_invoice_source_bundle') ?>', {
|
||||
source: _fhirInvoiceSource,
|
||||
bill_id: _fhirInvoiceBillId,
|
||||
patient_id: _fhirInvoicePatientId,
|
||||
consent_handle: ''
|
||||
}, function (res) {
|
||||
if (!res || parseInt(res.ok || '0', 10) !== 1) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalInvoiceHtml;
|
||||
var errorText = (res && (res.error_text || res.error || res.message)) || 'Invoice push failed';
|
||||
setStatus(errorText, true);
|
||||
alert('Invoice push failed:\n' + errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
var invoiceRow = _fhirSubmitTaskRow;
|
||||
if (invoiceRow) {
|
||||
invoiceRow.setAttribute('data-push-status', res.status === 'local_stored' ? 'local_only' : 'queued');
|
||||
var statusBadge = invoiceRow.querySelector('.invoice-status-badge');
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = res.status === 'local_stored' ? 'Local Only' : 'Submitted';
|
||||
statusBadge.className = 'badge ' + (res.status === 'local_stored' ? 'bg-info' : 'bg-warning text-dark') + ' invoice-status-badge';
|
||||
}
|
||||
var details = invoiceRow.querySelector('.invoice-details');
|
||||
if (details) {
|
||||
details.innerHTML = res.queue_id
|
||||
? '<div><strong>Queue:</strong> ' + hesc(String(res.queue_id)) + '</div>'
|
||||
: '<span>' + hesc(res.message || '-') + '</span>';
|
||||
}
|
||||
var rowPushButton = invoiceRow.querySelector('.btn-invoice-push');
|
||||
if (rowPushButton && res.status !== 'local_stored') {
|
||||
rowPushButton.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
btn.className = 'btn btn-sm btn-success';
|
||||
btn.innerHTML = '<i class="bi bi-check-circle"></i> ' + (res.status === 'local_stored' ? 'Stored Locally' : 'Submitted');
|
||||
setStatus(res.message || (res.status === 'local_stored' ? 'Invoice stored locally; patient ABHA is unavailable.' : 'Invoice submitted to ABDM gateway.'));
|
||||
alert(res.message || (res.status === 'local_stored' ? 'Invoice stored locally; patient ABHA is unavailable.' : 'Invoice submitted to ABDM gateway.'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (_fhirSubmitMode === 'task') {
|
||||
if (!_fhirSubmitTaskRow) {
|
||||
alert('No ABDM task selected for submission.');
|
||||
|
|
@ -2449,6 +2874,36 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (taskType === 'immunization_record_publish') {
|
||||
var immunizationId = parseInt(_fhirSubmitTaskRow.getAttribute('data-entity-id') || '0', 10) || 0;
|
||||
var immunizationPatientId = parseInt(_fhirSubmitTaskRow.getAttribute('data-patient-id') || '0', 10) || 0;
|
||||
if (immunizationId <= 0 || immunizationPatientId <= 0) {
|
||||
alert('Task data missing (record_id/patient_id).');
|
||||
return;
|
||||
}
|
||||
|
||||
post('<?= base_url('AbdmGateway/share_immunization_bundle') ?>', {
|
||||
task_id: parseInt(_fhirSubmitTaskRow.getAttribute('data-task-id') || '0', 10) || 0,
|
||||
patient_id: immunizationPatientId,
|
||||
record_id: immunizationId,
|
||||
abha_id: _fhirSubmitAbha,
|
||||
push_to_gateway: 1
|
||||
}, function (res) {
|
||||
if (!res || parseInt(res.ok || '0', 10) !== 1) {
|
||||
alert('ABDM Bridge push failed:\n' + ((res && (res.error_text || res.error || res.message)) || 'Unknown error'));
|
||||
return;
|
||||
}
|
||||
var badge = _fhirSubmitTaskRow.querySelector('.status-pill');
|
||||
if (badge) {
|
||||
badge.textContent = 'BRIDGE SUBMITTED';
|
||||
badge.className = 'badge bg-info status-pill';
|
||||
}
|
||||
setStatus('Submitted to ABDM Bridge. Queue ID: ' + (res.queue_id || '-') + '; care context: ' + (res.care_context_reference || '-'));
|
||||
fhirPreviewModal.hide();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runAction(_fhirSubmitTaskRow, 'submit', _fhirSubmitAbha);
|
||||
fhirPreviewModal.hide();
|
||||
return;
|
||||
|
|
@ -2570,6 +3025,59 @@
|
|||
});
|
||||
});
|
||||
|
||||
function openInvoicePreview(row, pushIntent) {
|
||||
var source = (row.getAttribute('data-invoice-source') || '').trim();
|
||||
var billId = parseInt(row.getAttribute('data-bill-id') || '0', 10) || 0;
|
||||
var patientId = parseInt(row.getAttribute('data-patient-id') || '0', 10) || 0;
|
||||
var pushStatus = (row.getAttribute('data-push-status') || '').trim();
|
||||
if (!source || billId <= 0 || patientId <= 0) {
|
||||
setStatus('Invoice source data is incomplete.', true);
|
||||
return;
|
||||
}
|
||||
var previewUrl = '<?= base_url('AbdmGateway/invoice_fhir_preview') ?>'
|
||||
+ '?source=' + encodeURIComponent(source)
|
||||
+ '&bill_id=' + encodeURIComponent(String(billId))
|
||||
+ '&patient_id=' + encodeURIComponent(String(patientId));
|
||||
openFhirModal(previewUrl, (pushIntent ? 'Review & Push' : 'FHIR Preview') + ' — Invoice #' + billId, {
|
||||
submitMode: 'invoice',
|
||||
taskRow: row,
|
||||
pushStatus: pushStatus,
|
||||
invoiceSource: source,
|
||||
invoiceBillId: billId,
|
||||
invoicePatientId: patientId
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.btn-invoice-preview').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var row = btn.closest('tr');
|
||||
if (row) openInvoicePreview(row, false);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-invoice-push').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var row = btn.closest('tr');
|
||||
if (row) openInvoicePreview(row, true);
|
||||
});
|
||||
});
|
||||
|
||||
var dashboardRangeForm = document.getElementById('dashboardRangeForm');
|
||||
if (dashboardRangeForm) {
|
||||
dashboardRangeForm.addEventListener('submit', function (event) {
|
||||
if (typeof window.load_form !== 'function') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var dateFrom = document.getElementById('dashboardDateFrom').value;
|
||||
var dateTo = document.getElementById('dashboardDateTo').value;
|
||||
var rangeUrl = refreshUrl
|
||||
+ '?date_from=' + encodeURIComponent(dateFrom)
|
||||
+ '&date_to=' + encodeURIComponent(dateTo);
|
||||
window.load_form(rangeUrl, 'ABDM Task Board');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btnRefresh').addEventListener('click', function () {
|
||||
if (typeof window.load_form === 'function') {
|
||||
window.load_form(refreshUrl, 'ABDM Task Board');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Libraries\Abdm\Sync\AbdmGatewayPushClient;
|
||||
use App\Libraries\Abdm\EAtriaBridgeConnector;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class AbdmGatewayPushClientTest extends CIUnitTestCase
|
||||
|
|
@ -37,4 +38,78 @@ final class AbdmGatewayPushClientTest extends CIUnitTestCase
|
|||
|
||||
$this->assertTrue((bool) ($validation['valid'] ?? false));
|
||||
}
|
||||
|
||||
public function testDuplicateRecordConflictIsNormalizedAsAcceptedSuccess(): void
|
||||
{
|
||||
$method = new ReflectionMethod(AbdmGatewayPushClient::class, 'mapResponse');
|
||||
$client = (new ReflectionClass(AbdmGatewayPushClient::class))->newInstanceWithoutConstructor();
|
||||
$result = $method->invoke($client, 409, [
|
||||
'error' => 'DUPLICATE_RECORD',
|
||||
'existing_record_id' => 91,
|
||||
'existing_queue_id' => 'QUEUE-OLD',
|
||||
'first_pushed_at' => '2026-08-10T09:00:00+05:30',
|
||||
], 'request-1');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(1, $result['duplicate']);
|
||||
$this->assertSame(91, $result['gateway_record_id']);
|
||||
$this->assertSame('QUEUE-OLD', $result['gateway_queue_id']);
|
||||
$this->assertSame('2026-08-10T09:00:00+05:30', $result['first_pushed_at']);
|
||||
}
|
||||
|
||||
public function testCreatedRecordIsNormalizedAsSuccess(): void
|
||||
{
|
||||
$method = new ReflectionMethod(AbdmGatewayPushClient::class, 'mapResponse');
|
||||
$client = (new ReflectionClass(AbdmGatewayPushClient::class))->newInstanceWithoutConstructor();
|
||||
$result = $method->invoke($client, 201, [
|
||||
'record_id' => 92,
|
||||
'queue_id' => 'QUEUE-NEW',
|
||||
], 'request-created');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(1, $result['submitted']);
|
||||
$this->assertSame(92, $result['gateway_record_id']);
|
||||
}
|
||||
|
||||
public function testGenericConflictRemainsFailure(): void
|
||||
{
|
||||
$method = new ReflectionMethod(AbdmGatewayPushClient::class, 'mapResponse');
|
||||
$client = (new ReflectionClass(AbdmGatewayPushClient::class))->newInstanceWithoutConstructor();
|
||||
$result = $method->invoke($client, 409, ['error_code' => 'CONFLICT'], 'request-2');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('failed', $result['status']);
|
||||
}
|
||||
|
||||
public function testDirectConnectorNormalizesDuplicateMetadataWithoutHttpCall(): void
|
||||
{
|
||||
$method = new ReflectionMethod(EAtriaBridgeConnector::class, 'normalizePushRecordResponse');
|
||||
$result = $method->invoke(null, [
|
||||
'http_code' => 409,
|
||||
'error' => ['code' => 'DUPLICATE_RECORD'],
|
||||
'data' => [
|
||||
'existing_record_id' => 77,
|
||||
'existing_queue_id' => 'QUEUE-77',
|
||||
'first_pushed_at' => '2026-08-09T08:00:00+05:30',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $result['ok']);
|
||||
$this->assertSame(77, $result['record_id']);
|
||||
$this->assertSame('QUEUE-77', $result['queue_id']);
|
||||
$this->assertSame('2026-08-09T08:00:00+05:30', $result['first_pushed_at']);
|
||||
}
|
||||
|
||||
public function testValidationFailureIncludesGatewayFieldErrors(): void
|
||||
{
|
||||
$method = new ReflectionMethod(EAtriaBridgeConnector::class, 'normalizePushRecordResponse');
|
||||
$result = $method->invoke(null, [
|
||||
'http_code' => 422,
|
||||
'message' => 'FHIR validation failed',
|
||||
'errors' => [['field' => 'fhir_bundle.entry', 'message' => 'must not be empty']],
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $result['ok']);
|
||||
$this->assertStringContainsString('fhir_bundle.entry must not be empty', $result['error_text']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Libraries\Abdm\Fhir\FhirGeneratorFactory;
|
||||
use App\Controllers\AbdmGateway;
|
||||
use App\Controllers\Ipd_discharge;
|
||||
use App\Libraries\FhirR4Builder;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class FhirGeneratorsTest extends CIUnitTestCase
|
||||
|
|
@ -13,6 +16,29 @@ final class FhirGeneratorsTest extends CIUnitTestCase
|
|||
$this->factory = new FhirGeneratorFactory();
|
||||
}
|
||||
|
||||
public function testImmunizationCareContextReferenceIsStableAcrossClinicalDateChanges(): void
|
||||
{
|
||||
$method = new ReflectionMethod(AbdmGateway::class, 'resolveImmunizationCareContextReference');
|
||||
|
||||
$first = $method->invoke(null, [[
|
||||
'id' => 47,
|
||||
'given_date' => '2026-08-10 09:00:00',
|
||||
'abdm_care_context_reference' => null,
|
||||
]], 47, 12);
|
||||
$retry = $method->invoke(null, [[
|
||||
'id' => 47,
|
||||
'given_date' => '2026-08-15 11:30:00',
|
||||
'abdm_care_context_reference' => null,
|
||||
]], 47, 12);
|
||||
|
||||
$this->assertSame('IMM-47', $first);
|
||||
$this->assertSame($first, $retry);
|
||||
$this->assertSame('LEGACY-IMM-47', $method->invoke(null, [[
|
||||
'id' => 47,
|
||||
'abdm_care_context_reference' => 'LEGACY-IMM-47',
|
||||
]], 47, 12));
|
||||
}
|
||||
|
||||
public function testOpdGeneratorHappyPath(): void
|
||||
{
|
||||
$src = [
|
||||
|
|
@ -78,4 +104,298 @@ final class FhirGeneratorsTest extends CIUnitTestCase
|
|||
$this->assertSame($o1['care_context_reference'], $o2['care_context_reference']);
|
||||
$this->assertSame($o1['hi_type'], $o2['hi_type']);
|
||||
}
|
||||
|
||||
public function testDischargeDocumentHasValidStructureAndResolvableReferences(): void
|
||||
{
|
||||
$src = [
|
||||
'record_id' => 4,
|
||||
'bundle_identifier' => 'discharge-A26080000004',
|
||||
'session_id' => 4,
|
||||
'visit_date' => '2026-08-15',
|
||||
'completed_at' => '2026-08-15T12:00:00+05:30',
|
||||
'patient' => ['id' => 11, 'name' => 'Test Patient', 'gender' => 'male', 'dob' => '1979-03-28'],
|
||||
'encounter' => ['id' => 'A26080000004', 'class_code' => 'IMP', 'start' => '2026-08-14T09:00:00+05:30', 'end' => '2026-08-15T12:00:00+05:30'],
|
||||
'doctor' => ['id' => 9, 'name' => 'Test Doctor'],
|
||||
'organization' => ['id' => 'H1', 'name' => 'Test Hospital'],
|
||||
'conditions' => [['text' => 'Viral fever']],
|
||||
'procedures' => [['text' => 'Supportive care']],
|
||||
'medications' => [['name' => 'Paracetamol', 'dosage' => 'As directed']],
|
||||
'care_plans' => [['title' => 'Advice', 'description' => 'Review after three days']],
|
||||
'documents' => [[
|
||||
'title' => 'Discharge Summary PDF',
|
||||
'content_type' => 'application/pdf',
|
||||
'data' => base64_encode('%PDF-test'),
|
||||
'created_at' => '2026-08-15T12:00:00+05:30',
|
||||
]],
|
||||
];
|
||||
|
||||
$bundle = $this->factory->discharge()->generate($src)['fhir_bundle'];
|
||||
$composition = $bundle['entry'][0]['resource'];
|
||||
$this->assertSame('discharge-A26080000004', $bundle['identifier']['value'] ?? null);
|
||||
$this->assertSame('IPD Discharge Summary', $composition['title'] ?? null);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DischargeSummaryRecord', $composition['meta']['profile'] ?? []);
|
||||
$this->assertNotEmpty($composition['author'] ?? []);
|
||||
$this->assertNotEmpty($composition['custodian'] ?? []);
|
||||
$this->assertNotEmpty($composition['section'] ?? []);
|
||||
$this->assertNotEmpty($composition['section'][0]['text']['div'] ?? '');
|
||||
|
||||
$fullUrls = array_column($bundle['entry'], 'fullUrl');
|
||||
foreach ($fullUrls as $fullUrl) {
|
||||
$this->assertMatchesRegularExpression('/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/', $fullUrl);
|
||||
}
|
||||
|
||||
$encounters = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'Encounter'));
|
||||
$patients = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'Patient'));
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient', $patients[0]['resource']['meta']['profile'] ?? []);
|
||||
$this->assertSame('IMP', $encounters[0]['resource']['class']['code'] ?? null);
|
||||
$this->assertSame('A26080000004', $encounters[0]['resource']['identifier'][0]['value'] ?? null);
|
||||
$documents = array_values(array_filter($bundle['entry'], static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'DocumentReference'));
|
||||
$this->assertCount(1, $documents);
|
||||
$this->assertSame('%PDF-test', base64_decode((string) ($documents[0]['resource']['content'][0]['attachment']['data'] ?? ''), true));
|
||||
|
||||
$encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES);
|
||||
preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches);
|
||||
foreach ($matches[1] as $reference) {
|
||||
$this->assertContains($reference, $fullUrls, 'Unresolved document reference: ' . $reference);
|
||||
}
|
||||
$this->assertStringNotContainsString('UNMAPPED', (string) $encoded);
|
||||
}
|
||||
|
||||
public function testInvoiceRecordHasNrcesProfilesAndResolvableReferences(): void
|
||||
{
|
||||
$controller = new AbdmGateway();
|
||||
$method = new ReflectionMethod($controller, 'buildSimpleInvoiceBundle');
|
||||
$bundle = $method->invoke($controller, [
|
||||
'resourceType' => 'Patient',
|
||||
'id' => 'patient-11',
|
||||
'identifier' => [[
|
||||
'system' => 'https://healthid.ndhm.gov.in/abha-address',
|
||||
'value' => 'testpatient@sbx',
|
||||
]],
|
||||
'name' => [['text' => 'Test Patient']],
|
||||
'gender' => 'male',
|
||||
'birthDate' => '1990-01-01',
|
||||
], [
|
||||
'id' => 19,
|
||||
'invoice_code' => 'OPD-19',
|
||||
'invoice_type_code' => '03',
|
||||
'invoice_type_display' => 'OPD',
|
||||
'encounter_class' => 'AMB',
|
||||
'practitioner_id' => 7,
|
||||
'practitioner_name' => 'Dr Test',
|
||||
'encounter_start' => '2026-08-14',
|
||||
'inv_date' => '2026-08-15',
|
||||
'net_amount' => 300,
|
||||
'total_amount' => 350,
|
||||
], [[
|
||||
'item_name' => 'OPD Consultation Fee',
|
||||
'item_qty' => 1,
|
||||
'item_rate' => 300,
|
||||
'item_amount' => 300,
|
||||
]]);
|
||||
|
||||
$this->assertSame('document', $bundle['type'] ?? null);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []);
|
||||
$this->assertSame('OPD-19', $bundle['identifier']['value'] ?? null);
|
||||
$this->assertSame('Composition', $bundle['entry'][0]['resource']['resourceType'] ?? null);
|
||||
|
||||
$resources = [];
|
||||
foreach ($bundle['entry'] as $entry) {
|
||||
$fullUrl = (string) ($entry['fullUrl'] ?? '');
|
||||
$this->assertMatchesRegularExpression('/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/', $fullUrl);
|
||||
$resources[(string) ($entry['resource']['resourceType'] ?? '')] = $entry['resource'];
|
||||
}
|
||||
|
||||
$composition = $resources['Composition'];
|
||||
$invoice = $resources['Invoice'];
|
||||
$patient = $resources['Patient'];
|
||||
$organization = $resources['Organization'];
|
||||
$encounter = $resources['Encounter'];
|
||||
$practitioner = $resources['Practitioner'];
|
||||
$documentReference = $resources['DocumentReference'];
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/InvoiceRecord', $composition['meta']['profile'] ?? []);
|
||||
$this->assertSame('Invoice Record', $composition['type']['text'] ?? null);
|
||||
$this->assertSame('69705-9', $composition['type']['coding'][0]['code'] ?? null);
|
||||
$this->assertSame(
|
||||
'Invoice',
|
||||
$composition['section'][0]['entry'][0]['type'] ?? null,
|
||||
'NRCeS InvoiceRecord 6.5.0 requires Reference.type to be Invoice'
|
||||
);
|
||||
$this->assertCount(1, $composition['section'][0]['entry'] ?? []);
|
||||
$this->assertNotEmpty($composition['author'][0]['reference'] ?? '');
|
||||
$this->assertNotEmpty($composition['custodian']['reference'] ?? '');
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Patient', $patient['meta']['profile'] ?? []);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Organization', $organization['meta']['profile'] ?? []);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Encounter', $encounter['meta']['profile'] ?? []);
|
||||
$this->assertSame('2026-08-14', $encounter['period']['start'] ?? null);
|
||||
$this->assertNotEmpty($encounter['serviceProvider']['reference'] ?? '');
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Practitioner', $practitioner['meta']['profile'] ?? []);
|
||||
$this->assertSame('MD', $practitioner['identifier'][0]['type']['coding'][0]['code'] ?? null);
|
||||
$this->assertSame('7', $practitioner['identifier'][0]['value'] ?? null);
|
||||
$this->assertSame('Dr Test', $practitioner['name'][0]['text'] ?? null);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/Invoice', $invoice['meta']['profile'] ?? []);
|
||||
$this->assertSame('Dr Test', $invoice['participant'][0]['actor']['display'] ?? null);
|
||||
$this->assertSame('03', $invoice['type']['coding'][0]['code'] ?? null);
|
||||
$this->assertSame('01', $invoice['lineItem'][0]['priceComponent'][0]['code']['coding'][0]['code'] ?? null);
|
||||
$this->assertArrayHasKey('chargeItemReference', $invoice['lineItem'][0]);
|
||||
$chargeItems = array_values(array_filter(
|
||||
$bundle['entry'],
|
||||
static fn (array $entry): bool => ($entry['resource']['resourceType'] ?? '') === 'ChargeItem'
|
||||
));
|
||||
$this->assertCount(1, $chargeItems);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/ChargeItem', $chargeItems[0]['resource']['meta']['profile'] ?? []);
|
||||
$this->assertSame('OPD Consultation Fee', $chargeItems[0]['resource']['code']['text'] ?? null);
|
||||
$this->assertSame('OPD Consultation Fee', $chargeItems[0]['resource']['productCodeableConcept']['text'] ?? null);
|
||||
$this->assertSame(1.0, $chargeItems[0]['resource']['quantity']['value'] ?? null);
|
||||
$this->assertSame('unit', $chargeItems[0]['resource']['quantity']['unit'] ?? null);
|
||||
$this->assertSame(300.0, $invoice['totalNet']['value'] ?? null, 'NRCeS Invoice requires totalNet');
|
||||
$this->assertSame(350.0, $invoice['totalGross']['value'] ?? null, 'NRCeS Invoice requires totalGross');
|
||||
$this->assertSame(300.0, $invoice['totalPriceComponent'][0]['amount']['value'] ?? null);
|
||||
$this->assertContains(
|
||||
'https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentReference',
|
||||
$documentReference['meta']['profile'] ?? []
|
||||
);
|
||||
$attachment = $documentReference['content'][0]['attachment'] ?? [];
|
||||
$pdfBytes = base64_decode((string) ($attachment['data'] ?? ''), true);
|
||||
$this->assertIsString($pdfBytes);
|
||||
$this->assertSame('application/pdf', $attachment['contentType'] ?? null);
|
||||
$this->assertStringStartsWith('%PDF-', $pdfBytes);
|
||||
$this->assertSame(strlen($pdfBytes), $attachment['size'] ?? null);
|
||||
$this->assertSame(base64_encode(sha1($pdfBytes, true)), $attachment['hash'] ?? null);
|
||||
$this->assertSame(
|
||||
$composition['section'][0]['entry'][0]['reference'] ?? null,
|
||||
$documentReference['context']['related'][0]['reference'] ?? null
|
||||
);
|
||||
|
||||
$fullUrls = array_column($bundle['entry'], 'fullUrl');
|
||||
$encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES);
|
||||
preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches);
|
||||
foreach ($matches[1] as $reference) {
|
||||
$this->assertContains($reference, $fullUrls, 'Unresolved invoice reference: ' . $reference);
|
||||
}
|
||||
}
|
||||
|
||||
public function testImmunizationRecordHasM2DocumentStructureAndResolvableReferences(): void
|
||||
{
|
||||
$bundle = (new FhirR4Builder())->buildImmunizationRecordBundle([
|
||||
'id' => '91',
|
||||
'name' => 'Test Patient',
|
||||
'gender' => 'female',
|
||||
'birthDate' => '2020-01-10',
|
||||
'abhaAddress' => 'testpatient@sbx',
|
||||
], [[
|
||||
'id' => '17',
|
||||
'vaccine_name' => 'BCG Vaccine',
|
||||
'vaccine_code_system' => 'https://hms.local/immunization/uip',
|
||||
'vaccine_code' => 'UIP-BCG',
|
||||
'given_date' => '2026-08-15',
|
||||
'status' => 'completed',
|
||||
'lot_number' => 'LOT-42',
|
||||
'dose_number' => '1',
|
||||
'series_name' => 'Infant Immunization Schedule',
|
||||
'target_disease_code' => '56717001',
|
||||
'target_disease_name' => 'Tuberculosis',
|
||||
]], [
|
||||
'care_context_reference' => 'IMM-17-91-20260815',
|
||||
'practitioner' => [
|
||||
'id' => '7',
|
||||
'name' => 'Dr Test',
|
||||
'registration_number' => 'MED-7',
|
||||
],
|
||||
'organization' => ['name' => 'Test Hospital', 'hfr_id' => 'IN0000000001'],
|
||||
'encounter' => [
|
||||
'id' => 'IMM-17',
|
||||
'status' => 'finished',
|
||||
'class_code' => 'AMB',
|
||||
'period_start' => '2026-08-15',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame('document', $bundle['type'] ?? null);
|
||||
$this->assertSame('IMM-17-91-20260815', $bundle['identifier']['value'] ?? null);
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/DocumentBundle', $bundle['meta']['profile'] ?? []);
|
||||
|
||||
$resources = [];
|
||||
$fullUrls = array_column($bundle['entry'], 'fullUrl');
|
||||
foreach ($bundle['entry'] as $entry) {
|
||||
$resource = $entry['resource'] ?? [];
|
||||
$resources[(string) ($resource['resourceType'] ?? '')] = $resource;
|
||||
}
|
||||
|
||||
$composition = $resources['Composition'];
|
||||
$immunization = $resources['Immunization'];
|
||||
$this->assertContains('https://nrces.in/ndhm/fhir/r4/StructureDefinition/ImmunizationRecord', $composition['meta']['profile'] ?? []);
|
||||
$this->assertSame('41000179103', $composition['type']['coding'][0]['code'] ?? null);
|
||||
$this->assertSame('Dr Test', $composition['author'][0]['display'] ?? null);
|
||||
$this->assertSame('Test Hospital', $composition['custodian']['display'] ?? null);
|
||||
$this->assertSame('Immunization', $composition['section'][0]['entry'][0]['type'] ?? null);
|
||||
$this->assertSame('BCG Vaccine', $immunization['vaccineCode']['text'] ?? null);
|
||||
$this->assertSame('http://hl7.org/fhir/sid/cvx', $immunization['vaccineCode']['coding'][0]['system'] ?? null);
|
||||
$this->assertSame('19', $immunization['vaccineCode']['coding'][0]['code'] ?? null);
|
||||
$this->assertSame('2026-08-15T00:00:00+05:30', $immunization['occurrenceDateTime'] ?? null);
|
||||
$this->assertNotEmpty($immunization['performer'][0]['actor']['reference'] ?? '');
|
||||
$this->assertNotEmpty($immunization['encounter']['reference'] ?? '');
|
||||
$this->assertSame('Test Hospital', $immunization['location']['display'] ?? null);
|
||||
$this->assertSame('Infant Immunization Schedule', $immunization['protocolApplied'][0]['series'] ?? null);
|
||||
$this->assertSame(1, $immunization['protocolApplied'][0]['doseNumberPositiveInt'] ?? null);
|
||||
$this->assertSame('56717001', $immunization['protocolApplied'][0]['targetDisease'][0]['coding'][0]['code'] ?? null);
|
||||
|
||||
$encoded = json_encode($bundle, JSON_UNESCAPED_SLASHES);
|
||||
preg_match_all('/"reference":"(urn:uuid:[^"]+)"/', (string) $encoded, $matches);
|
||||
foreach ($matches[1] as $reference) {
|
||||
$this->assertContains($reference, $fullUrls, 'Unresolved ImmunizationRecord reference: ' . $reference);
|
||||
}
|
||||
}
|
||||
|
||||
public function testImmunizationBuilderFallbackReferenceIsStableOnRetry(): void
|
||||
{
|
||||
$builder = new FhirR4Builder();
|
||||
$patient = ['id' => '91', 'name' => 'Test Patient'];
|
||||
$first = $builder->buildImmunizationRecordBundle($patient, [[
|
||||
'id' => '17',
|
||||
'vaccine_name' => 'BCG Vaccine',
|
||||
'given_date' => '2026-08-10',
|
||||
'status' => 'completed',
|
||||
]]);
|
||||
$retry = $builder->buildImmunizationRecordBundle($patient, [[
|
||||
'id' => '17',
|
||||
'vaccine_name' => 'BCG Vaccine',
|
||||
'given_date' => '2026-08-15',
|
||||
'status' => 'completed',
|
||||
]]);
|
||||
|
||||
$this->assertSame('IMM-17', $first['identifier']['value'] ?? null);
|
||||
$this->assertSame($first['identifier']['value'] ?? null, $retry['identifier']['value'] ?? null);
|
||||
}
|
||||
|
||||
public function testLegacyNumericGenderMapsToFhirGender(): void
|
||||
{
|
||||
$controller = new AbdmGateway();
|
||||
$method = new ReflectionMethod($controller, 'normalizeFhirGender');
|
||||
|
||||
$this->assertSame('male', $method->invoke($controller, '1'));
|
||||
$this->assertSame('female', $method->invoke($controller, '2'));
|
||||
$this->assertSame('other', $method->invoke($controller, '3'));
|
||||
}
|
||||
|
||||
public function testBlankDischargeTemplateUsesDefaultHospitalHeader(): void
|
||||
{
|
||||
$controller = new Ipd_discharge();
|
||||
$blankMethod = new ReflectionMethod($controller, 'isDischargeHeaderBlank');
|
||||
$headerMethod = new ReflectionMethod($controller, 'buildDefaultDischargeHeader');
|
||||
|
||||
$this->assertTrue($blankMethod->invoke($controller, '<div> </div>'));
|
||||
$this->assertFalse($blankMethod->invoke($controller, '<div>Custom Letterhead</div>'));
|
||||
|
||||
$header = $headerMethod->invoke($controller, [
|
||||
'H_Name' => 'Test Hospital',
|
||||
'hospital_address' => 'Hospital Road',
|
||||
'H_phone_No' => '1234567890',
|
||||
'H_Email' => 'care@example.test',
|
||||
'H_logo_abs' => '',
|
||||
]);
|
||||
$this->assertStringContainsString('Test Hospital', $header);
|
||||
$this->assertStringContainsString('Hospital Road', $header);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
tests/unit/Abdm/M2LinkStatusResolverTest.php
Normal file
42
tests/unit/Abdm/M2LinkStatusResolverTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use App\Libraries\Abdm\M2LinkStatusResolver;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class M2LinkStatusResolverTest extends CIUnitTestCase
|
||||
{
|
||||
public function testParsesCamelCaseCallbackIdentifiers(): void
|
||||
{
|
||||
$callback = M2LinkStatusResolver::parse([
|
||||
'requestId' => 'req-42',
|
||||
'abha_address' => 'patient@abdm',
|
||||
'careContextReference' => 'IMM-17-91-20260815',
|
||||
'linkedAt' => '2026-08-15 12:30:00',
|
||||
'status' => 'SUCCESS',
|
||||
]);
|
||||
|
||||
$this->assertSame('req-42', $callback['request_id']);
|
||||
$this->assertSame('IMM-17-91-20260815', $callback['care_context_reference']);
|
||||
$this->assertSame('linked', $callback['status']);
|
||||
}
|
||||
|
||||
public function testHeaderRequestIdIsUsedAsFallback(): void
|
||||
{
|
||||
$callback = M2LinkStatusResolver::parse([
|
||||
'abha_id' => '12345678901234',
|
||||
'care_context_reference' => 'OPD-10-S1-2026-08-15',
|
||||
'status' => 'linked',
|
||||
], 'header-request-id');
|
||||
|
||||
$this->assertSame('header-request-id', $callback['request_id']);
|
||||
}
|
||||
|
||||
public function testLinkedStateCannotBeDowngradedByLateCallback(): void
|
||||
{
|
||||
$this->assertSame('linked', M2LinkStatusResolver::merge('linked', 'failed'));
|
||||
$this->assertSame('linked', M2LinkStatusResolver::merge('linked', 'pending'));
|
||||
$this->assertSame('failed', M2LinkStatusResolver::merge('pending', 'rejected'));
|
||||
$this->assertSame('failed', M2LinkStatusResolver::merge('failed', 'pending'));
|
||||
$this->assertSame('linked', M2LinkStatusResolver::merge('failed', 'completed'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user