Push M2 records to gateway for NAT deployment and cover all 8 HI types
This commit is contained in:
parent
b25ab98fd7
commit
00b949ad0a
193
app/Commands/AbdmPushBackfill.php
Normal file
193
app/Commands/AbdmPushBackfill.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\Abdm\AbdmConnectorFactory;
|
||||
use App\Libraries\FhirEncryptionService;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class AbdmPushBackfill extends BaseCommand
|
||||
{
|
||||
protected $group = 'ABDM';
|
||||
protected $name = 'abdm:push-backfill';
|
||||
protected $description = 'Push stored health_records that never reached the ABDM bridge.';
|
||||
protected $usage = 'abdm:push-backfill [--limit 20] [--id 5] [--dry-run]';
|
||||
protected $arguments = [];
|
||||
protected $options = [
|
||||
'--limit' => 'Maximum records to push (default: 20).',
|
||||
'--id' => 'Push a single health_records id.',
|
||||
'--dry-run' => 'List eligible records without pushing.',
|
||||
];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$limit = (int) (CLI::getOption('limit') ?? 20);
|
||||
if ($limit <= 0) {
|
||||
$limit = 20;
|
||||
}
|
||||
$onlyId = (int) (CLI::getOption('id') ?? 0);
|
||||
$dryRun = CLI::getOption('dry-run') !== null;
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
if (! $db->tableExists('health_records')) {
|
||||
CLI::error('health_records table not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$builder = $db->table('health_records')
|
||||
->whereNotIn('push_status', ['pushed', 'linked'])
|
||||
->where('care_context_reference !=', '')
|
||||
->where('abha_id !=', '')
|
||||
->orderBy('id', 'ASC');
|
||||
if ($onlyId > 0) {
|
||||
$builder->where('id', $onlyId);
|
||||
}
|
||||
|
||||
$rows = $builder->get($limit)->getResultArray();
|
||||
CLI::write('ABDM Push Backfill' . ($dryRun ? ' (dry-run)' : ''), 'yellow');
|
||||
CLI::write('Eligible: ' . count($rows));
|
||||
|
||||
if ($rows === [] || $dryRun) {
|
||||
foreach ($rows as $row) {
|
||||
CLI::write(' id=' . $row['id'] . ' ' . $row['hi_type'] . ' ' . $row['care_context_reference']);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$connector = AbdmConnectorFactory::make();
|
||||
$pushed = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$bundle = $this->resolveBundle($row);
|
||||
if ($bundle === null) {
|
||||
CLI::write(' id=' . $row['id'] . ' — no readable FHIR bundle, skipped', 'red');
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$patient = $this->loadPatient($db, (int) ($row['patient_id'] ?? 0));
|
||||
$abhaId = trim((string) ($row['abha_id'] ?? ''));
|
||||
|
||||
try {
|
||||
$result = $connector->pushRecord([
|
||||
'patient_id' => (string) ($row['patient_id'] ?? ''),
|
||||
'patient_name' => $patient['name'] !== '' ? $patient['name'] : ('PATIENT-' . (int) $row['patient_id']),
|
||||
'abha_id' => str_contains($abhaId, '@') ? '' : $abhaId,
|
||||
'abha_address' => str_contains($abhaId, '@') ? $abhaId : $patient['abha_address'],
|
||||
'gender' => $patient['gender'],
|
||||
'year_of_birth' => $patient['year_of_birth'],
|
||||
'hi_type' => (string) ($row['hi_type'] ?? ''),
|
||||
'visit_date' => substr((string) ($row['created_at'] ?? date('Y-m-d')), 0, 10),
|
||||
'care_context_reference' => (string) ($row['care_context_reference'] ?? ''),
|
||||
'care_context_display' => (string) ($row['hi_type'] ?? '') . ' ' . substr((string) ($row['created_at'] ?? ''), 0, 10),
|
||||
'record_data' => $bundle,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
CLI::write(' id=' . $row['id'] . ' — exception: ' . $e->getMessage(), 'red');
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = ! empty($result['ok']) && (int) $result['ok'] === 1;
|
||||
$httpCode = (int) ($result['http_code'] ?? 0);
|
||||
if ($ok || $httpCode === 201 || $httpCode === 409) {
|
||||
$db->table('health_records')->where('id', $row['id'])->update([
|
||||
'push_status' => 'pushed',
|
||||
'push_at' => date('Y-m-d H:i:s'),
|
||||
'bridge_record_id' => (int) ($result['record_id'] ?? 0) ?: null,
|
||||
'abdm_txn_id' => (string) ($result['queue_id'] ?? $row['abdm_txn_id'] ?? ''),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
CLI::write(' id=' . $row['id'] . ' — pushed (record_id=' . (int) ($result['record_id'] ?? 0) . ')', 'green');
|
||||
$pushed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$error = trim((string) ($result['message'] ?? $result['error_text'] ?? ('HTTP ' . $httpCode)));
|
||||
foreach ((array) ($result['errors'] ?? []) as $detail) {
|
||||
if (is_array($detail)) {
|
||||
$error .= ' | ' . trim(implode(' ', array_filter([
|
||||
(string) ($detail['code'] ?? ''),
|
||||
(string) ($detail['field'] ?? ''),
|
||||
(string) ($detail['message'] ?? ''),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
$db->table('health_records')->where('id', $row['id'])->update([
|
||||
'push_status' => 'failed',
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
CLI::write(' id=' . $row['id'] . ' — failed: ' . $error, 'red');
|
||||
$failed++;
|
||||
}
|
||||
|
||||
CLI::write('Pushed: ' . $pushed, 'green');
|
||||
CLI::write('Failed: ' . $failed, $failed > 0 ? 'red' : 'green');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function resolveBundle(array $row): ?array
|
||||
{
|
||||
$plain = trim((string) ($row['record_data'] ?? ''));
|
||||
if ($plain === '') {
|
||||
$encrypted = trim((string) ($row['fhir_bundle_enc'] ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$plain = (new FhirEncryptionService())->decrypt($encrypted);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$decoded = json_decode($plain, true);
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bundle = $decoded['bundle'] ?? $decoded['fhir_bundle'] ?? $decoded;
|
||||
|
||||
return is_array($bundle) && ($bundle['resourceType'] ?? '') === 'Bundle' ? $bundle : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name:string,gender:string,year_of_birth:string,abha_address:string}
|
||||
*/
|
||||
private function loadPatient($db, int $patientId): array
|
||||
{
|
||||
$out = ['name' => '', 'gender' => '', 'year_of_birth' => '', 'abha_address' => ''];
|
||||
if ($patientId <= 0 || ! $db->tableExists('patient_master')) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$fields = $db->getFieldNames('patient_master') ?? [];
|
||||
$row = $db->table('patient_master')->where('id', $patientId)->get(1)->getRowArray();
|
||||
if (! $row) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$out['name'] = trim((string) ($row['p_fname'] ?? ''));
|
||||
$gender = (int) ($row['gender'] ?? 0);
|
||||
$out['gender'] = $gender === 1 ? 'M' : ($gender === 2 ? 'F' : '');
|
||||
|
||||
$dob = trim((string) ($row['dob'] ?? ''));
|
||||
if ($dob !== '' && $dob !== '0000-00-00') {
|
||||
$out['year_of_birth'] = substr($dob, 0, 4);
|
||||
}
|
||||
|
||||
if (in_array('abha_address', $fields, true)) {
|
||||
$out['abha_address'] = trim((string) ($row['abha_address'] ?? ''));
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -1102,7 +1102,7 @@ class AbdmGateway extends BaseController
|
|||
$abhaId = trim((string) $this->request->getPost('abha_id'));
|
||||
$abhaAddressPost = trim((string) $this->request->getPost('abha_address'));
|
||||
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
|
||||
$pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1;
|
||||
$pushToGateway = $this->resolveGatewayPushMode();
|
||||
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
||||
|
||||
$logBridge = function (string $status, array $responsePayload, string $errorMessage = '', string $eventType = 'abdm.opd.prescription.share') use ($opdId, $patientId, $now, $pushToGateway): void {
|
||||
|
|
@ -2087,7 +2087,7 @@ class AbdmGateway extends BaseController
|
|||
$abhaId = trim((string) $this->request->getPost('abha_id'));
|
||||
$abhaAddressPost = trim((string) $this->request->getPost('abha_address'));
|
||||
$consentHandle = trim((string) $this->request->getPost('consent_handle'));
|
||||
$pushToGateway = (int) $this->request->getPost('push_to_gateway') === 1;
|
||||
$pushToGateway = $this->resolveGatewayPushMode();
|
||||
|
||||
if ($abhaId === '' && $abhaAddressPost !== '') {
|
||||
$abhaId = $abhaAddressPost;
|
||||
|
|
@ -2095,6 +2095,9 @@ class AbdmGateway extends BaseController
|
|||
if ($patientId <= 0) {
|
||||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required']);
|
||||
}
|
||||
if ($abhaId === '') {
|
||||
$abhaId = $this->resolvePatientAbhaIdentifier($patientId);
|
||||
}
|
||||
|
||||
$payload = $this->buildImmunizationGatewayPayload($patientId, $recordId, $abhaId);
|
||||
if ($payload === null) {
|
||||
|
|
@ -2248,6 +2251,10 @@ class AbdmGateway extends BaseController
|
|||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'No wellness/vital data found for this patient']);
|
||||
}
|
||||
|
||||
if ($abhaId === '') {
|
||||
$abhaId = $this->resolvePatientAbhaIdentifier($patientId);
|
||||
}
|
||||
|
||||
return $this->pushAdditionalHiRecord($payload, $patientId, $abhaId, $consentHandle);
|
||||
}
|
||||
|
||||
|
|
@ -2270,6 +2277,10 @@ class AbdmGateway extends BaseController
|
|||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'document_title with document_text/document_base64/file_path is required']);
|
||||
}
|
||||
|
||||
if ($abhaId === '') {
|
||||
$abhaId = $this->resolvePatientAbhaIdentifier($patientId);
|
||||
}
|
||||
|
||||
return $this->pushAdditionalHiRecord($payload, $patientId, $abhaId, $consentHandle);
|
||||
}
|
||||
|
||||
|
|
@ -2293,6 +2304,10 @@ class AbdmGateway extends BaseController
|
|||
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invoice not found or patient could not be resolved']);
|
||||
}
|
||||
|
||||
if ($abhaId === '') {
|
||||
$abhaId = $this->resolvePatientAbhaIdentifier((int) $payload['patient_id']);
|
||||
}
|
||||
|
||||
return $this->pushAdditionalHiRecord($payload, (int) $payload['patient_id'], $abhaId, $consentHandle);
|
||||
}
|
||||
|
||||
|
|
@ -4505,6 +4520,23 @@ class AbdmGateway extends BaseController
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HMS sits behind NAT, so ABDM/bridge discovery callbacks can never reach it —
|
||||
* records must be pushed to the gateway to become discoverable in the PHR.
|
||||
* Set ABDM_GATEWAY_PUSH_MODE=0 only for a callback-reachable deployment.
|
||||
*/
|
||||
private function resolveGatewayPushMode(): bool
|
||||
{
|
||||
$requested = $this->request->getPost('push_to_gateway');
|
||||
if ($requested !== null && trim((string) $requested) !== '') {
|
||||
return (int) $requested === 1;
|
||||
}
|
||||
|
||||
$setting = strtolower(trim($this->readRuntimeSetting('ABDM_GATEWAY_PUSH_MODE')));
|
||||
|
||||
return ! in_array($setting, ['0', 'off', 'false', 'no'], true);
|
||||
}
|
||||
|
||||
private function readRuntimeSetting(string $name): string
|
||||
{
|
||||
$envValue = getenv($name);
|
||||
|
|
@ -5771,6 +5803,32 @@ class AbdmGateway extends BaseController
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Share endpoints are triggered from screens that do not carry the ABHA, and an
|
||||
* empty identifier silently downgrades the record to local-only instead of pushing.
|
||||
*/
|
||||
private function resolvePatientAbhaIdentifier(int $patientId): string
|
||||
{
|
||||
$row = $this->loadPatientRow($patientId);
|
||||
if ($row === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$address = trim((string) ($row['abha_address'] ?? ''));
|
||||
if ($address !== '') {
|
||||
return $address;
|
||||
}
|
||||
|
||||
foreach (['abha_id', 'abha_no', 'abha'] as $field) {
|
||||
$value = trim((string) ($row[$field] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function loadPatientRow(int $patientId): array
|
||||
{
|
||||
if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class AbdmGatewayPushClient
|
|||
];
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->readSetting('ABDM_BRIDGE_URL', 'EATRIA_BRIDGE_URL'), '/');
|
||||
$baseUrl = $this->resolveBridgeBaseUrl();
|
||||
if ($baseUrl === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
|
|
@ -81,7 +81,7 @@ class AbdmGatewayPushClient
|
|||
];
|
||||
}
|
||||
|
||||
$url = $baseUrl . '/api/v3/records/push';
|
||||
$url = $this->buildGatewayUrl($baseUrl, '/api/v3/records/push');
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
|
|
@ -153,7 +153,7 @@ class AbdmGatewayPushClient
|
|||
];
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->readSetting('ABDM_BRIDGE_URL', 'EATRIA_BRIDGE_URL'), '/');
|
||||
$baseUrl = $this->resolveBridgeBaseUrl();
|
||||
if ($baseUrl === '') {
|
||||
return [
|
||||
'ok' => false,
|
||||
|
|
@ -443,6 +443,27 @@ class AbdmGatewayPushClient
|
|||
$isSubmitted = $httpStatus === 201 || $isDuplicate;
|
||||
$message = (string) ($body['message'] ?? $body['error_text'] ?? ('HTTP ' . $httpStatus));
|
||||
|
||||
// The bridge returns field-level FHIR errors here; without them a 422 is undiagnosable.
|
||||
$validationErrors = is_array($body['errors'] ?? null) ? $body['errors'] : [];
|
||||
if ($validationErrors !== []) {
|
||||
$details = [];
|
||||
foreach ($validationErrors as $error) {
|
||||
if (! is_array($error)) {
|
||||
$details[] = trim((string) $error);
|
||||
continue;
|
||||
}
|
||||
$details[] = trim(implode(' ', array_filter([
|
||||
trim((string) ($error['code'] ?? '')),
|
||||
trim((string) ($error['field'] ?? '')),
|
||||
trim((string) ($error['message'] ?? '')),
|
||||
])));
|
||||
}
|
||||
$details = array_values(array_filter($details));
|
||||
if ($details !== []) {
|
||||
$message .= ' | ' . implode(' ; ', $details);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isSubmitted || ((int) ($body['ok'] ?? 0) === 1 && $httpStatus >= 200 && $httpStatus < 300)) {
|
||||
return [
|
||||
'ok' => true,
|
||||
|
|
@ -654,6 +675,25 @@ class AbdmGatewayPushClient
|
|||
return str_repeat('*', strlen($left) - 4) . substr($left, -4) . ($right !== '' ? '@' . $right : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* ABDM_BRIDGE_URL still points at the retired /api/v1/bridge root in older .env files,
|
||||
* so normalise every source down to the bridge API root that serves /api/v3/*.
|
||||
*/
|
||||
private function resolveBridgeBaseUrl(): string
|
||||
{
|
||||
$baseUrl = trim($this->readSetting('EATRIA_BRIDGE_URL', 'ABDM_BRIDGE_URL'));
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$baseUrl = (string) preg_replace('#/api/v\d+/bridge$#i', '/api', $baseUrl);
|
||||
$baseUrl = (string) preg_replace('#/v\d+/bridge$#i', '', $baseUrl);
|
||||
$baseUrl = (string) preg_replace('#/api/v\d+$#i', '/api', $baseUrl);
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function buildGatewayUrl(string $baseUrl, string $path): string
|
||||
{
|
||||
$url = rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
|
||||
|
|
|
|||
|
|
@ -106,43 +106,45 @@ class AbdmSyncWorkerService
|
|||
|
||||
$patientProfile = $this->resolvePatientProfile((int) ($record['local_patient_id'] ?? 0), $payload);
|
||||
|
||||
$hospitalId = (int) ($payload['hospital_id'] ?? 0);
|
||||
if ($hospitalId <= 0) {
|
||||
$hospitalId = $this->resolveHospitalIdFromSettings();
|
||||
$hfrId = trim((string) ($record['hfr_id'] ?? ''));
|
||||
if ($hfrId === '') {
|
||||
$hfrId = trim((string) ($payload['hfr_id'] ?? ''));
|
||||
}
|
||||
|
||||
$bundle = $payload['fhir_bundle'] ?? null;
|
||||
if (is_string($bundle)) {
|
||||
$bundle = json_decode($bundle, true);
|
||||
}
|
||||
|
||||
$recordType = $this->mapHiTypeToRecordType((string) ($record['hi_type'] ?? ''));
|
||||
$careContextPayload = [
|
||||
'hospital_id' => $hospitalId,
|
||||
'hfr_id' => (string) ($record['hfr_id'] ?? ''),
|
||||
'patient' => [
|
||||
'patient_id' => $patientProfile['patient_id'],
|
||||
'name' => $patientProfile['name'],
|
||||
'mobile' => $patientProfile['mobile'],
|
||||
'gender' => $patientProfile['gender'],
|
||||
'year_of_birth' => $patientProfile['year_of_birth'],
|
||||
],
|
||||
'care_contexts' => [[
|
||||
'reference_number' => (string) ($record['care_context_reference'] ?? ''),
|
||||
'display' => (string) ($record['care_context_display'] ?? $recordType),
|
||||
'record_type' => $recordType,
|
||||
'visit_date' => $this->normalizeVisitDateTime((string) ($record['visit_date'] ?? '')),
|
||||
'doctor_name' => (string) ($record['doctor_name'] ?? ''),
|
||||
'department' => (string) ($record['department'] ?? ''),
|
||||
]],
|
||||
|
||||
// NAT deployment: the bridge answers ABDM discovery/data requests from pushed
|
||||
// records, so /api/v3/records/push is the only call needed (v1 link is retired).
|
||||
$recordPayload = [
|
||||
'hfr_id' => $hfrId,
|
||||
'hi_type' => (string) ($record['hi_type'] ?? $recordType),
|
||||
'care_context_reference' => (string) ($record['care_context_reference'] ?? ''),
|
||||
'care_context_display' => (string) ($record['care_context_display'] ?? $recordType),
|
||||
'visit_date' => $this->normalizeVisitDateTime((string) ($record['visit_date'] ?? '')),
|
||||
'doctor_name' => (string) ($record['doctor_name'] ?? ''),
|
||||
'department' => (string) ($record['department'] ?? ''),
|
||||
'patient_name' => $patientProfile['name'],
|
||||
'gender' => $patientProfile['gender'],
|
||||
'mobile' => $patientProfile['mobile'],
|
||||
'fhir_bundle' => is_array($bundle) ? $bundle : null,
|
||||
];
|
||||
|
||||
$abhaAddress = trim((string) ($patientProfile['abha_address'] ?? ''));
|
||||
if ($abhaAddress !== '') {
|
||||
$careContextPayload['patient']['abha_address'] = $abhaAddress;
|
||||
$recordPayload['abha_address'] = $abhaAddress;
|
||||
}
|
||||
|
||||
$abhaNumber = trim((string) ($patientProfile['abha_number'] ?? ''));
|
||||
if ($abhaNumber !== '') {
|
||||
$careContextPayload['patient']['abha_number'] = $abhaNumber;
|
||||
$recordPayload['abha_id'] = $abhaNumber;
|
||||
}
|
||||
|
||||
$result = $this->gatewayClient->pushCareContextLink($careContextPayload, $idempotencyKey);
|
||||
$result = $this->gatewayClient->pushRecord($recordPayload, $idempotencyKey);
|
||||
|
||||
$this->recordModel->update($syncRecordId, [
|
||||
'sync_status' => (bool) ($result['ok'] ?? false) ? 'done' : ((bool) ($result['retryable'] ?? false) ? 'failed' : 'dead'),
|
||||
|
|
|
|||
|
|
@ -1235,6 +1235,37 @@ class FhirR4Builder
|
|||
if ($organizationRef !== '') {
|
||||
$encounterResource['serviceProvider'] = ['reference' => $organizationRef];
|
||||
}
|
||||
|
||||
// ABDM rejects a DischargeSummaryRecord without at least one Condition or
|
||||
// Procedure resource, so the diagnosis must be a resource and not just narrative.
|
||||
$diagnosisResourceText = trim((string) (
|
||||
$summary['diagnosis_text']
|
||||
?? $summary['final_diagnosis']
|
||||
?? $summary['chief_complaints']
|
||||
?? ''
|
||||
));
|
||||
$conditionRef = '';
|
||||
if ($diagnosisResourceText !== '') {
|
||||
$conditionUuid = $this->generateUuid();
|
||||
$conditionRef = 'urn:uuid:' . $conditionUuid;
|
||||
|
||||
$encounterResource['diagnosis'] = [[
|
||||
'condition' => ['reference' => $conditionRef, 'display' => 'Condition'],
|
||||
]];
|
||||
|
||||
$resourceEntries[] = ['fullUrl' => $conditionRef, 'resource' => [
|
||||
'resourceType' => 'Condition',
|
||||
'id' => $conditionUuid,
|
||||
'meta' => ['profile' => ['https://nrces.in/ndhm/fhir/r4/StructureDefinition/Condition']],
|
||||
'clinicalStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-clinical', 'code' => 'active', 'display' => 'Active']]],
|
||||
'verificationStatus' => ['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-ver-status', 'code' => 'confirmed', 'display' => 'Confirmed']]],
|
||||
'category' => [['coding' => [['system' => 'http://terminology.hl7.org/CodeSystem/condition-category', 'code' => 'encounter-diagnosis', 'display' => 'Encounter Diagnosis']]]],
|
||||
'code' => ['text' => $diagnosisResourceText],
|
||||
'subject' => ['reference' => $patientRef, 'display' => 'Patient'],
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
]];
|
||||
}
|
||||
|
||||
$resourceEntries[] = ['fullUrl' => $encounterRef, 'resource' => $encounterResource];
|
||||
|
||||
// ── Composition sections per DischargeSummaryRecord spec ──────────────
|
||||
|
|
@ -1264,6 +1295,15 @@ class FhirR4Builder
|
|||
];
|
||||
}
|
||||
|
||||
if ($conditionRef !== '') {
|
||||
$sections[] = [
|
||||
'title' => 'Diagnosis',
|
||||
'code' => ['coding' => [['system' => 'http://snomed.info/sct', 'code' => '721981007', 'display' => 'Diagnosis']]],
|
||||
'entry' => [['reference' => $conditionRef, 'display' => 'Condition']],
|
||||
'text' => ['status' => 'generated', 'div' => '<div xmlns="http://www.w3.org/1999/xhtml">' . htmlspecialchars($diagnosisResourceText) . '</div>'],
|
||||
];
|
||||
}
|
||||
|
||||
// Investigations section (SNOMED 721981007)
|
||||
$investigationsText = trim((string) ($summary['investigations_text'] ?? ''));
|
||||
if ($investigationsText !== '') {
|
||||
|
|
@ -1434,11 +1474,81 @@ class FhirR4Builder
|
|||
]],
|
||||
];
|
||||
|
||||
// ABDM validates InvoiceRecord on a FHIR Invoice resource; Claim alone is rejected.
|
||||
$invoiceLineItems = [];
|
||||
foreach ($claimItems as $index => $claimItem) {
|
||||
$invoiceLineItems[] = [
|
||||
'sequence' => $index + 1,
|
||||
'chargeItemCodeableConcept' => $claimItem['productOrService'],
|
||||
'priceComponent' => [[
|
||||
'type' => 'base',
|
||||
'amount' => $claimItem['net'],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
$invoiceResource = [
|
||||
'resourceType' => 'Invoice',
|
||||
'id' => 'invoice-' . $claimId,
|
||||
'status' => (string) ($claim['invoice_status'] ?? 'issued'),
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'date' => $issuedAt,
|
||||
'totalGross' => [
|
||||
'value' => (float) ($claim['total'] ?? 0),
|
||||
'currency' => (string) ($claim['currency'] ?? 'INR'),
|
||||
],
|
||||
'totalNet' => [
|
||||
'value' => (float) ($claim['total'] ?? 0),
|
||||
'currency' => (string) ($claim['currency'] ?? 'INR'),
|
||||
],
|
||||
];
|
||||
|
||||
$invoiceNo = trim((string) ($claim['invoice_no'] ?? ''));
|
||||
if ($invoiceNo !== '') {
|
||||
$invoiceResource['identifier'] = [['system' => 'https://ndhm.in/invoice', 'value' => $invoiceNo]];
|
||||
}
|
||||
if ($invoiceLineItems !== []) {
|
||||
$invoiceResource['lineItem'] = $invoiceLineItems;
|
||||
}
|
||||
|
||||
// ABDM InvoiceRecord must be a document bundle led by a Composition.
|
||||
$composition = [
|
||||
'resourceType' => 'Composition',
|
||||
'id' => 'comp-invoice-' . $claimId,
|
||||
'status' => 'final',
|
||||
'type' => [
|
||||
'coding' => [[
|
||||
'system' => 'http://snomed.info/sct',
|
||||
'code' => '736762002',
|
||||
'display' => 'Invoice record',
|
||||
]],
|
||||
'text' => 'Invoice',
|
||||
],
|
||||
'subject' => ['reference' => $patientRef],
|
||||
'date' => $issuedAt,
|
||||
'title' => 'Invoice',
|
||||
'encounter' => ['reference' => $encounterRef],
|
||||
'section' => [[
|
||||
'title' => 'Invoice',
|
||||
'entry' => [
|
||||
['reference' => 'Invoice/invoice-' . $claimId],
|
||||
['reference' => 'Claim/' . $claimId],
|
||||
],
|
||||
]],
|
||||
];
|
||||
|
||||
$provider = trim((string) ($claim['provider'] ?? ''));
|
||||
if ($provider !== '') {
|
||||
$composition['author'] = [['display' => $provider]];
|
||||
}
|
||||
|
||||
return [
|
||||
'resourceType' => 'Bundle',
|
||||
'type' => 'collection',
|
||||
'type' => 'document',
|
||||
'timestamp' => $issuedAt,
|
||||
'entry' => [[
|
||||
'resource' => $composition,
|
||||
], [
|
||||
'resource' => $this->buildPatientResource($patient),
|
||||
], [
|
||||
'resource' => [
|
||||
|
|
@ -1447,6 +1557,8 @@ class FhirR4Builder
|
|||
'status' => (string) ($encounter['status'] ?? 'finished'),
|
||||
'subject' => ['reference' => $patientRef],
|
||||
],
|
||||
], [
|
||||
'resource' => $invoiceResource,
|
||||
], [
|
||||
'resource' => $claimResource,
|
||||
]],
|
||||
|
|
@ -2749,7 +2861,8 @@ class FhirR4Builder
|
|||
if ($practitionerRef !== '') {
|
||||
$composition['author'] = [['reference' => $practitionerRef]];
|
||||
}
|
||||
$entries[] = ['resource' => $composition];
|
||||
// ABDM requires the Composition to be the first bundle entry.
|
||||
array_unshift($entries, ['resource' => $composition]);
|
||||
|
||||
return [
|
||||
'resourceType' => 'Bundle',
|
||||
|
|
|
|||
|
|
@ -193,6 +193,26 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-2">
|
||||
<div class="small fw-semibold mb-1">Share Additional HI Types</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<input type="number" min="1" class="form-control form-control-sm" id="tb_hi_patient_id" placeholder="Patient ID">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="number" min="1" class="form-control form-control-sm" id="tb_hi_opd_id" placeholder="OPD ID (wellness)">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="number" min="1" class="form-control form-control-sm" id="tb_hi_invoice_id" placeholder="Invoice ID">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" data-hi-share="immunization">Immunization</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm" data-hi-share="wellness">Wellness</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm" data-hi-share="health_document">Health Document</button>
|
||||
<button type="button" class="btn btn-outline-info btn-sm" data-hi-share="invoice">Invoice</button>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted" id="abdmSandboxStatus">Ready</div>
|
||||
<div class="mt-2 d-none" id="abdmScanResolvePane"></div>
|
||||
</div>
|
||||
|
|
@ -1296,6 +1316,51 @@
|
|||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-hi-share]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var kind = btn.getAttribute('data-hi-share');
|
||||
var patientId = parseInt((document.getElementById('tb_hi_patient_id').value || '0'), 10) || 0;
|
||||
var opdId = parseInt((document.getElementById('tb_hi_opd_id').value || '0'), 10) || 0;
|
||||
var invoiceId = parseInt((document.getElementById('tb_hi_invoice_id').value || '0'), 10) || 0;
|
||||
|
||||
if (kind !== 'invoice' && patientId <= 0) {
|
||||
setSandboxStatus('Enter Patient ID first.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (kind === 'invoice' && invoiceId <= 0) {
|
||||
setSandboxStatus('Enter Invoice ID first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
var urls = {
|
||||
immunization: '<?= base_url('AbdmGateway/share_immunization_bundle') ?>',
|
||||
wellness: '<?= base_url('AbdmGateway/share_wellness_bundle') ?>',
|
||||
health_document: '<?= base_url('AbdmGateway/share_health_document_bundle') ?>',
|
||||
invoice: '<?= base_url('AbdmGateway/share_invoice_bundle') ?>'
|
||||
};
|
||||
|
||||
var payload = { patient_id: patientId, consent_handle: '' };
|
||||
if (kind === 'wellness') { payload.opd_id = opdId; }
|
||||
if (kind === 'invoice') { payload.invoice_id = invoiceId; }
|
||||
|
||||
var original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sharing';
|
||||
setSandboxStatus('Sharing ' + kind + ' record...');
|
||||
|
||||
post(urls[kind], payload, function (res) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
if (!res || parseInt(res.ok || '0', 10) !== 1) {
|
||||
setSandboxStatus(res.error_text || res.error || ('Failed to share ' + kind + ' record.'), 'danger');
|
||||
return;
|
||||
}
|
||||
setSandboxStatus(kind + ' shared. Care context: ' + (res.care_context_reference || '-')
|
||||
+ (res.queue_id ? (' | Queue: ' + res.queue_id) : ''), 'success');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btnHideFhirDebug').addEventListener('click', function () {
|
||||
if (fhirDebugCard) {
|
||||
fhirDebugCard.classList.add('d-none');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user