851 lines
36 KiB
PHP
851 lines
36 KiB
PHP
<?php
|
||
|
||
namespace App\Libraries\Abdm;
|
||
|
||
use CodeIgniter\I18n\Time;
|
||
|
||
class M3HiuDocumentRepository
|
||
{
|
||
private \CodeIgniter\Database\BaseConnection $db;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->db = \Config\Database::connect();
|
||
}
|
||
|
||
public function persistFromDataFetch(array $payload, array $result, int $workflowId = 0): array
|
||
{
|
||
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
||
return ['saved' => 0, 'updated' => 0, 'skipped' => 0];
|
||
}
|
||
|
||
$sessions = $this->extractSessions($result);
|
||
if (empty($sessions)) {
|
||
return ['saved' => 0, 'updated' => 0, 'skipped' => 0];
|
||
}
|
||
|
||
$this->ensureConsentRequestIdColumn();
|
||
|
||
$requestId = trim((string) ($result['request_id'] ?? $payload['request_id'] ?? $payload['requestId'] ?? ''));
|
||
$abhaAddress = trim((string) ($payload['abha_address'] ?? $result['abha_address'] ?? ''));
|
||
// The specific consent request (e.g. Method 1/2's targeted request) that
|
||
// triggered this fetch, when known. Method 3 (fetch-all-by-abha-address)
|
||
// spans many past consent requests at once, so this is intentionally left
|
||
// blank for that broad fetch rather than mis-attributing every document to
|
||
// whichever single request happened to be passed in.
|
||
$consentRequestId = trim((string) (
|
||
$payload['abdm_consent_request_id']
|
||
?? $payload['consent_request_id']
|
||
?? $payload['consentRequestId']
|
||
?? ''
|
||
));
|
||
|
||
$saved = 0;
|
||
$updated = 0;
|
||
$skipped = 0;
|
||
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
||
|
||
foreach ($sessions as $session) {
|
||
if (! is_array($session)) {
|
||
continue;
|
||
}
|
||
|
||
$transactionId = trim((string) ($session['transaction_id'] ?? ''));
|
||
$consentRef = trim((string) ($session['consent_id'] ?? ''));
|
||
$consentArtifactId = $this->extractConsentArtifactId($consentRef);
|
||
$records = is_array($session['decrypted_data'] ?? null) ? (array) $session['decrypted_data'] : [];
|
||
|
||
foreach ($records as $record) {
|
||
if (! is_array($record)) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$careContextRef = trim((string) ($record['careContextReference'] ?? ''));
|
||
$media = trim((string) ($record['media'] ?? ''));
|
||
$bundle = $this->decodeBundle($record['decrypted_data'] ?? null);
|
||
if (! is_array($bundle) || empty($bundle)) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$summary = $this->buildSummary($bundle);
|
||
$resolvedAbha = $abhaAddress !== '' ? $abhaAddress : trim((string) ($summary['abha_address'] ?? ''));
|
||
$patientMap = $this->mapPatientByAbha($resolvedAbha, (string) ($summary['abha_number'] ?? ''));
|
||
|
||
// NOTE: transaction_id and consent_ref are intentionally EXCLUDED
|
||
// from the dedup key. Confirmed empirically: the SAME clinical
|
||
// document (identical bundle_id) gets re-delivered by the bridge
|
||
// under a DIFFERENT transaction_id/consent_ref every time it is
|
||
// re-fetched (e.g. via the by-ABHA-address historical fetch, which
|
||
// returns every past consent session), so including them created a
|
||
// brand-new duplicate row per fetch instead of updating the
|
||
// existing one. bundle_id is the FHIR Bundle resource's own `id`
|
||
// (set once by the HIP) and is stable across re-fetches/consents,
|
||
// so pair it with care_context_reference (the clinical encounter
|
||
// reference) to uniquely identify a document. request_id is also
|
||
// excluded since it is unique per polling/fetch call.
|
||
$bundleId = trim((string) ($summary['bundle_id'] ?? ''));
|
||
$docHash = $bundleId !== ''
|
||
? sha1('bundle:' . $careContextRef . '|' . $bundleId)
|
||
: sha1(implode('|', [
|
||
$transactionId,
|
||
$consentRef,
|
||
$careContextRef,
|
||
(string) ($summary['bundle_id'] ?? ''),
|
||
]));
|
||
|
||
$row = [
|
||
'workflow_id' => $workflowId > 0 ? $workflowId : null,
|
||
'request_id' => $requestId !== '' ? $requestId : null,
|
||
'transaction_id' => $transactionId !== '' ? $transactionId : null,
|
||
'consent_ref' => $consentRef !== '' ? $consentRef : null,
|
||
'consent_artifact_id' => $consentArtifactId !== '' ? $consentArtifactId : null,
|
||
'abha_address' => $resolvedAbha !== '' ? $resolvedAbha : null,
|
||
'patient_id' => (int) ($patientMap['patient_id'] ?? 0) > 0 ? (int) $patientMap['patient_id'] : null,
|
||
'patient_name' => trim((string) ($summary['patient_name'] ?? '')) !== '' ? trim((string) ($summary['patient_name'] ?? '')) : null,
|
||
'care_context_reference' => $careContextRef !== '' ? $careContextRef : null,
|
||
'media' => $media !== '' ? $media : null,
|
||
'bundle_id' => trim((string) ($summary['bundle_id'] ?? '')) !== '' ? trim((string) ($summary['bundle_id'] ?? '')) : null,
|
||
'bundle_type' => trim((string) ($summary['bundle_type'] ?? '')) !== '' ? trim((string) ($summary['bundle_type'] ?? '')) : null,
|
||
'document_title' => trim((string) ($summary['document_title'] ?? '')) !== '' ? trim((string) ($summary['document_title'] ?? '')) : null,
|
||
'document_date' => $this->toDateTimeString((string) ($summary['document_date'] ?? '')),
|
||
'practitioner_name' => trim((string) ($summary['practitioner_name'] ?? '')) !== '' ? trim((string) ($summary['practitioner_name'] ?? '')) : null,
|
||
'organization_name' => trim((string) ($summary['organization_name'] ?? '')) !== '' ? trim((string) ($summary['organization_name'] ?? '')) : null,
|
||
'summary_json' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
'raw_bundle' => json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
'updated_at' => $now,
|
||
];
|
||
|
||
// Only set (and never blank out on update) when this specific fetch
|
||
// actually knew which consent request it belonged to.
|
||
if ($consentRequestId !== '') {
|
||
$row['consent_request_id'] = $consentRequestId;
|
||
}
|
||
|
||
$existing = $this->db->table('abdm_hiu_documents')
|
||
->select('id')
|
||
->where('doc_hash', $docHash)
|
||
->get(1)
|
||
->getRowArray();
|
||
|
||
if (! empty($existing)) {
|
||
$this->db->table('abdm_hiu_documents')->where('id', (int) $existing['id'])->update($row);
|
||
$updated++;
|
||
continue;
|
||
}
|
||
|
||
$row['doc_hash'] = $docHash;
|
||
$row['created_at'] = $now;
|
||
$this->db->table('abdm_hiu_documents')->insert($row);
|
||
$saved++;
|
||
}
|
||
}
|
||
|
||
return ['saved' => $saved, 'updated' => $updated, 'skipped' => $skipped];
|
||
}
|
||
|
||
/**
|
||
* Idempotent backfill so deployments that predate the Consent Request ID
|
||
* enrichment (added 2026-07-29) don't fail with "Unknown column" on save.
|
||
*/
|
||
private function ensureConsentRequestIdColumn(): void
|
||
{
|
||
$fields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
|
||
if (in_array('consent_request_id', $fields, true)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$this->db->query('ALTER TABLE abdm_hiu_documents ADD COLUMN consent_request_id VARCHAR(190) NULL AFTER consent_artifact_id');
|
||
} catch (\Throwable $e) {
|
||
// Best-effort; if the ALTER fails (e.g. concurrent request already
|
||
// added it, or insufficient DB privileges) just skip enrichment for
|
||
// this call rather than breaking the fetch.
|
||
}
|
||
}
|
||
|
||
public function listDocuments(array $filters, int $limit = 100): array
|
||
{
|
||
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
||
return [];
|
||
}
|
||
|
||
$builder = $this->db->table('abdm_hiu_documents')
|
||
->select('*')
|
||
->orderBy('id', 'DESC')
|
||
->limit($limit);
|
||
|
||
$patientId = (int) ($filters['patient_id'] ?? 0);
|
||
if ($patientId > 0) {
|
||
$builder->where('patient_id', $patientId);
|
||
}
|
||
|
||
$abhaAddress = trim((string) ($filters['abha_address'] ?? ''));
|
||
if ($abhaAddress !== '') {
|
||
$builder->where('abha_address', $abhaAddress);
|
||
}
|
||
|
||
$q = trim((string) ($filters['q'] ?? ''));
|
||
if ($q !== '') {
|
||
$builder->groupStart()
|
||
->like('patient_name', $q)
|
||
->orLike('care_context_reference', $q)
|
||
->orLike('document_title', $q)
|
||
->orLike('practitioner_name', $q)
|
||
->orLike('organization_name', $q)
|
||
->groupEnd();
|
||
}
|
||
|
||
return $builder->get()->getResultArray();
|
||
}
|
||
|
||
public function getDocument(int $id): ?array
|
||
{
|
||
if ($id <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) {
|
||
return null;
|
||
}
|
||
|
||
$row = $this->db->table('abdm_hiu_documents')->where('id', $id)->get(1)->getRowArray();
|
||
if (empty($row)) {
|
||
return null;
|
||
}
|
||
|
||
$row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: [];
|
||
$row['bundle'] = json_decode((string) ($row['raw_bundle'] ?? ''), true) ?: [];
|
||
|
||
// Documents saved before attachment extraction was added won't have
|
||
// 'attachments' in their stored summary_json; derive it live from the
|
||
// raw bundle instead of requiring a brand-new fetch.
|
||
if (empty($row['summary']['attachments']) && ! empty($row['bundle'])) {
|
||
$row['summary']['attachments'] = $this->extractAttachments($row['bundle']);
|
||
}
|
||
|
||
return $row;
|
||
}
|
||
|
||
private function extractSessions(array $result): array
|
||
{
|
||
$sessions = $result['sessions'] ?? null;
|
||
if (is_array($sessions)) {
|
||
return $sessions;
|
||
}
|
||
|
||
$nested = $result['data']['sessions'] ?? null;
|
||
return is_array($nested) ? $nested : [];
|
||
}
|
||
|
||
private function extractConsentArtifactId(string $consentRef): string
|
||
{
|
||
$consentRef = trim($consentRef);
|
||
if ($consentRef === '') {
|
||
return '';
|
||
}
|
||
$parts = explode(':', $consentRef);
|
||
return trim((string) ($parts[0] ?? ''));
|
||
}
|
||
|
||
private function decodeBundle($raw): array
|
||
{
|
||
if (is_array($raw)) {
|
||
return $raw;
|
||
}
|
||
|
||
$text = trim((string) $raw);
|
||
if ($text === '') {
|
||
return [];
|
||
}
|
||
|
||
$decoded = json_decode($text, true);
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
|
||
private function buildSummary(array $bundle): array
|
||
{
|
||
$entries = is_array($bundle['entry'] ?? null) ? (array) $bundle['entry'] : [];
|
||
$patient = [];
|
||
$composition = [];
|
||
$practitioner = [];
|
||
$organization = [];
|
||
$conditions = [];
|
||
$vitals = [];
|
||
$medications = [];
|
||
// Indexed by "ResourceType/id" and by fullUrl, so Composition.section[].entry
|
||
// references (e.g. {"reference": "Condition/xyz"}) can be resolved back to
|
||
// their actual resource below.
|
||
$resourcesByRef = [];
|
||
|
||
foreach ($entries as $entry) {
|
||
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
|
||
$type = trim((string) ($resource['resourceType'] ?? ''));
|
||
if ($type === '') {
|
||
continue;
|
||
}
|
||
|
||
$resourceId = trim((string) ($resource['id'] ?? ''));
|
||
if ($resourceId !== '') {
|
||
$resourcesByRef[$type . '/' . $resourceId] = $resource;
|
||
$resourcesByRef[$resourceId] = $resource;
|
||
}
|
||
$fullUrl = trim((string) ($entry['fullUrl'] ?? ''));
|
||
if ($fullUrl !== '') {
|
||
$resourcesByRef[$fullUrl] = $resource;
|
||
if (str_starts_with($fullUrl, 'urn:uuid:')) {
|
||
$resourcesByRef[substr($fullUrl, 9)] = $resource;
|
||
}
|
||
}
|
||
|
||
if ($type === 'Patient' && empty($patient)) {
|
||
$patient = $resource;
|
||
} elseif ($type === 'Composition' && empty($composition)) {
|
||
$composition = $resource;
|
||
} elseif ($type === 'Practitioner' && empty($practitioner)) {
|
||
$practitioner = $resource;
|
||
} elseif ($type === 'Organization' && empty($organization)) {
|
||
$organization = $resource;
|
||
} elseif ($type === 'Condition') {
|
||
$conditions[] = [
|
||
'text' => trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? ''))),
|
||
];
|
||
} elseif ($type === 'Observation') {
|
||
$codeText = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
|
||
$val = '';
|
||
if (isset($resource['valueQuantity']) && is_array($resource['valueQuantity'])) {
|
||
$val = trim((string) (($resource['valueQuantity']['value'] ?? '') . ' ' . ($resource['valueQuantity']['unit'] ?? '')));
|
||
} elseif (isset($resource['valueString'])) {
|
||
$val = trim((string) $resource['valueString']);
|
||
}
|
||
if ($codeText !== '' || $val !== '') {
|
||
$vitals[] = ['name' => $codeText, 'value' => $val];
|
||
}
|
||
} elseif ($type === 'MedicationRequest') {
|
||
$medications[] = [
|
||
'name' => trim((string) (($resource['medicationCodeableConcept']['text'] ?? '') ?: ($resource['medicationCodeableConcept']['coding'][0]['display'] ?? ''))),
|
||
'dose' => trim((string) ($resource['dosageInstruction'][0]['text'] ?? '')),
|
||
];
|
||
}
|
||
}
|
||
|
||
$patientName = trim((string) (($patient['name'][0]['text'] ?? '') ?: implode(' ', (array) ($patient['name'][0]['given'] ?? []))));
|
||
$abhaNumber = '';
|
||
foreach ((array) ($patient['identifier'] ?? []) as $identifier) {
|
||
$system = trim((string) ($identifier['system'] ?? ''));
|
||
if (stripos($system, 'healthid.ndhm.gov.in') !== false) {
|
||
$abhaNumber = preg_replace('/\D+/', '', (string) ($identifier['value'] ?? '')) ?? '';
|
||
break;
|
||
}
|
||
}
|
||
|
||
$practitionerName = trim((string) (($practitioner['name'][0]['text'] ?? '') ?: implode(' ', (array) ($practitioner['name'][0]['given'] ?? []))));
|
||
$organizationName = trim((string) ($organization['name'] ?? ''));
|
||
|
||
return [
|
||
'bundle_id' => trim((string) ($bundle['id'] ?? '')),
|
||
'bundle_type' => trim((string) (($bundle['resourceType'] ?? '') . '/' . ($bundle['type'] ?? ''))),
|
||
'document_title' => trim((string) ($composition['title'] ?? '')),
|
||
'document_date' => trim((string) ($composition['date'] ?? $bundle['timestamp'] ?? '')),
|
||
'patient_name' => $patientName,
|
||
'patient_gender' => trim((string) ($patient['gender'] ?? '')),
|
||
'patient_birth_date' => trim((string) ($patient['birthDate'] ?? '')),
|
||
'abha_number' => $abhaNumber,
|
||
'abha_address' => $abhaNumber !== '' ? ($abhaNumber . '@sbx') : '',
|
||
'practitioner_name' => $practitionerName,
|
||
'organization_name' => $organizationName,
|
||
'conditions' => $conditions,
|
||
'vitals' => $vitals,
|
||
'medications' => $medications,
|
||
'attachments' => $this->extractAttachments($bundle),
|
||
// Mirrors the Composition's own section breakdown (Chief Complaints,
|
||
// Physical Examination, Allergies, Investigations, Medications,
|
||
// Procedures, Medical History, Care Plan, Documents, etc.) so the UI
|
||
// can render an accordion per record exactly like the source document
|
||
// is organized, instead of a fixed hard-coded set of categories.
|
||
'sections' => $this->buildCompositionSections($composition, $resourcesByRef),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Resolves each Composition.section[].entry[] reference to its underlying
|
||
* resource and produces a short display label for it, falling back to the
|
||
* section's own narrative text (section.text.div) when there are no
|
||
* resolvable entries (some HIPs only populate the narrative).
|
||
*
|
||
* @return array<int, array{title: string, items: array<int, string>, narrative: string}>
|
||
*/
|
||
private function buildCompositionSections(array $composition, array $resourcesByRef): array
|
||
{
|
||
$sections = [];
|
||
|
||
foreach ((array) ($composition['section'] ?? []) as $section) {
|
||
if (! is_array($section)) {
|
||
continue;
|
||
}
|
||
|
||
$title = trim((string) (($section['title'] ?? '') ?: ($section['code']['text'] ?? '')));
|
||
if ($title === '') {
|
||
continue;
|
||
}
|
||
|
||
$items = [];
|
||
foreach ((array) ($section['entry'] ?? []) as $ref) {
|
||
if (! is_array($ref)) {
|
||
continue;
|
||
}
|
||
$refStr = trim((string) ($ref['reference'] ?? ''));
|
||
if ($refStr === '') {
|
||
continue;
|
||
}
|
||
$matchedResource = $resourcesByRef[$refStr]
|
||
?? $resourcesByRef['urn:uuid:' . $refStr]
|
||
?? (str_starts_with($refStr, 'urn:uuid:') ? ($resourcesByRef[substr($refStr, 9)] ?? null) : null);
|
||
if (! is_array($matchedResource)) {
|
||
continue;
|
||
}
|
||
foreach ($this->describeResourceForSection($matchedResource, $resourcesByRef) as $label) {
|
||
if ($label !== '') {
|
||
$items[] = $label;
|
||
}
|
||
}
|
||
}
|
||
|
||
$narrative = '';
|
||
if ($items === []) {
|
||
$div = trim((string) ($section['text']['div'] ?? ''));
|
||
if ($div !== '') {
|
||
$div = preg_replace('/<li[^>]*>/i', "\n- ", $div) ?? $div;
|
||
$div = preg_replace('/<br\s*\/?>/i', "\n", $div) ?? $div;
|
||
$narrative = trim(html_entity_decode(strip_tags($div), ENT_QUOTES | ENT_HTML5));
|
||
}
|
||
}
|
||
|
||
$sections[] = [
|
||
'title' => $title,
|
||
'items' => $items,
|
||
'narrative' => $narrative,
|
||
];
|
||
}
|
||
|
||
return $sections;
|
||
}
|
||
|
||
/**
|
||
* Produces one or more short, human-readable one-line labels for a FHIR
|
||
* resource, for display inside a Composition section's item list. Most
|
||
* resource types resolve to a single line; DiagnosticReport expands into
|
||
* one line per linked Observation result (e.g. individual lab test
|
||
* values) since that's where the actual pathology/lab values live.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
private function describeResourceForSection(array $resource, array $resourcesByRef = []): array
|
||
{
|
||
$type = trim((string) ($resource['resourceType'] ?? ''));
|
||
|
||
switch ($type) {
|
||
case 'Condition':
|
||
case 'AllergyIntolerance':
|
||
case 'Procedure':
|
||
case 'ServiceRequest':
|
||
$label = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
|
||
return $label !== '' ? [$label] : [];
|
||
|
||
case 'Observation':
|
||
$label = $this->describeObservation($resource);
|
||
return $label !== '' ? [$label] : [];
|
||
|
||
case 'MedicationRequest':
|
||
case 'MedicationStatement':
|
||
$med = trim((string) (($resource['medicationCodeableConcept']['text'] ?? '') ?: ($resource['medicationCodeableConcept']['coding'][0]['display'] ?? '')));
|
||
$dose = trim((string) ($resource['dosageInstruction'][0]['text'] ?? ($resource['dosage'][0]['text'] ?? '')));
|
||
$label = $dose !== '' ? ($med . ' — ' . $dose) : $med;
|
||
return $label !== '' ? [$label] : [];
|
||
|
||
case 'DiagnosticReport':
|
||
$name = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
|
||
$conclusion = trim((string) ($resource['conclusion'] ?? ''));
|
||
$header = $conclusion !== '' ? ($name . ' — ' . $conclusion) : $name;
|
||
|
||
$lines = $header !== '' ? [$header] : [];
|
||
foreach ((array) ($resource['result'] ?? []) as $resultRef) {
|
||
if (! is_array($resultRef)) {
|
||
continue;
|
||
}
|
||
$refStr = trim((string) ($resultRef['reference'] ?? ''));
|
||
if ($refStr === '' || ! isset($resourcesByRef[$refStr])) {
|
||
continue;
|
||
}
|
||
$obsResource = (array) $resourcesByRef[$refStr];
|
||
if (trim((string) ($obsResource['resourceType'] ?? '')) !== 'Observation') {
|
||
continue;
|
||
}
|
||
$obsLine = $this->describeObservation($obsResource);
|
||
if ($obsLine !== '') {
|
||
$lines[] = '– ' . $obsLine;
|
||
}
|
||
}
|
||
|
||
return $lines;
|
||
|
||
case 'CarePlan':
|
||
$label = trim((string) (($resource['description'] ?? '') ?: ($resource['title'] ?? '')));
|
||
return $label !== '' ? [$label] : [];
|
||
|
||
case 'DocumentReference':
|
||
$label = trim((string) (($resource['description'] ?? '') ?: ($resource['type']['text'] ?? '') ?: ($resource['type']['coding'][0]['display'] ?? '')));
|
||
return $label !== '' ? [$label] : [];
|
||
|
||
case 'Invoice':
|
||
return $this->describeInvoice($resource, $resourcesByRef);
|
||
|
||
case 'ChargeItem':
|
||
$itemName = trim((string) (
|
||
($resource['code']['text'] ?? '')
|
||
?: ($resource['productCodeableConcept']['text'] ?? '')
|
||
?: ($resource['code']['coding'][0]['display'] ?? '')
|
||
));
|
||
$qtyVal = $resource['quantity']['value'] ?? null;
|
||
$qtyUnit = trim((string) ($resource['quantity']['unit'] ?? 'unit'));
|
||
$qtyStr = $qtyVal !== null ? ($qtyVal . ' ' . $qtyUnit) : '';
|
||
$label = 'Item: ' . $itemName . ($qtyStr !== '' ? (' — Qty: ' . $qtyStr) : '');
|
||
return $label !== '' ? [$label] : [];
|
||
|
||
case 'Binary':
|
||
return ['Attached PDF document (available under Attached Files below)'];
|
||
|
||
default:
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Formats an ABDM FHIR Invoice resource and its line items / charge items
|
||
* into user-friendly bullet points matching the PHR app and hospital view.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
private function describeInvoice(array $resource, array $resourcesByRef = []): array
|
||
{
|
||
$lines = [];
|
||
$currency = trim((string) (
|
||
$resource['totalNet']['currency']
|
||
?? $resource['totalGross']['currency']
|
||
?? 'INR'
|
||
));
|
||
|
||
$lineItems = (array) ($resource['lineItem'] ?? []);
|
||
foreach ($lineItems as $idx => $line) {
|
||
if (! is_array($line)) {
|
||
continue;
|
||
}
|
||
|
||
// 1. Resolve item name
|
||
$itemName = '';
|
||
$chargeRefStr = trim((string) ($line['chargeItemReference']['reference'] ?? ''));
|
||
$linkedCharge = null;
|
||
if ($chargeRefStr !== '') {
|
||
$linkedCharge = $resourcesByRef[$chargeRefStr]
|
||
?? $resourcesByRef['urn:uuid:' . $chargeRefStr]
|
||
?? (str_starts_with($chargeRefStr, 'urn:uuid:') ? ($resourcesByRef[substr($chargeRefStr, 9)] ?? null) : null);
|
||
}
|
||
|
||
if (is_array($linkedCharge)) {
|
||
$itemName = trim((string) (
|
||
($linkedCharge['code']['text'] ?? '')
|
||
?: ($linkedCharge['productCodeableConcept']['text'] ?? '')
|
||
?: ($linkedCharge['code']['coding'][0]['display'] ?? '')
|
||
));
|
||
}
|
||
if ($itemName === '') {
|
||
$itemName = trim((string) (
|
||
($line['chargeItemReference']['display'] ?? '')
|
||
?: ($line['chargeItemCodeableConcept']['text'] ?? '')
|
||
?: ($line['chargeItemCodeableConcept']['coding'][0]['display'] ?? '')
|
||
));
|
||
}
|
||
if ($itemName === '') {
|
||
$itemName = 'Item #' . ($idx + 1);
|
||
}
|
||
|
||
// 2. Resolve quantity
|
||
$quantityVal = null;
|
||
$quantityUnit = '';
|
||
if (is_array($linkedCharge) && isset($linkedCharge['quantity']) && is_array($linkedCharge['quantity'])) {
|
||
$quantityVal = $linkedCharge['quantity']['value'] ?? null;
|
||
$quantityUnit = trim((string) ($linkedCharge['quantity']['unit'] ?? ''));
|
||
}
|
||
if ($quantityVal === null && isset($line['quantity']) && is_array($line['quantity'])) {
|
||
$quantityVal = $line['quantity']['value'] ?? null;
|
||
$quantityUnit = trim((string) ($line['quantity']['unit'] ?? ''));
|
||
}
|
||
if ($quantityUnit === '') {
|
||
$quantityUnit = 'unit';
|
||
}
|
||
$quantityStr = $quantityVal !== null ? ($quantityVal . ' ' . $quantityUnit) : '1 unit';
|
||
|
||
// 3. Resolve rate / amount
|
||
$rateStr = '';
|
||
$priceComponents = (array) ($line['priceComponent'] ?? []);
|
||
foreach ($priceComponents as $comp) {
|
||
if (is_array($comp) && isset($comp['amount']['value'])) {
|
||
$amt = $comp['amount']['value'];
|
||
$curr = trim((string) ($comp['amount']['currency'] ?? $currency));
|
||
$rateStr = $amt . ($curr !== '' ? (' ' . $curr) : '');
|
||
break;
|
||
}
|
||
}
|
||
if ($rateStr === '' && is_array($linkedCharge)) {
|
||
if (isset($linkedCharge['priceOverride']['value'])) {
|
||
$amt = $linkedCharge['priceOverride']['value'];
|
||
$curr = trim((string) ($linkedCharge['priceOverride']['currency'] ?? $currency));
|
||
$rateStr = $amt . ($curr !== '' ? (' ' . $curr) : '');
|
||
} elseif (isset($linkedCharge['net']['value'])) {
|
||
$amt = $linkedCharge['net']['value'];
|
||
$curr = trim((string) ($linkedCharge['net']['currency'] ?? $currency));
|
||
$rateStr = $amt . ($curr !== '' ? (' ' . $curr) : '');
|
||
}
|
||
}
|
||
|
||
$part = 'Item: ' . $itemName . ' — Qty: ' . $quantityStr;
|
||
if ($rateStr !== '') {
|
||
$part .= ' — Rate: ' . $rateStr;
|
||
}
|
||
$lines[] = $part;
|
||
}
|
||
|
||
// 4. Totals (Total Net / Total Gross)
|
||
$net = $resource['totalNet']['value'] ?? null;
|
||
$gross = $resource['totalGross']['value'] ?? null;
|
||
if ($net !== null) {
|
||
$lines[] = 'Total Net: ' . $net . ' ' . $currency;
|
||
}
|
||
if ($gross !== null) {
|
||
$lines[] = 'Total Gross: ' . $gross . ' ' . $currency;
|
||
}
|
||
|
||
return $lines;
|
||
}
|
||
|
||
/**
|
||
* Builds a single "Test name: value unit (Flag)" style line for an
|
||
* Observation resource, covering the common FHIR value shapes used by
|
||
* lab/pathology reports (valueQuantity, valueString, valueCodeableConcept,
|
||
* valueInteger/valueBoolean, and multi-component observations like Blood
|
||
* Pressure which report systolic/diastolic as separate `component` entries
|
||
* instead of a single top-level value).
|
||
*/
|
||
private function describeObservation(array $resource): string
|
||
{
|
||
$name = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
|
||
|
||
$formatValue = static function (array $obs): string {
|
||
if (isset($obs['valueQuantity']) && is_array($obs['valueQuantity'])) {
|
||
return trim((string) (($obs['valueQuantity']['value'] ?? '') . ' ' . ($obs['valueQuantity']['unit'] ?? ($obs['valueQuantity']['code'] ?? ''))));
|
||
}
|
||
if (isset($obs['valueString'])) {
|
||
return trim((string) $obs['valueString']);
|
||
}
|
||
if (isset($obs['valueCodeableConcept'])) {
|
||
return trim((string) (($obs['valueCodeableConcept']['text'] ?? '') ?: ($obs['valueCodeableConcept']['coding'][0]['display'] ?? '')));
|
||
}
|
||
if (isset($obs['valueInteger'])) {
|
||
return trim((string) $obs['valueInteger']);
|
||
}
|
||
if (isset($obs['valueBoolean'])) {
|
||
return $obs['valueBoolean'] ? 'Yes' : 'No';
|
||
}
|
||
if (isset($obs['valueDateTime'])) {
|
||
return trim((string) $obs['valueDateTime']);
|
||
}
|
||
if (isset($obs['valueRange']) && is_array($obs['valueRange'])) {
|
||
$low = $obs['valueRange']['low']['value'] ?? '';
|
||
$high = $obs['valueRange']['high']['value'] ?? '';
|
||
return trim($low . ' - ' . $high);
|
||
}
|
||
|
||
return '';
|
||
};
|
||
|
||
$val = $formatValue($resource);
|
||
|
||
// Multi-component observations (e.g. Blood Pressure: systolic + diastolic)
|
||
// carry no top-level value, only a `component[]` array each with its
|
||
// own code + value.
|
||
if ($val === '' && isset($resource['component']) && is_array($resource['component'])) {
|
||
$parts = [];
|
||
foreach ((array) $resource['component'] as $component) {
|
||
if (! is_array($component)) {
|
||
continue;
|
||
}
|
||
$compName = trim((string) (($component['code']['text'] ?? '') ?: ($component['code']['coding'][0]['display'] ?? '')));
|
||
$compVal = $formatValue($component);
|
||
if ($compVal === '') {
|
||
continue;
|
||
}
|
||
$parts[] = $compName !== '' ? ($compName . ': ' . $compVal) : $compVal;
|
||
}
|
||
$val = implode(', ', $parts);
|
||
}
|
||
|
||
$flag = trim((string) (($resource['interpretation'][0]['text'] ?? '') ?: ($resource['interpretation'][0]['coding'][0]['display'] ?? '')));
|
||
|
||
$label = $val !== '' ? ($name . ': ' . $val) : $name;
|
||
if ($flag !== '') {
|
||
$label .= ' (' . $flag . ')';
|
||
}
|
||
|
||
return trim($label, ' :');
|
||
}
|
||
|
||
/**
|
||
* Extracts attached scanned images/PDFs/generic documents (DocumentReference
|
||
* resources, whose content may carry the base64 attachment data inline or
|
||
* reference a separate Binary resource) from a FHIR bundle.
|
||
*
|
||
* @return array<int, array{title: string, content_type: string, data: string}>
|
||
*/
|
||
public function extractAttachments(array $bundle): array
|
||
{
|
||
$entries = is_array($bundle['entry'] ?? null) ? (array) $bundle['entry'] : [];
|
||
$documentReferences = [];
|
||
|
||
// Binary resources are usually referenced by DocumentReference.content[].attachment.url
|
||
// (e.g. "Binary/xyz" or a matching fullUrl) rather than carrying the base64 data inline,
|
||
// so index them by both id and fullUrl for lookup in the second pass below.
|
||
$binariesById = [];
|
||
foreach ($entries as $entry) {
|
||
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
|
||
if (trim((string) ($resource['resourceType'] ?? '')) !== 'Binary') {
|
||
continue;
|
||
}
|
||
$data = trim((string) ($resource['data'] ?? ''));
|
||
$contentType = trim((string) ($resource['contentType'] ?? ''));
|
||
if ($data === '') {
|
||
continue;
|
||
}
|
||
$binary = ['data' => $data, 'content_type' => $contentType];
|
||
$id = trim((string) ($resource['id'] ?? ''));
|
||
if ($id !== '') {
|
||
$binariesById[$id] = $binary;
|
||
}
|
||
$fullUrl = trim((string) ($entry['fullUrl'] ?? ''));
|
||
if ($fullUrl !== '') {
|
||
$binariesById[$fullUrl] = $binary;
|
||
}
|
||
}
|
||
|
||
foreach ($entries as $entry) {
|
||
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
|
||
if (trim((string) ($resource['resourceType'] ?? '')) !== 'DocumentReference') {
|
||
continue;
|
||
}
|
||
|
||
foreach ((array) ($resource['content'] ?? []) as $content) {
|
||
$attachment = is_array($content['attachment'] ?? null) ? (array) $content['attachment'] : [];
|
||
if ($attachment === []) {
|
||
continue;
|
||
}
|
||
|
||
$contentType = trim((string) ($attachment['contentType'] ?? ''));
|
||
$data = trim((string) ($attachment['data'] ?? ''));
|
||
if ($data === '') {
|
||
$refUrl = trim((string) ($attachment['url'] ?? ''));
|
||
$refId = str_replace('Binary/', '', $refUrl);
|
||
$binary = $binariesById[$refUrl] ?? $binariesById[$refId] ?? null;
|
||
if (is_array($binary)) {
|
||
$data = (string) ($binary['data'] ?? '');
|
||
if ($contentType === '') {
|
||
$contentType = (string) ($binary['content_type'] ?? '');
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($data === '') {
|
||
continue;
|
||
}
|
||
|
||
$documentReferences[] = [
|
||
'title' => trim((string) (
|
||
($resource['description'] ?? '')
|
||
?: ($resource['type']['text'] ?? '')
|
||
?: ($resource['type']['coding'][0]['display'] ?? '')
|
||
?: ($attachment['title'] ?? 'Attached Document')
|
||
)),
|
||
'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream',
|
||
'data' => $data,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $documentReferences;
|
||
}
|
||
|
||
private function mapPatientByAbha(string $abhaAddress, string $abhaNumber): array
|
||
{
|
||
if (! $this->db->tableExists('patient_master')) {
|
||
return ['patient_id' => 0];
|
||
}
|
||
|
||
$fields = $this->db->getFieldNames('patient_master') ?? [];
|
||
$idCol = $this->resolveExistingColumn($fields, ['id']);
|
||
if ($idCol === null) {
|
||
return ['patient_id' => 0];
|
||
}
|
||
|
||
$abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']);
|
||
$abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']);
|
||
|
||
if ($abhaAddress !== '' && $abhaAddressCol !== null) {
|
||
$row = $this->db->table('patient_master')
|
||
->select($idCol . ' AS patient_id')
|
||
->where($abhaAddressCol, $abhaAddress)
|
||
->orderBy($idCol, 'DESC')
|
||
->get(1)
|
||
->getRowArray();
|
||
if (! empty($row)) {
|
||
return ['patient_id' => (int) ($row['patient_id'] ?? 0)];
|
||
}
|
||
}
|
||
|
||
$digits = preg_replace('/\D+/', '', $abhaNumber) ?? '';
|
||
if ($digits !== '' && $abhaNumberCol !== null) {
|
||
$row = $this->db->table('patient_master')
|
||
->select($idCol . ' AS patient_id')
|
||
->where($abhaNumberCol, $digits)
|
||
->orderBy($idCol, 'DESC')
|
||
->get(1)
|
||
->getRowArray();
|
||
if (! empty($row)) {
|
||
return ['patient_id' => (int) ($row['patient_id'] ?? 0)];
|
||
}
|
||
}
|
||
|
||
return ['patient_id' => 0];
|
||
}
|
||
|
||
private function resolveExistingColumn(array $fields, array $candidates): ?string
|
||
{
|
||
foreach ($candidates as $candidate) {
|
||
if (in_array($candidate, $fields, true)) {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function toDateTimeString(string $value): ?string
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
$dt = new \DateTime($value);
|
||
return $dt->format('Y-m-d H:i:s');
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|