fix(abdm): enforce strict ABHA uniqueness and demographic validation on patient linking
This commit is contained in:
parent
4f9f257923
commit
6a247fdb63
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
330
app/Libraries/Abdm/AbhaDemographicValidator.php
Normal file
330
app/Libraries/Abdm/AbhaDemographicValidator.php
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<?php
|
||||
|
||||
namespace App\Libraries\Abdm;
|
||||
|
||||
use Config\Database;
|
||||
|
||||
/**
|
||||
* AbhaDemographicValidator
|
||||
*
|
||||
* Enforces strict integrity rules for ABHA linking in HMS:
|
||||
* 1. Global Uniqueness: Neither ABHA ID (14 digits) nor ABHA Address (@sbx/@abdm)
|
||||
* can be linked to multiple patient records.
|
||||
* 2. Demographic Verification: ABHA identity can only be linked to a patient
|
||||
* when Name, Age/Year-of-Birth, and Gender match within acceptable clinical thresholds.
|
||||
*/
|
||||
class AbhaDemographicValidator
|
||||
{
|
||||
/**
|
||||
* Check if an ABHA ID or ABHA Address is already linked to another patient in patient_master.
|
||||
*
|
||||
* @param string $abhaId 14-digit ABHA Number (formatted or raw)
|
||||
* @param string $abhaAddress ABHA Address (e.g. user@sbx)
|
||||
* @param int|null $excludePatientId Patient ID to exclude from conflict check
|
||||
* @return array{conflict_patient_id: int, p_code: string, p_name: string, error_text: string}|null
|
||||
*/
|
||||
public static function checkUniqueness(
|
||||
string $abhaId = '',
|
||||
string $abhaAddress = '',
|
||||
?int $excludePatientId = null,
|
||||
$db = null
|
||||
): ?array {
|
||||
$db = $db ?? Database::connect();
|
||||
if (! $db->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<string, mixed> $patientRow HMS patient record (from patient_master or input)
|
||||
* @param array<string, mixed> $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<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -103,6 +103,8 @@
|
|||
type="button"
|
||||
class="btn btn-sm btn-light border py-1 px-2 text-primary quick-pill"
|
||||
data-abha="<?= esc($val) ?>"
|
||||
data-patient-id="<?= (int) ($rp['id'] ?? 0) ?>"
|
||||
data-uhid="<?= esc((string) ($rp['p_code'] ?? '')) ?>"
|
||||
data-name="<?= esc($rp['name'] ?? '') ?>"
|
||||
style="font-size: 11.5px; border-radius: 6px;"
|
||||
>
|
||||
|
|
@ -130,6 +132,15 @@
|
|||
<!-- Active Patient Inspector Container (shown after search/selection) -->
|
||||
<div id="inspActivePatientContainer" class="<?= (empty($filter_abha) && empty($patient_id)) ? 'd-none' : '' ?>">
|
||||
|
||||
<!-- Multiple Patients Alert (shown when 1 ABHA matches multiple patients) -->
|
||||
<div id="inspMultiplePatientsAlert" class="alert alert-warning py-2 px-3 mb-3 d-none shadow-sm border d-flex align-items-center justify-content-between flex-wrap gap-2" style="border-radius: 8px;">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<i class="bi bi-people-fill text-warning fs-5"></i>
|
||||
<span class="fw-semibold text-dark">Multiple patient records found for this ABHA address:</span>
|
||||
<div id="inspMultiplePatientsPills" class="d-inline-flex align-items-center flex-wrap gap-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Banner Card -->
|
||||
<div class="card shadow-sm mb-3 border-0 bg-white" style="border-radius: 10px; overflow: hidden;">
|
||||
<div class="card-header bg-primary text-white py-3 px-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
|
|
@ -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 = '<tr><td colspan="6" class="text-center text-muted py-4"><div class="spinner-border spinner-border-sm text-primary me-2" role="status"></div>Loading patient care contexts & FHIR status...</td></tr>';
|
||||
|
||||
var url = '<?= base_url('AbdmFhirInspector/patient_data') ?>?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 = '<?= base_url('AbdmFhirInspector/patient_data') ?>?' + 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 = '<tr><td colspan="6" class="text-center text-warning py-4"><i class="bi bi-exclamation-triangle-fill me-2 fs-5"></i>' + hesc(errTxt) + ' for <strong>' + hesc(searchVal) + '</strong>.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-warning py-4"><i class="bi bi-exclamation-triangle-fill me-2 fs-5"></i>' + hesc(errTxt) + ' for <strong>' + hesc(searchVal || optUhid || optPatientId) + '</strong>.</td></tr>';
|
||||
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 = '<span class="badge bg-secondary">Unchecked</span>';
|
||||
|
|
@ -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 '<span class="badge bg-primary py-1 px-2 shadow-sm"><i class="bi bi-check-circle-fill me-1"></i>' + hesc(mp.name) + ' (' + hesc(mp.p_code) + ')</span>';
|
||||
}
|
||||
return '<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2 fw-semibold" onclick="inspectPatient(\'\', ' + mp.id + ', \'' + hesc(mp.p_code) + '\')"><i class="bi bi-arrow-repeat me-1"></i>Switch to ' + hesc(mp.name) + ' (' + hesc(mp.p_code) + ')</button>';
|
||||
}).join('');
|
||||
multAlert.classList.remove('d-none');
|
||||
}
|
||||
} else if (multAlert) {
|
||||
multAlert.classList.add('d-none');
|
||||
}
|
||||
|
||||
// Gateway Status
|
||||
if (linkedRefs.length > 0) {
|
||||
document.getElementById('dspGatewayStatus').innerHTML = '<span class="badge bg-success shadow-sm"><i class="bi bi-shield-check me-1"></i>Gateway Verified (' + linkedRefs.length + ' links)</span>';
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
107
tests/unit/Abdm/AbhaDemographicValidatorTest.php
Normal file
107
tests/unit/Abdm/AbhaDemographicValidatorTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Abdm;
|
||||
|
||||
use App\Libraries\Abdm\AbhaDemographicValidator;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class AbhaDemographicValidatorTest extends CIUnitTestCase
|
||||
{
|
||||
public function testGenderValidationMismatch(): void
|
||||
{
|
||||
// Sunita Bisht (Female = 2) vs Dhairya Singh Bisht (Male = 'M')
|
||||
$patient = [
|
||||
'p_fname' => '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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user