diff --git a/app/Controllers/AbdmFhirInspector.php b/app/Controllers/AbdmFhirInspector.php index 0f55ded..279f53b 100644 --- a/app/Controllers/AbdmFhirInspector.php +++ b/app/Controllers/AbdmFhirInspector.php @@ -83,12 +83,28 @@ class AbdmFhirInspector extends BaseController $abhaAddress = trim((string) ($this->request->getGet('abha_address') ?? $this->request->getGet('abha') ?? '')); $pCode = trim((string) ($this->request->getGet('p_code') ?? $this->request->getGet('uhid') ?? '')); + if ($abhaAddress !== '' && ! str_contains($abhaAddress, '@')) { + if (is_numeric($abhaAddress) && strlen($abhaAddress) < 14 && $patientId <= 0) { + $patientId = (int) $abhaAddress; + } elseif (preg_match('/^P\d+/i', $abhaAddress) && $pCode === '') { + $pCode = $abhaAddress; + } + } + if ($patientId <= 0 && $abhaAddress === '' && $pCode === '') { return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA Address, Patient ID, or UHID is required']); } // Delegate to AbdmGateway for canonical patient discovery & care contexts try { + if ($patientId > 0) { + $_GET['patient_id'] = (string) $patientId; + $_REQUEST['patient_id'] = (string) $patientId; + } + if ($pCode !== '') { + $_GET['p_code'] = $pCode; + $_REQUEST['p_code'] = $pCode; + } $gateway = new \App\Controllers\AbdmGateway(); $gateway->initController($this->request, $this->response, service('logger')); return $gateway->hipPatientCareContexts(); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index 74544ba..63ba8f6 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -11786,6 +11786,23 @@ class AbdmGateway extends BaseController } } + // Fallback: If pCode was not provided, but abhaAddress or abhaNumber was actually given as a UHID or numeric patient_id + $rawSearch = trim($pCode !== '' ? $pCode : ($abhaAddress !== '' ? $abhaAddress : $abhaNumber)); + if ($rawSearch !== '' && ! str_contains($rawSearch, '@')) { + if (is_numeric($rawSearch)) { + $row = $this->db->table('patient_master')->where('id', (int) $rawSearch)->get(1)->getRowArray(); + if (! empty($row)) { + return $row; + } + } + if (preg_match('/^P\d+/i', $rawSearch)) { + $row = $this->db->table('patient_master')->where('p_code', $rawSearch)->get(1)->getRowArray(); + if (! empty($row)) { + return $row; + } + } + } + $cleanAbhaNumber = preg_replace('/\D/', '', $abhaNumber !== '' ? $abhaNumber : $abhaAddress); $cleanAbhaNumber = strlen($cleanAbhaNumber) === 14 ? $cleanAbhaNumber : ''; @@ -11811,7 +11828,10 @@ class AbdmGateway extends BaseController } elseif ($hasIdCol) { $builder->where('abha_id', $cleanAbhaAddress); } - $row = $builder->get(1)->getRowArray(); + if ($hasIdCol) { + $builder->orderBy("CASE WHEN NULLIF(TRIM(abha_id), '') IS NOT NULL THEN 1 ELSE 2 END", 'ASC', false); + } + $row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); if (! empty($row)) { return $row; } @@ -11827,7 +11847,7 @@ class AbdmGateway extends BaseController } elseif ($hasIdCol) { $builder->where('abha_id', $cleanAbhaNumber); } - $row = $builder->get(1)->getRowArray(); + $row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); if (! empty($row)) { return $row; } @@ -13695,22 +13715,13 @@ class AbdmGateway extends BaseController } } - if (empty($body['patient_ref']) || $body['patient_ref'] === $body['abha_address']) { - if (! empty($body['care_contexts']) && is_array($body['care_contexts'])) { - $firstCc = $body['care_contexts'][0]; - $cRef = trim((string) ($firstCc['ref'] ?? $firstCc['referenceNumber'] ?? $firstCc['reference_number'] ?? '')); - if ($cRef !== '') { - $body['patient_ref'] = $cRef; - } - } - if (empty($body['patient_ref']) || $body['patient_ref'] === $body['abha_address']) { - if (! empty($patientRow['p_code'])) { - $body['patient_ref'] = (string) $patientRow['p_code']; - } elseif ($patientId > 0) { - $body['patient_ref'] = 'P-' . $patientId; - } else { - $body['patient_ref'] = $body['abha_address']; - } + if (empty($body['patient_ref']) || $body['patient_ref'] === $body['abha_address'] || preg_match('/^(OPD|PRESC|INVOICE|LAB|RAD|WELLNESS)-/i', (string) $body['patient_ref'])) { + if (! empty($patientRow['p_code'])) { + $body['patient_ref'] = (string) $patientRow['p_code']; + } elseif ($patientId > 0) { + $body['patient_ref'] = 'P-' . $patientId; + } else { + $body['patient_ref'] = (string) ($body['abha_address'] ?? 'Patient'); } } if (empty($body['display']) || $body['display'] === $body['patient_ref']) { @@ -13806,17 +13817,22 @@ class AbdmGateway extends BaseController // 1. Update patient_master verification status & timestamps if ($patientId > 0 && $this->db->tableExists('patient_master')) { - $pmUp = [ - 'abha_verified_status' => 'LINKED', - 'abdm_linked_at' => $now, - ]; - if (! empty($body['abha_address'])) { + $pmUp = []; + if ($this->db->fieldExists('abha_verified_status', 'patient_master')) { + $pmUp['abha_verified_status'] = 'LINKED'; + } + if ($this->db->fieldExists('abdm_linked_at', 'patient_master')) { + $pmUp['abdm_linked_at'] = $now; + } + if (! empty($body['abha_address']) && $this->db->fieldExists('abha_address', 'patient_master')) { $pmUp['abha_address'] = (string) $body['abha_address']; } - if (! empty($body['abha_number'])) { + if (! empty($body['abha_number']) && $this->db->fieldExists('abha_id', 'patient_master')) { $pmUp['abha_id'] = (string) $body['abha_number']; } - $this->db->table('patient_master')->where('id', $patientId)->update($pmUp); + if ($pmUp !== []) { + $this->db->table('patient_master')->where('id', $patientId)->update($pmUp); + } } // 2. Persist care contexts into record_links so status is instantly marked LINKED in UI @@ -14217,6 +14233,28 @@ class AbdmGateway extends BaseController } } + $multiplePatients = []; + if ($effAbhaAddress !== '' && str_contains($effAbhaAddress, '@') && $this->db->tableExists('patient_master') && $this->db->fieldExists('abha_address', 'patient_master')) { + $otherRows = $this->db->table('patient_master') + ->where('abha_address', $effAbhaAddress) + ->orderBy('id', 'DESC') + ->limit(5) + ->get() + ->getResultArray(); + if (count($otherRows) > 1) { + foreach ($otherRows as $or) { + $multiplePatients[] = [ + 'id' => (int) $or['id'], + 'p_code' => (string) ($or['p_code'] ?? ''), + 'name' => trim(($or['p_fname'] ?? '') . ' ' . ($or['p_lname'] ?? '')), + 'gender' => ((int) ($or['gender'] ?? 0) === 2 || strtoupper((string) ($or['gender'] ?? '')) === 'F') ? 'F' : 'M', + 'dob' => (string) ($or['dob'] ?? ''), + 'is_current' => (int) $or['id'] === $patientId, + ]; + } + } + } + return $this->response->setJSON([ 'ok' => 1, 'patient' => [ @@ -14231,9 +14269,10 @@ class AbdmGateway extends BaseController 'abha_number' => $effAbhaNumber, 'phone' => $cleanPhone, ], - 'care_contexts' => $contextsList, - 'linked_refs' => $linkedRefs, - 'hospital_name' => $hospitalName, + 'care_contexts' => $contextsList, + 'linked_refs' => $linkedRefs, + 'hospital_name' => $hospitalName, + 'multiple_patients' => $multiplePatients, ]); } } diff --git a/app/Controllers/Abha.php b/app/Controllers/Abha.php index 3b1ec47..b460faa 100644 --- a/app/Controllers/Abha.php +++ b/app/Controllers/Abha.php @@ -2149,44 +2149,22 @@ class Abha extends BaseController return $this->response->setJSON(['ok' => 0, 'error_text' => 'Missing ABHA identifier.']); } - // ABHA number must be unique across patient_master — never let two - // patient rows carry the same ABHA. - if ($abhaNumClean !== '') { - $formattedAbha = (strlen($abhaNumClean) === 14) - ? substr($abhaNumClean, 0, 2) . '-' . substr($abhaNumClean, 2, 4) . '-' . substr($abhaNumClean, 6, 4) . '-' . substr($abhaNumClean, 10, 4) - : ''; - $checkVals = array_values(array_filter([$abhaNumClean, $formattedAbha])); - - $conflictBuilder = $db->table('patient_master')->select('id,p_code,p_fname'); - $hasAbhaCheck = false; - $conflictBuilder->groupStart(); - foreach (['abha_id', 'abha_no', 'abha'] as $cand) { - if (in_array($cand, $fields, true)) { - if (! $hasAbhaCheck) { - $conflictBuilder->whereIn($cand, $checkVals); - $hasAbhaCheck = true; - } else { - $conflictBuilder->orWhereIn($cand, $checkVals); - } - } - } - $conflictBuilder->groupEnd(); - - if ($hasAbhaCheck) { - $conflict = $conflictBuilder->get()->getRowArray(); - if ($conflict) { - $conflictId = (int) ($conflict['id'] ?? 0); - if ($action === 'new' || $conflictId !== $patientId) { - return $this->response->setJSON([ - 'ok' => 0, - 'error_text' => 'This ABHA number is already linked to patient ' - . ($conflict['p_code'] ?? '') . ' (' . ($conflict['p_fname'] ?? '') . '). ' - . 'Please select that patient instead.', - 'conflict_patient_id' => $conflictId, - 'conflict_p_code' => (string) ($conflict['p_code'] ?? ''), - ]); - } - } + // ABHA ID and Address must be unique across patient_master — never let two + // patient rows carry the same ABHA number or address. + if ($abhaNumClean !== '' || $abhaAddress !== '') { + $conflict = \App\Libraries\Abdm\AbhaDemographicValidator::checkUniqueness( + $abhaNumClean, + $abhaAddress, + $action === 'existing' ? $patientId : null, + $db + ); + if ($conflict !== null) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => $conflict['error_text'], + 'conflict_patient_id' => $conflict['conflict_patient_id'], + 'conflict_p_code' => $conflict['p_code'], + ]); } } @@ -2208,6 +2186,23 @@ class Abha extends BaseController ]); } + // Enforce demographic validation: Name, Age/DOB, and Gender must match! + $demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch( + $row, + [ + 'name' => $name, + 'gender' => $gender, + 'dob' => $dob, + 'mobile' => $mobile, + ] + ); + if ($demoError !== null) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => $demoError, + ]); + } + $patientInfo = $this->linkAbhaToPatient( $db, $patientId, @@ -2289,7 +2284,16 @@ class Abha extends BaseController if ($existing) { $patientId = (int) ($existing['id'] ?? 0); - return $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); + $demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch($existing, [ + 'name' => $name, + 'gender' => $gender, + 'dob' => $dob, + 'mobile' => $mobile, + ]); + if ($demoError === null) { + return $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); + } + log_message('warning', '[tryAutoLinkByDirectMatch] Skipped auto-link due to demographic mismatch: {msg}', ['msg' => $demoError]); } // Safe repair path for orphan placeholder rows created during prior failed mapping. diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 1a7f477..3a851f5 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -1337,15 +1337,34 @@ class Patient extends BaseController ]); } - // Enforce unique ABHA ID across patients (excluding current patient) - if ($abhaId !== '') { - $conflictAbha = $this->findPatientByAbha($abhaId, $pid); + // Enforce unique ABHA ID and Address across patients (excluding current patient) + if ($abhaId !== '' || $abhaAddress !== '') { + $conflictAbha = \App\Libraries\Abdm\AbhaDemographicValidator::checkUniqueness($abhaId, $abhaAddress, $pid, $this->db); if ($conflictAbha !== null) { return $this->response->setJSON([ 'update' => 0, - 'error_text' => 'This ABHA ID is already linked to patient ' . ($conflictAbha['p_code'] ?? '') . ' (' . ($conflictAbha['p_fname'] ?? '') . '). ABHA ID must be unique to one person.', + 'error_text' => $conflictAbha['error_text'], ]); } + + // Validate demographics if known ABHA profile exists + $knownProfile = $this->resolveKnownAbhaProfile($abhaId, $abhaAddress); + if (! empty($knownProfile)) { + $candidatePatient = [ + 'p_fname' => (string) $this->request->getPost('input_name'), + 'gender' => $this->request->getPost('optionsRadios_gender'), + 'dob' => $this->request->getPost('datepicker_dob'), + 'age' => $this->request->getPost('input_age_year'), + 'mphone1' => $this->request->getPost('input_mphone1'), + ]; + $demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch($candidatePatient, $knownProfile); + if ($demoError !== null) { + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => $demoError, + ]); + } + } } // Enforce unique Aadhaar Number across patients (excluding current patient) @@ -1543,18 +1562,32 @@ class Patient extends BaseController ]); } - // ABHA number must be unique across patient_master — block if another - // patient already carries this exact ABHA id. - if ($abhaId !== '') { - $conflict = $this->findPatientByAbha($abhaId, $pid); + // ABHA ID and Address must be unique across patient_master — block if another + // patient already carries this exact ABHA ID or address. + if ($abhaId !== '' || $abhaAddress !== '') { + $conflict = \App\Libraries\Abdm\AbhaDemographicValidator::checkUniqueness($abhaId, $abhaAddress, $pid, $this->db); if ($conflict !== null) { return $this->response->setJSON([ 'update' => 0, - 'error_text' => 'This ABHA ID is already linked to patient ' - . ($conflict['p_code'] ?? '') . ' (' . ($conflict['p_fname'] ?? '') . '). ' - . 'An ABHA ID can only be linked to one patient.', + 'error_text' => $conflict['error_text'], ]); } + + // Enforce demographic matching if known ABHA profile exists + $patientModel = new PatientModel(); + $patRow = $patientModel->find($pid); + if (is_array($patRow)) { + $knownProfile = $this->resolveKnownAbhaProfile($abhaId, $abhaAddress); + if (! empty($knownProfile)) { + $demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch($patRow, $knownProfile); + if ($demoError !== null) { + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => $demoError, + ]); + } + } + } } // Set abdm_linked_at when ABHA is verified via OTP; clear when manually entered @@ -4954,6 +4987,57 @@ class Patient extends BaseController ]; } + /** + * Attempt to resolve known ABHA verified profile data (from session or bridge) + * for demographic validation during linking. + */ + public function resolveKnownAbhaProfile(string $abhaId = '', string $abhaAddress = ''): ?array + { + $cleanAbha = preg_replace('/\D/', '', $abhaId); + $cleanAddr = trim($abhaAddress); + + // 1. Session check + $sessProfile = session()->get('abha_verified_profile') ?? session()->get('abha_profile'); + if (is_array($sessProfile)) { + $sessAddr = trim((string) ($sessProfile['abha_address'] ?? '')); + $sessId = preg_replace('/\D/', '', (string) ($sessProfile['abha_number'] ?? $sessProfile['abha_id'] ?? '')); + if (($cleanAddr !== '' && $sessAddr === $cleanAddr) || ($cleanAbha !== '' && $sessId === $cleanAbha)) { + return $sessProfile; + } + } + + // 2. Query bridge abdm_gateway_db.abdm_abha_profiles if available + try { + $bridgeDb = \Config\Database::connect('abdm_gateway_db', false); + if ($bridgeDb && $bridgeDb->tableExists('abdm_abha_profiles')) { + $b = $bridgeDb->table('abdm_abha_profiles'); + if ($cleanAddr !== '' && $cleanAbha !== '') { + $b->groupStart()->where('abha_address', $cleanAddr)->orWhere('abha_number LIKE', "%{$cleanAbha}%")->groupEnd(); + } elseif ($cleanAddr !== '') { + $b->where('abha_address', $cleanAddr); + } elseif ($cleanAbha !== '') { + $b->where('abha_number LIKE', "%{$cleanAbha}%"); + } + $row = $b->orderBy('id', 'DESC')->get(1)->getRowArray(); + if (! empty($row)) { + return [ + 'name' => (string) ($row['full_name'] ?? $row['name'] ?? ''), + 'gender' => (string) ($row['gender'] ?? ''), + 'date_of_birth' => (string) ($row['date_of_birth'] ?? ''), + 'year_of_birth' => (string) ($row['year_of_birth'] ?? ''), + 'mobile' => (string) ($row['mobile'] ?? ''), + 'abha_address' => (string) ($row['abha_address'] ?? ''), + 'abha_number' => (string) ($row['abha_number'] ?? ''), + ]; + } + } + } catch (\Throwable $e) { + // Secondary DB connection not configured, fallback to null + } + + return null; + } + /** * Find an existing patient in patient_master by Aadhaar number (12 digits). * Matches deterministic hash in udai_hash and unmasked digits in udai. diff --git a/app/Libraries/Abdm/AbhaDemographicValidator.php b/app/Libraries/Abdm/AbhaDemographicValidator.php new file mode 100644 index 0000000..0c27818 --- /dev/null +++ b/app/Libraries/Abdm/AbhaDemographicValidator.php @@ -0,0 +1,330 @@ +tableExists('patient_master')) { + return null; + } + + $fields = $db->getFieldNames('patient_master') ?? []; + $cleanAbhaId = preg_replace('/\D/', '', $abhaId); + $formattedAbhaId = (strlen($cleanAbhaId) === 14) + ? substr($cleanAbhaId, 0, 2) . '-' . substr($cleanAbhaId, 2, 4) . '-' . substr($cleanAbhaId, 6, 4) . '-' . substr($cleanAbhaId, 10, 4) + : ''; + $cleanAbhaAddress = trim($abhaAddress); + + $checkIdVals = array_values(array_filter([$cleanAbhaId, $formattedAbhaId, $abhaId])); + $hasIdCheck = ! empty($checkIdVals); + $hasAddressCheck = ($cleanAbhaAddress !== '' && str_contains($cleanAbhaAddress, '@')); + + if (! $hasIdCheck && ! $hasAddressCheck) { + return null; + } + + $builder = $db->table('patient_master') + ->select('id, p_code, p_fname, p_lname, gender, dob, age, mphone1, abha_id, abha_address'); + + $matchedAny = false; + $builder->groupStart(); + + // 1. Check ABHA ID columns + if ($hasIdCheck) { + foreach (['abha_id', 'abha_no', 'abha'] as $candCol) { + if (in_array($candCol, $fields, true)) { + if (! $matchedAny) { + $builder->whereIn($candCol, $checkIdVals); + $matchedAny = true; + } else { + $builder->orWhereIn($candCol, $checkIdVals); + } + } + } + } + + // 2. Check ABHA Address column + if ($hasAddressCheck && in_array('abha_address', $fields, true)) { + if (! $matchedAny) { + $builder->where('abha_address', $cleanAbhaAddress); + $matchedAny = true; + } else { + $builder->orWhere('abha_address', $cleanAbhaAddress); + } + } + + // Also check if abha_id column accidentally holds the abha_address string + if ($hasAddressCheck && in_array('abha_id', $fields, true)) { + $builder->orWhere('abha_id', $cleanAbhaAddress); + } + + $builder->groupEnd(); + + if (! $matchedAny) { + return null; + } + + if ($excludePatientId !== null && $excludePatientId > 0) { + $builder->where('id !=', $excludePatientId); + } + + $conflict = $builder->orderBy('id', 'DESC')->get(1)->getRowArray(); + if (! $conflict) { + return null; + } + + $conflictId = (int) ($conflict['id'] ?? 0); + $conflictCode = (string) ($conflict['p_code'] ?? ('P-' . $conflictId)); + $conflictName = trim(($conflict['p_fname'] ?? '') . ' ' . ($conflict['p_lname'] ?? '')); + $matchedValue = $hasAddressCheck ? $cleanAbhaAddress : $cleanAbhaId; + + return [ + 'conflict_patient_id' => $conflictId, + 'p_code' => $conflictCode, + 'p_name' => $conflictName, + 'error_text' => "This ABHA ({$matchedValue}) is already linked to patient {$conflictCode} ({$conflictName}). ABHA ID and Address must be unique to one person.", + ]; + } + + /** + * Validate that patient demographics in HMS match the ABHA profile demographics. + * + * @param array $patientRow HMS patient record (from patient_master or input) + * @param array $abhaProfile ABHA profile data (from verification/gateway) + * @param bool $strict If true, requires phone number match if available + * @return string|null Null if matching passes; error string on mismatch. + */ + public static function validateDemographicsMatch(array $patientRow, array $abhaProfile, bool $strict = false): ?string + { + // 1. Gender Validation + $patientGender = self::normalizeGender($patientRow['gender'] ?? ($patientRow['optionsRadios_gender'] ?? '')); + $abhaGender = self::normalizeGender($abhaProfile['gender'] ?? ''); + + if ($patientGender !== '' && $abhaGender !== '' && $patientGender !== $abhaGender) { + $pLabel = self::genderLabel($patientGender); + $aLabel = self::genderLabel($abhaGender); + return "Demographic Mismatch: Patient gender ({$pLabel}) does not match ABHA profile gender ({$aLabel}). ABHA cannot be linked to a different person."; + } + + // 2. Age / Date of Birth / Year of Birth Validation + $patientYob = self::extractYearOfBirth( + $patientRow['dob'] ?? ($patientRow['datepicker_dob'] ?? ($patientRow['yob'] ?? '')), + $patientRow['age'] ?? ($patientRow['input_age_year'] ?? '') + ); + $abhaYob = self::extractYearOfBirth( + $abhaProfile['year_of_birth'] ?? ($abhaProfile['yob'] ?? ($abhaProfile['date_of_birth'] ?? ($abhaProfile['dob'] ?? ''))), + $abhaProfile['age'] ?? '' + ); + + if ($patientYob !== null && $abhaYob !== null) { + $diffYears = abs($patientYob - $abhaYob); + // Allow up to 2 years tolerance for DOB vs estimated age rounding + if ($diffYears > 2) { + return "Demographic Mismatch: Patient birth year ({$patientYob}) does not match ABHA profile birth year ({$abhaYob}, difference: {$diffYears} years). ABHA cannot be linked to a different person."; + } + } + + // 3. Name Similarity Validation + $patientName = trim((string) ($patientRow['p_fname'] ?? ($patientRow['input_name'] ?? ''))); + if (! empty($patientRow['p_lname']) && $patientRow['p_lname'] !== '0') { + $patientName .= ' ' . trim((string) $patientRow['p_lname']); + } + $abhaName = trim((string) ($abhaProfile['full_name'] ?? ($abhaProfile['name'] ?? ''))); + + if ($patientName !== '' && $abhaName !== '') { + $nameSimilarity = self::calculateNameSimilarity($patientName, $abhaName); + // If similarity is below 0.35 and no significant tokens match, reject + if ($nameSimilarity < 0.35) { + return "Demographic Mismatch: Patient name '{$patientName}' does not match ABHA profile name '{$abhaName}'."; + } + } + + // 4. Strict Phone Match (if requested and both phones provided) + if ($strict) { + $patientPhone = substr(preg_replace('/\D/', '', (string) ($patientRow['mphone1'] ?? ($patientRow['input_mphone1'] ?? ''))), -10); + $abhaPhone = substr(preg_replace('/\D/', '', (string) ($abhaProfile['mobile'] ?? ($abhaProfile['phone'] ?? ''))), -10); + if (strlen($patientPhone) === 10 && strlen($abhaPhone) === 10 && $patientPhone !== $abhaPhone) { + return "Demographic Mismatch: Patient mobile ({$patientPhone}) does not match ABHA mobile ({$abhaPhone})."; + } + } + + return null; + } + + /** + * Normalize gender string or numeric code to 'M', 'F', or 'O'. + */ + public static function normalizeGender(mixed $gender): string + { + if ($gender === null || $gender === '') { + return ''; + } + $g = strtoupper(trim((string) $gender)); + if ($g === '1' || $g === 'M' || $g === 'MALE' || $g === 'BOY') { + return 'M'; + } + if ($g === '2' || $g === 'F' || $g === 'FEMALE' || $g === 'GIRL') { + return 'F'; + } + if ($g === '3' || $g === 'O' || $g === 'OTHER' || $g === 'TRANSGENDER') { + return 'O'; + } + return ''; + } + + public static function genderLabel(string $code): string + { + return match ($code) { + 'M' => 'Male', + 'F' => 'Female', + 'O' => 'Other', + default => 'Unknown', + }; + } + + /** + * Extract or calculate 4-digit Year of Birth. + */ + public static function extractYearOfBirth(mixed $dobString = '', mixed $ageString = ''): ?int + { + $currentYear = (int) date('Y'); + + if (! empty($dobString)) { + $dob = trim((string) $dobString); + if ($dob !== '0000-00-00' && ! str_starts_with($dob, '0000')) { + // Check standalone 4-digit year (e.g. "2020") + if (preg_match('/^\d{4}$/', $dob, $m)) { + $y = (int) $m[0]; + if ($y >= 1900 && $y <= $currentYear) { + return $y; + } + } + // Check YYYY-MM-DD + if (preg_match('/^(\d{4})[-.\/]/', $dob, $m)) { + $y = (int) $m[1]; + if ($y >= 1900 && $y <= $currentYear) { + return $y; + } + } + // Check DD-MM-YYYY + if (preg_match('/[-.\/](\d{4})$/', $dob, $m)) { + $y = (int) $m[1]; + if ($y >= 1900 && $y <= $currentYear) { + return $y; + } + } + // Fallback strtotime + $ts = strtotime($dob); + if ($ts !== false) { + $y = (int) date('Y', $ts); + if ($y >= 1900 && $y <= $currentYear) { + return $y; + } + } + } + } + + if (! empty($ageString) && is_numeric($ageString)) { + $num = (int) $ageString; + if ($num >= 1900 && $num <= $currentYear) { + return $num; + } + if ($num >= 0 && $num <= 125) { + return $currentYear - $num; + } + } + + return null; + } + + /** + * Calculate name similarity between 0.0 and 1.0 after cleaning honorifics and titles. + */ + public static function calculateNameSimilarity(string $name1, string $name2): float + { + $clean1 = self::cleanNameTokens($name1); + $clean2 = self::cleanNameTokens($name2); + + if ($clean1 === [] || $clean2 === []) { + return 0.0; + } + + $str1 = implode(' ', $clean1); + $str2 = implode(' ', $clean2); + + if ($str1 === $str2) { + return 1.0; + } + + // Check token intersection + $intersect = array_intersect($clean1, $clean2); + if (empty($intersect)) { + // No matching word at all + $maxTokenSim = 0.0; + foreach ($clean1 as $t1) { + foreach ($clean2 as $t2) { + similar_text($t1, $t2, $pct); + if ($pct > $maxTokenSim) { + $maxTokenSim = $pct; + } + } + } + return (float) round(($maxTokenSim / 100.0) * 0.5, 2); + } + + $tokenScore = (count($intersect) * 2) / (count($clean1) + count($clean2)); + return (float) round(min(1.0, $tokenScore), 2); + } + + /** + * Clean name tokens and remove common Indian and medical salutations/fillers. + * + * @return list + */ + private static function cleanNameTokens(string $name): array + { + $name = strtoupper(trim($name)); + $name = preg_replace('/[^A-Z0-9\s]/', ' ', $name); + $tokens = preg_split('/\s+/', (string) $name, -1, PREG_SPLIT_NO_EMPTY) ?: []; + + $ignored = [ + 'MR', 'MRS', 'MS', 'MISS', 'MASTER', 'BABY', 'DR', 'PROF', 'SMT', 'SHRI', + 'SHREE', 'KUMARI', 'LATE', 'W/O', 'S/O', 'D/O', 'C/O', 'WIFE', 'SON', 'DAUGHTER', + ]; + + $cleaned = []; + foreach ($tokens as $t) { + if (strlen($t) >= 2 && ! in_array($t, $ignored, true)) { + $cleaned[] = $t; + } + } + + return $cleaned; + } +} diff --git a/app/Views/abdm/fhir_inspector.php b/app/Views/abdm/fhir_inspector.php index df55fc8..1c3abbc 100644 --- a/app/Views/abdm/fhir_inspector.php +++ b/app/Views/abdm/fhir_inspector.php @@ -103,6 +103,8 @@ type="button" class="btn btn-sm btn-light border py-1 px-2 text-primary quick-pill" data-abha="" + data-patient-id="" + data-uhid="" data-name="" style="font-size: 11.5px; border-radius: 6px;" > @@ -130,6 +132,15 @@
+ +
+
+ + Multiple patient records found for this ABHA address: +
+
+
+
@@ -325,9 +336,12 @@ } // Load Patient Demographics & Care Contexts - window.inspectPatient = function(searchVal) { + window.inspectPatient = function(searchVal, optPatientId, optUhid) { searchVal = (searchVal || '').trim(); - if (!searchVal) { + optPatientId = optPatientId ? parseInt(optPatientId, 10) : 0; + optUhid = (optUhid || '').trim(); + + if (!searchVal && !optPatientId && !optUhid) { alert('Please enter an ABHA Address, ABHA Number, or Patient UHID.'); return; } @@ -335,13 +349,36 @@ var emptyPrompt = document.getElementById('inspEmptyPrompt'); var container = document.getElementById('inspActivePatientContainer'); var tbody = document.getElementById('careContextsTbody'); + var multAlert = document.getElementById('inspMultiplePatientsAlert'); if (emptyPrompt) emptyPrompt.classList.add('d-none'); if (container) container.classList.remove('d-none'); + if (multAlert) multAlert.classList.add('d-none'); tbody.innerHTML = '
Loading patient care contexts & FHIR status...'; - var url = '?abha_address=' + encodeURIComponent(searchVal); + var queryParams = []; + if (optPatientId > 0) { + queryParams.push('patient_id=' + encodeURIComponent(optPatientId)); + } + if (optUhid) { + queryParams.push('p_code=' + encodeURIComponent(optUhid)); + } + if (searchVal) { + if (searchVal.indexOf('@') !== -1) { + queryParams.push('abha_address=' + encodeURIComponent(searchVal)); + } else if (/^\d{14}$/.test(searchVal.replace(/\D/g, ''))) { + queryParams.push('abha_address=' + encodeURIComponent(searchVal)); + } else if (/^P\d+/i.test(searchVal)) { + queryParams.push('p_code=' + encodeURIComponent(searchVal)); + } else if (/^\d+$/.test(searchVal) && optPatientId <= 0) { + queryParams.push('patient_id=' + encodeURIComponent(searchVal)); + } else { + queryParams.push('abha_address=' + encodeURIComponent(searchVal)); + } + } + + var url = '?' + queryParams.join('&'); fetch(url, { headers: { @@ -353,9 +390,9 @@ .then(function(res) { if (!res || !res.ok || !res.patient) { var errTxt = (res && (res.error_text || res.error || res.message)) || 'Patient record not found'; - tbody.innerHTML = '' + hesc(errTxt) + ' for ' + hesc(searchVal) + '.'; + tbody.innerHTML = '' + hesc(errTxt) + ' for ' + hesc(searchVal || optUhid || optPatientId) + '.'; document.getElementById('dspPatientName').textContent = 'Patient Not Found'; - document.getElementById('dspAbhaAddress').textContent = searchVal; + document.getElementById('dspAbhaAddress').textContent = searchVal || '-'; document.getElementById('dspAbhaNumber').textContent = '-'; document.getElementById('dspPhone').textContent = '-'; document.getElementById('dspGatewayStatus').innerHTML = 'Unchecked'; @@ -384,6 +421,22 @@ document.getElementById('dspAbhaNumber').textContent = p.abha_number || '-'; document.getElementById('dspPhone').textContent = p.phone || '-'; + // Multiple Patients Switcher + if (res.multiple_patients && res.multiple_patients.length > 1) { + var pDiv = document.getElementById('inspMultiplePatientsPills'); + if (pDiv && multAlert) { + pDiv.innerHTML = res.multiple_patients.map(function(mp) { + if (mp.is_current) { + return '' + hesc(mp.name) + ' (' + hesc(mp.p_code) + ')'; + } + return ''; + }).join(''); + multAlert.classList.remove('d-none'); + } + } else if (multAlert) { + multAlert.classList.add('d-none'); + } + // Gateway Status if (linkedRefs.length > 0) { document.getElementById('dspGatewayStatus').innerHTML = 'Gateway Verified (' + linkedRefs.length + ' links)'; @@ -523,9 +576,11 @@ document.querySelectorAll('.quick-pill').forEach(function(p) { p.addEventListener('click', function() { var val = p.dataset.abha; - if (val) { - searchInput.value = val; - inspectPatient(val); + var pid = p.dataset.patientId; + var uhid = p.dataset.uhid; + if (val || pid || uhid) { + searchInput.value = val || uhid; + inspectPatient(val, pid, uhid); } }); }); diff --git a/tests/unit/Abdm/AbhaDemographicValidatorTest.php b/tests/unit/Abdm/AbhaDemographicValidatorTest.php new file mode 100644 index 0000000..b6ace2a --- /dev/null +++ b/tests/unit/Abdm/AbhaDemographicValidatorTest.php @@ -0,0 +1,107 @@ + 'SUNITA BISHT', + 'gender' => 2, + 'age' => 28, + ]; + $abhaProfile = [ + 'name' => 'DHAIRYA SINGH BISHT', + 'gender' => 'M', + 'dob' => '2020-07-05', + ]; + + $error = AbhaDemographicValidator::validateDemographicsMatch($patient, $abhaProfile); + $this->assertNotNull($error); + $this->assertStringContainsString('Demographic Mismatch: Patient gender (Female) does not match ABHA profile gender (Male)', $error); + } + + public function testAgeValidationMismatch(): void + { + // Matching gender but 22-year age gap (adult vs child) + $patient = [ + 'p_fname' => 'DHAIRYA SINGH BISHT', + 'gender' => 1, + 'age' => 28, // adult YOB ~ 1998 + ]; + $abhaProfile = [ + 'name' => 'DHAIRYA SINGH BISHT', + 'gender' => 'M', + 'year_of_birth' => '2020', + ]; + + $error = AbhaDemographicValidator::validateDemographicsMatch($patient, $abhaProfile); + $this->assertNotNull($error); + $this->assertStringContainsString('birth year', $error); + $this->assertStringContainsString('does not match ABHA profile birth year', $error); + } + + public function testNameSimilarityMismatch(): void + { + // Matching gender and age, but completely different names + $patient = [ + 'p_fname' => 'RAMESH KUMAR', + 'gender' => 1, + 'dob' => '2020-07-05', + ]; + $abhaProfile = [ + 'name' => 'DHAIRYA SINGH BISHT', + 'gender' => 'M', + 'year_of_birth' => '2020', + ]; + + $error = AbhaDemographicValidator::validateDemographicsMatch($patient, $abhaProfile); + $this->assertNotNull($error); + $this->assertStringContainsString("Patient name 'RAMESH KUMAR' does not match ABHA profile name 'DHAIRYA SINGH BISHT'", $error); + } + + public function testValidDemographicsMatch(): void + { + // Dhairya Singh Bisht matching on Name, Age, and Gender + $patient = [ + 'p_fname' => 'DHAIRYA SINGH BISHT', + 'gender' => 1, + 'dob' => '2020-07-05', + 'mphone1' => '9720958717', + ]; + $abhaProfile = [ + 'name' => 'Dhairya Singh Bisht', + 'gender' => 'M', + 'year_of_birth' => '2020', + 'mobile' => '9720958717', + ]; + + $error = AbhaDemographicValidator::validateDemographicsMatch($patient, $abhaProfile, true); + $this->assertNull($error); + } + + public function testExtractYearOfBirth(): void + { + $this->assertSame(2020, AbhaDemographicValidator::extractYearOfBirth('2020-07-05')); + $this->assertSame(2020, AbhaDemographicValidator::extractYearOfBirth('05-07-2020')); + $this->assertSame(2020, AbhaDemographicValidator::extractYearOfBirth('2020')); + $currentYear = (int) date('Y'); + $this->assertSame($currentYear - 28, AbhaDemographicValidator::extractYearOfBirth('', '28')); + } + + public function testCalculateNameSimilarity(): void + { + // Perfect match ignoring honorifics + $score = AbhaDemographicValidator::calculateNameSimilarity('MR. DHAIRYA SINGH BISHT', 'Dhairya Singh Bisht'); + $this->assertGreaterThanOrEqual(0.8, $score); + + // Completely different names + $scoreDiff = AbhaDemographicValidator::calculateNameSimilarity('SUNITA BISHT', 'DHAIRYA SINGH'); + $this->assertLessThan(0.35, $scoreDiff); + } +}