diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 1c0e4ca..cd6250a 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -503,6 +503,7 @@ $routes->group('billing', function($routes) { $routes->get('patient/abdm_content_fetch_only/(:num)', 'Patient::abdm_content_fetch_only/$1'); $routes->get('patient/abdm_consent_detail/(:num)', 'Patient::abdm_consent_detail/$1'); $routes->get('patient/abdm_consent_requests/(:num)', 'Patient::abdm_consent_requests/$1'); +$routes->get('patient/abdm_check_live_status/(:num)', 'Patient::abdm_check_live_status/$1'); $routes->post('patient/abdm_content_request_custom/(:num)', 'Patient::abdm_content_request_custom/$1'); $routes->get('patient/abdm_timeline/(:num)', 'Patient::abdm_timeline/$1'); $routes->post('patient/save_profile_image/(:num)', 'Patient::save_profile_image/$1'); @@ -728,6 +729,7 @@ $routes->post('abha/create/initiate', 'Abha::initiate'); $routes->post('abha/create/verify_otp', 'Abha::verifyOtp'); $routes->post('abha/create/communication', 'Abha::communication'); $routes->post('abha/create/verify_comm_otp', 'Abha::verifyCommOtp'); +$routes->post('abha/create/confirm_patient', 'Abha::confirmPatient'); $routes->post('abha/create/address', 'Abha::address'); $routes->get('abha/card/(:segment)', 'Abha::card/$1'); $routes->post('abha/register/validate', 'Abha::validateAbha'); diff --git a/app/Controllers/Abha.php b/app/Controllers/Abha.php index 598497e..618ba3a 100644 --- a/app/Controllers/Abha.php +++ b/app/Controllers/Abha.php @@ -38,6 +38,11 @@ class Abha extends BaseController // Normalise for the wizard: expose txn_id at top level if (! empty($result['ok']) && $result['ok'] == 1) { $txnId = $result['txn_id'] ?? $result['data']['txnId'] ?? $result['data']['txn_id'] ?? null; + // Cache the Aadhaar number against this txn_id so verifyOtp() can use it + // later for local-patient name/age/gender/aadhaar matching (never sent to browser). + if ($txnId) { + session()->set('abha_aadhaar_txn_' . $txnId, $aadhaar); + } return $this->response->setJSON(['ok' => 1, 'txn_id' => $txnId]); } @@ -112,38 +117,70 @@ class Abha extends BaseController $districtName = (string) (($profile['districtName'] ?? '') ?: ($profile['district_name'] ?? '') ?: ($payload['districtName'] ?? '') ?: ($payload['district_name'] ?? '') ?: ($payload['gateway_abha_profile']['district_name'] ?? '') ?: ($payload['gateway_patient']['district'] ?? '')); $email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_abha_profile']['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? '')); - $patientInfo = $this->autoCreateOrFindPatient( - $abhaNum, - $name, - $mobile, - $profileGender, - $profileDob, - [ - 'abha_address' => $abhaAddress, - 'profile_photo' => $photo, - 'verified_status' => $verifiedStatus, - 'verification_type' => $verificationType, - 'kyc_verified' => $kycVerified, - 'mobile_verified' => $mobileVerified, - 'address' => $address, - 'district' => $districtName, - 'state' => $stateName, - 'zip' => $zip, - 'email' => $email, - ] - ); + $abhaMeta = [ + 'abha_address' => $abhaAddress, + 'profile_photo' => $photo, + 'verified_status' => $verifiedStatus, + 'verification_type' => $verificationType, + 'kyc_verified' => $kycVerified, + 'mobile_verified' => $mobileVerified, + 'address' => $address, + 'district' => $districtName, + 'state' => $stateName, + 'zip' => $zip, + 'email' => $email, + ]; - return $this->response->setJSON([ - 'ok' => 1, - 'txn_id' => $newTxnId, - 'skip_mobile' => true, - 'abha_number' => $abhaNum, - 'name' => $name, - 'photo' => $photo, - 'mobile' => $mobile, - 'patient_id' => $patientInfo['patient_id'], - 'p_code' => $patientInfo['p_code'], - 'is_new_patient' => $patientInfo['is_new'], + // Aadhaar cached against this txn_id in initiate(), used only for local + // duplicate-patient matching below (never persisted/echoed to the browser). + $aadhaarForMatch = trim((string) (session()->get('abha_aadhaar_txn_' . $txnId) ?? '')); + session()->remove('abha_aadhaar_txn_' . $txnId); + + $patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $mobile, $profileGender, $profileDob, $abhaMeta); + + $responseBase = [ + 'ok' => 1, + 'txn_id' => $newTxnId, + 'skip_mobile' => true, + 'abha_number' => $abhaNum, + 'name' => $name, + 'photo' => $photo, + 'mobile' => $mobile, + 'gender' => $profileGender, + 'dob' => $profileDob, + 'abha_address' => $abhaAddress, + 'verified_status' => $verifiedStatus, + 'verification_type' => $verificationType, + 'kyc_verified' => $kycVerified, + 'mobile_verified' => $mobileVerified, + 'address' => $address, + 'district' => $districtName, + 'state' => $stateName, + 'zip' => $zip, + 'email' => $email, + ]; + + if ($patientInfo !== null) { + return $this->response->setJSON($responseBase + [ + 'need_confirmation' => false, + 'patient_id' => $patientInfo['patient_id'], + 'p_code' => $patientInfo['p_code'], + 'is_new_patient' => $patientInfo['is_new'], + ]); + } + + $db = \Config\Database::connect(); + $fields = $db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveAbhaFieldName($fields); + $abhaNumClean = preg_replace('/\D/', '', $abhaNum); + $candidates = $this->findMatchingCandidates($db, $fields, $name, $mobile, $profileGender, $profileDob, $aadhaarForMatch, $abhaField, $abhaNumClean); + + return $this->response->setJSON($responseBase + [ + 'need_confirmation' => true, + 'patient_id' => 0, + 'p_code' => '', + 'is_new_patient' => null, + 'candidates' => $candidates, ]); } @@ -248,37 +285,64 @@ class Abha extends BaseController $districtName = (string) (($profile['districtName'] ?? '') ?: ($profile['district_name'] ?? '') ?: ($payload['districtName'] ?? '') ?: ($payload['district_name'] ?? '') ?: ($payload['gateway_abha_profile']['district_name'] ?? '') ?: ($payload['gateway_patient']['district'] ?? '')); $email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_abha_profile']['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? '')); - $patientInfo = $this->autoCreateOrFindPatient( - $abhaNum, - $name, - $mobile, - $gender, - $dob, - [ - 'abha_address' => $abhaAddress, - 'profile_photo' => $photo, - 'verified_status' => $verifiedStatus, - 'verification_type' => $verificationType, - 'kyc_verified' => $kycVerified, - 'mobile_verified' => $mobileVerified, - 'address' => $address, - 'district' => $districtName, - 'state' => $stateName, - 'zip' => $zip, - 'email' => $email, - ] - ); + $abhaMeta = [ + 'abha_address' => $abhaAddress, + 'profile_photo' => $photo, + 'verified_status' => $verifiedStatus, + 'verification_type' => $verificationType, + 'kyc_verified' => $kycVerified, + 'mobile_verified' => $mobileVerified, + 'address' => $address, + 'district' => $districtName, + 'state' => $stateName, + 'zip' => $zip, + 'email' => $email, + ]; - return $this->response->setJSON([ - 'ok' => 1, - 'abha_number' => $abhaNum, - 'name' => $name, - 'photo' => $photo, - 'gender' => $gender, - 'dob' => $dob, - 'patient_id' => $patientInfo['patient_id'], - 'p_code' => $patientInfo['p_code'], - 'is_new_patient' => $patientInfo['is_new'], + $patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $mobile, $gender, $dob, $abhaMeta); + + $responseBase = [ + 'ok' => 1, + 'abha_number' => $abhaNum, + 'name' => $name, + 'photo' => $photo, + 'mobile' => $mobile, + 'gender' => $gender, + 'dob' => $dob, + 'abha_address' => $abhaAddress, + 'verified_status' => $verifiedStatus, + 'verification_type' => $verificationType, + 'kyc_verified' => $kycVerified, + 'mobile_verified' => $mobileVerified, + 'address' => $address, + 'district' => $districtName, + 'state' => $stateName, + 'zip' => $zip, + 'email' => $email, + ]; + + if ($patientInfo !== null) { + return $this->response->setJSON($responseBase + [ + 'need_confirmation' => false, + 'patient_id' => $patientInfo['patient_id'], + 'p_code' => $patientInfo['p_code'], + 'is_new_patient' => $patientInfo['is_new'], + ]); + } + + $db = \Config\Database::connect(); + $fields = $db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveAbhaFieldName($fields); + $abhaNumClean = preg_replace('/\D/', '', $abhaNum); + // No Aadhaar available on the pure mobile-OTP path. + $candidates = $this->findMatchingCandidates($db, $fields, $name, $mobile, $gender, $dob, '', $abhaField, $abhaNumClean); + + return $this->response->setJSON($responseBase + [ + 'need_confirmation' => true, + 'patient_id' => 0, + 'p_code' => '', + 'is_new_patient' => null, + 'candidates' => $candidates, ]); } @@ -622,32 +686,148 @@ class Abha extends BaseController } // ------------------------------------------------------------------------- - // Private helper — find or auto-create patient from ABHA profile data + // Step 5 — Confirm patient link/create after a "matching patients found" + // review by the operator (see findMatchingCandidates()). + // POST abha/create/confirm_patient // ------------------------------------------------------------------------- - private function autoCreateOrFindPatient( + public function confirmPatient() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $action = trim((string) ($this->request->getPost('action') ?? '')); + $patientId = (int) ($this->request->getPost('patient_id') ?? 0); + $abhaNum = (string) ($this->request->getPost('abha_number') ?? ''); + $name = trim((string) ($this->request->getPost('name') ?? '')); + $mobile = preg_replace('/\D/', '', (string) ($this->request->getPost('mobile') ?? '')); + $gender = (string) ($this->request->getPost('gender') ?? ''); + $dob = (string) ($this->request->getPost('dob') ?? ''); + + if (! in_array($action, ['new', 'existing'], true)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid action. Must be "new" or "existing".']); + } + + $abhaMeta = [ + 'abha_address' => (string) ($this->request->getPost('abha_address') ?? ''), + 'profile_photo' => (string) ($this->request->getPost('photo') ?? ''), + 'verified_status' => (string) ($this->request->getPost('verified_status') ?? ''), + 'verification_type' => (string) ($this->request->getPost('verification_type') ?? ''), + 'kyc_verified' => $this->request->getPost('kyc_verified'), + 'mobile_verified' => $this->request->getPost('mobile_verified'), + 'address' => (string) ($this->request->getPost('address') ?? ''), + 'district' => (string) ($this->request->getPost('district') ?? ''), + 'state' => (string) ($this->request->getPost('state') ?? ''), + 'zip' => (string) ($this->request->getPost('zip') ?? ''), + 'email' => (string) ($this->request->getPost('email') ?? ''), + ]; + + $db = \Config\Database::connect(); + $fields = $db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveAbhaFieldName($fields); + $abhaNumClean = preg_replace('/\D/', '', $abhaNum); + $abhaAddress = trim((string) ($abhaMeta['abha_address'] ?? '')); + + if ($abhaNumClean === '' && $abhaAddress === '') { + 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 ($abhaField && $abhaNumClean !== '') { + $conflict = $db->table('patient_master') + ->select('id,p_code,p_fname') + ->where($abhaField, $abhaNumClean) + ->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'] ?? ''), + ]); + } + } + } + + if ($action === 'existing') { + if ($patientId <= 0) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required to link an existing patient.']); + } + $row = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray(); + if (! $row) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Selected patient not found.']); + } + + // Guard: this patient must not already carry a *different* ABHA number. + if ($abhaField && ! empty($row[$abhaField]) && $abhaNumClean !== '' + && preg_replace('/\D/', '', (string) $row[$abhaField]) !== $abhaNumClean) { + return $this->response->setJSON([ + 'ok' => 0, + 'error_text' => 'Selected patient already has a different ABHA linked (' . $row[$abhaField] . '). Cannot link a second ABHA to the same patient.', + ]); + } + + $patientInfo = $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); + } else { + $patientInfo = $this->createPatientFromAbha($db, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); + } + + return $this->response->setJSON([ + 'ok' => 1, + 'patient_id' => $patientInfo['patient_id'], + 'p_code' => $patientInfo['p_code'], + 'is_new_patient' => $patientInfo['is_new'], + ]); + } + + // ------------------------------------------------------------------------- + // Private helper — resolve the column name used to store the ABHA number + // in patient_master (varies by installation/migration history). + // ------------------------------------------------------------------------- + private function resolveAbhaFieldName(array $fields): ?string + { + foreach (['abha_id', 'abha_no', 'abha'] as $f) { + if (in_array($f, $fields, true)) { + return $f; + } + } + if (in_array('abha_address', $fields, true)) { + return 'abha_address'; + } + + return null; + } + + /** + * Try to safely auto-resolve the local patient WITHOUT operator confirmation. + * Only used when there's no real ambiguity: + * - exact ABHA-number match (this exact digital identity was already linked here before), or + * - an unambiguous orphan "ABHA PATIENT" placeholder row (HMS's own incomplete record, not a + * different real patient). + * Returns null when neither applies — caller should then run findMatchingCandidates() + * and ask the operator to confirm "Create New" vs "Update Existing". + * + * @return array{patient_id:int,p_code:string,is_new:bool}|null + */ + private function tryAutoLinkByDirectMatch( string $abhaNum, string $name, string $mobile, string $gender, string $dob, array $abhaMeta = [] - ): array { + ): ?array { $db = \Config\Database::connect(); $fields = $db->getFieldNames('patient_master') ?? []; - - // Detect ABHA column name - $abhaField = null; - foreach (['abha_id', 'abha_no', 'abha'] as $f) { - if (in_array($f, $fields, true)) { $abhaField = $f; break; } - } - if ($abhaField === null && in_array('abha_address', $fields, true)) { - $abhaField = 'abha_address'; - } - - $abhaNumClean = preg_replace('/\D/', '', $abhaNum); // strip dashes -> 14 digits + $abhaField = $this->resolveAbhaFieldName($fields); + $abhaNumClean = preg_replace('/\D/', '', $abhaNum); $abhaAddress = trim((string) ($abhaMeta['abha_address'] ?? '')); - // 1. Search by ABHA number $existing = null; if ($abhaField && $abhaNumClean !== '') { $existing = $db->table('patient_master') @@ -658,82 +838,319 @@ class Abha extends BaseController } } - // 2. Fallback: search by mobile - if (! $existing && $mobile !== '') { - $existing = $db->table('patient_master') - ->where('mphone1', $mobile)->get()->getRowArray(); - } - if ($existing) { $patientId = (int) ($existing['id'] ?? 0); - $pCode = (string) ($existing['p_code'] ?? ''); - $existingUpdates = []; - - $existingName = strtoupper(trim((string) ($existing['p_fname'] ?? ''))); - if ($name !== '' && ($existingName === '' || $existingName === 'ABHA PATIENT')) { - $existingUpdates['p_fname'] = strtoupper($name); - } - - // Backfill ABHA field if it was empty - if ($abhaField && empty($existing[$abhaField]) && $abhaNumClean !== '') { - $existingUpdates[$abhaField] = $abhaNumClean; - } - - if ($existingUpdates !== []) { - $db->table('patient_master')->where('id', $patientId)->update($existingUpdates); - } - - $this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields); - return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => false]; + return $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); } - // 2b. Safe repair path for orphan placeholder rows created during prior failed mapping. + // Safe repair path for orphan placeholder rows created during prior failed mapping. // Reuse only when there is exactly one unambiguous candidate with no clinical linkage. $placeholder = $this->findRepairablePlaceholderPatient($db, $fields); if ($placeholder !== null) { $patientId = (int) ($placeholder['id'] ?? 0); if ($patientId > 0) { - $repairData = [ - 'p_fname' => strtoupper($name !== '' ? $name : 'ABHA PATIENT'), - 'mphone1' => $mobile, - 'gender' => $this->toPatientGenderValue($gender), - 'estimate_dob' => 1, - ]; - - $dobDb = $this->normalizeDobToDb($dob); - if ($dobDb !== '') { - $repairData['dob'] = $dobDb; - $repairData['estimate_dob'] = 0; - } - - if ($abhaField && $abhaNumClean !== '') { - $repairData[$abhaField] = $abhaNumClean; - } - if ($abhaAddress !== '' && in_array('abha_address', $fields, true)) { - $repairData['abha_address'] = $abhaAddress; - } - - $this->applyAbhaMetaColumns($repairData, $abhaMeta, $fields); - if (($abhaNumClean !== '' || $abhaAddress !== '') && in_array('abdm_linked_at', $fields, true)) { - $repairData['abdm_linked_at'] = date('Y-m-d H:i:s'); - } - - $db->table('patient_master')->where('id', $patientId)->update($repairData); - - $row = $db->table('patient_master')->select('p_code')->where('id', $patientId)->get()->getRowArray(); - $pCode = (string) ($row['p_code'] ?? ''); - - $this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields); - - return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => false]; + return $this->repairPlaceholderPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); } } - // 3. Create new patient row from ABHA profile data - $genderDb = $this->toPatientGenderValue($gender); + return null; + } - // Convert DOB to MySQL YYYY-MM-DD + /** + * Compute whole-years age from a dob string ('DD-MM-YYYY' or 'YYYY-MM-DD'). + */ + private function computeAgeYears(string $dob): ?int + { $dobDb = $this->normalizeDobToDb($dob); + if ($dobDb === '') { + return null; + } + try { + return (int) (new \DateTime($dobDb))->diff(new \DateTime())->y; + } catch (\Throwable $e) { + return null; + } + } + + private function genderLabel(int $g): string + { + if ($g === 2) { + return 'Female'; + } + if ($g === 3) { + return 'Other'; + } + if ($g === 1) { + return 'Male'; + } + + return ''; + } + + /** + * Search patient_master for possible existing local records matching the + * ABHA profile by name/age/gender (and mobile/aadhaar when available), so + * the operator can confirm "this is the same person" before linking, or + * choose to register as a brand-new patient. HMS never auto-creates a new + * patient record on its own once this search has run — the operator always + * makes the final call. + * + * @return array> + */ + private function findMatchingCandidates( + $db, + array $fields, + string $name, + string $mobile, + string $genderRaw, + string $dob, + string $aadhaar, + ?string $abhaField, + string $abhaNumClean + ): array { + $genderDb = $this->toPatientGenderValue($genderRaw); + $ageYears = $this->computeAgeYears($dob); + $nameUp = strtoupper(trim($name)); + $nameTokens = array_values(array_filter(preg_split('/\s+/', $nameUp) ?: [], fn ($t) => strlen($t) >= 3)); + $aadhaar = preg_replace('/\D/', '', $aadhaar); + + if ($nameTokens === [] && $mobile === '' && $aadhaar === '' && $ageYears === null) { + return []; + } + + $select = 'id,p_code,p_fname,p_lname,gender,dob,age,mphone1,udai'; + if ($abhaField !== null && ! in_array($abhaField, ['id', 'p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'mphone1', 'udai'], true)) { + $select .= ',' . $abhaField; + } + + $builder = $db->table('patient_master')->select($select); + $builder->groupStart(); + $any = false; + foreach ($nameTokens as $tok) { + $builder->orLike('p_fname', $tok); + $any = true; + } + if ($mobile !== '') { + $builder->orWhere('mphone1', $mobile); + $any = true; + } + if ($aadhaar !== '') { + $builder->orWhere('udai', $aadhaar); + $any = true; + } + if ($ageYears !== null && $genderDb > 0) { + $builder->orWhere('gender', $genderDb); + $any = true; + } + $builder->groupEnd(); + + if (! $any) { + return []; + } + + $rows = $builder->orderBy('insert_date', 'DESC')->limit(40)->get()->getResultArray(); + + $candidates = []; + foreach ($rows as $row) { + $rowName = strtoupper(trim((string) ($row['p_fname'] ?? ''))); + $rowTokens = array_filter(preg_split('/\s+/', $rowName) ?: []); + $nameOverlap = $nameTokens !== [] && count(array_intersect($nameTokens, $rowTokens)) > 0; + + $rowAgeYears = null; + $rowDob = trim((string) ($row['dob'] ?? '')); + if ($rowDob !== '' && $rowDob !== '0000-00-00') { + try { + $rowAgeYears = (int) (new \DateTime($rowDob))->diff(new \DateTime())->y; + } catch (\Throwable $e) { + $rowAgeYears = null; + } + } + if ($rowAgeYears === null && (int) ($row['age'] ?? 0) > 0) { + $rowAgeYears = (int) $row['age']; + } + + $ageMatch = $ageYears !== null && $rowAgeYears !== null && abs($rowAgeYears - $ageYears) <= 1; + $genderMatch = $genderDb > 0 && (int) ($row['gender'] ?? 0) === $genderDb; + $mobileMatch = $mobile !== '' && preg_replace('/\D/', '', (string) ($row['mphone1'] ?? '')) === $mobile; + $aadhaarMatch = $aadhaar !== '' && preg_replace('/\D/', '', (string) ($row['udai'] ?? '')) === $aadhaar; + + $rowAbha = $abhaField !== null ? trim((string) ($row[$abhaField] ?? '')) : ''; + $abhaConflict = $rowAbha !== '' && preg_replace('/\D/', '', $rowAbha) !== $abhaNumClean; + + $score = ($nameOverlap ? 2 : 0) + ($ageMatch ? 1 : 0) + ($genderMatch ? 1 : 0) + ($mobileMatch ? 2 : 0) + ($aadhaarMatch ? 3 : 0); + if ($score <= 0) { + continue; + } + + $candidates[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'p_code' => (string) ($row['p_code'] ?? ''), + 'name' => trim((string) ($row['p_fname'] ?? '') . ' ' . (string) ($row['p_lname'] ?? '')), + 'gender' => (int) ($row['gender'] ?? 0), + 'gender_label' => $this->genderLabel((int) ($row['gender'] ?? 0)), + 'dob' => $rowDob, + 'age' => $rowAgeYears, + 'mobile' => (string) ($row['mphone1'] ?? ''), + 'aadhaar' => (string) ($row['udai'] ?? ''), + 'abha' => $rowAbha, + 'abha_conflict' => $abhaConflict, + 'match' => [ + 'name' => $nameOverlap, + 'age' => $ageMatch, + 'gender' => $genderMatch, + 'mobile' => $mobileMatch, + 'aadhaar' => $aadhaarMatch, + ], + 'score' => $score, + ]; + } + + usort($candidates, fn ($a, $b) => $b['score'] <=> $a['score']); + + return array_slice($candidates, 0, 8); + } + + /** + * Link an ABHA profile to an already-identified existing patient row + * (either auto-matched by exact ABHA number, or explicitly chosen by the + * operator from the "matching patients" confirmation modal). Only + * backfills fields that are currently blank so verified/manually-entered + * HMS data is never clobbered. + * + * @return array{patient_id:int,p_code:string,is_new:bool} + */ + private function linkAbhaToPatient( + $db, + int $patientId, + string $name, + string $mobile, + string $gender, + string $dob, + array $abhaMeta, + string $abhaNumClean, + string $abhaAddress, + array $fields, + ?string $abhaField + ): array { + $existing = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray() ?? []; + $pCode = (string) ($existing['p_code'] ?? ''); + $existingUpdates = []; + + $existingName = strtoupper(trim((string) ($existing['p_fname'] ?? ''))); + if ($name !== '' && ($existingName === '' || $existingName === 'ABHA PATIENT')) { + $existingUpdates['p_fname'] = strtoupper($name); + } + + // Backfill DOB/gender/mobile from ABHA profile only if not already recorded, + // so we never clobber a manually-corrected value. + $existingDob = trim((string) ($existing['dob'] ?? '')); + if (($existingDob === '' || $existingDob === '0000-00-00') && $dob !== '') { + $dobDb = $this->normalizeDobToDb($dob); + if ($dobDb !== '') { + $existingUpdates['dob'] = $dobDb; + if (in_array('estimate_dob', $fields, true)) { + $existingUpdates['estimate_dob'] = 0; + } + } + } + $existingGenderRaw = (int) ($existing['gender'] ?? 0); + if ($existingGenderRaw <= 0 && $gender !== '') { + $existingUpdates['gender'] = $this->toPatientGenderValue($gender); + } + if ($mobile !== '' && empty($existing['mphone1'])) { + $existingUpdates['mphone1'] = $mobile; + } + + // Backfill ABHA field if it was empty + if ($abhaField && empty($existing[$abhaField]) && $abhaNumClean !== '') { + $existingUpdates[$abhaField] = $abhaNumClean; + } + + if ($existingUpdates !== []) { + $db->table('patient_master')->where('id', $patientId)->update($existingUpdates); + } + + $this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields); + + return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => false]; + } + + /** + * Repair a single unambiguous orphan "ABHA PATIENT" placeholder row created + * during a prior failed mapping. Not a "new patient" from the operator's + * perspective — this is HMS fixing its own incomplete record. + * + * @return array{patient_id:int,p_code:string,is_new:bool} + */ + private function repairPlaceholderPatient( + $db, + int $patientId, + string $name, + string $mobile, + string $gender, + string $dob, + array $abhaMeta, + string $abhaNumClean, + string $abhaAddress, + array $fields, + ?string $abhaField + ): array { + $repairData = [ + 'p_fname' => strtoupper($name !== '' ? $name : 'ABHA PATIENT'), + 'mphone1' => $mobile, + 'gender' => $this->toPatientGenderValue($gender), + 'estimate_dob' => 1, + ]; + + $dobDb = $this->normalizeDobToDb($dob); + if ($dobDb !== '') { + $repairData['dob'] = $dobDb; + $repairData['estimate_dob'] = 0; + } + + if ($abhaField && $abhaNumClean !== '') { + $repairData[$abhaField] = $abhaNumClean; + } + if ($abhaAddress !== '' && in_array('abha_address', $fields, true)) { + $repairData['abha_address'] = $abhaAddress; + } + + $this->applyAbhaMetaColumns($repairData, $abhaMeta, $fields); + if (($abhaNumClean !== '' || $abhaAddress !== '') && in_array('abdm_linked_at', $fields, true)) { + $repairData['abdm_linked_at'] = date('Y-m-d H:i:s'); + } + + $db->table('patient_master')->where('id', $patientId)->update($repairData); + + $row = $db->table('patient_master')->select('p_code')->where('id', $patientId)->get()->getRowArray(); + $pCode = (string) ($row['p_code'] ?? ''); + + $this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields); + + return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => false]; + } + + /** + * Create a brand-new patient_master row from an ABHA profile. Only ever + * called when the operator has explicitly chosen "Create New" from the + * matching-patients confirmation modal (or there was nothing to match). + * + * @return array{patient_id:int,p_code:string,is_new:bool} + */ + private function createPatientFromAbha( + $db, + string $name, + string $mobile, + string $gender, + string $dob, + array $abhaMeta, + string $abhaNumClean, + string $abhaAddress, + array $fields, + ?string $abhaField + ): array { + $genderDb = $this->toPatientGenderValue($gender); + $dobDb = $this->normalizeDobToDb($dob); $insertData = [ 'p_fname' => strtoupper($name !== '' ? $name : 'ABHA PATIENT'), diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 97cfc38..86cfc6d 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -836,6 +836,27 @@ class Patient extends BaseController ]); } + // ABHA number must be unique across patient_master — block if another + // patient already carries this exact ABHA id. + if ($abhaId !== '') { + $abhaField = $this->resolvePatientAbhaIdField(); + if ($abhaField !== null) { + $conflict = $this->db->table('patient_master') + ->select('id,p_code,p_fname') + ->where($abhaField, $abhaId) + ->where('id !=', $pid) + ->get()->getRowArray(); + if ($conflict) { + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => 'This ABHA number is already linked to patient ' + . ($conflict['p_code'] ?? '') . ' (' . ($conflict['p_fname'] ?? '') . '). ' + . 'An ABHA number can only be linked to one patient.', + ]); + } + } + } + // Set abdm_linked_at when ABHA is verified via OTP; clear when manually entered $pmFields = $this->db->getFieldNames('patient_master') ?? []; if (in_array('abdm_linked_at', $pmFields, true)) { @@ -1194,7 +1215,32 @@ class Patient extends BaseController $builder->groupEnd(); if ($filterConsentRequestId !== '' && in_array('consent_request_id', $docFields, true)) { - $builder->where('d.consent_request_id', $filterConsentRequestId); + // A single M3 HIU consent request (umbrella consent_request_id) can + // produce ONE consent artifact PER linked HIP facility, and each + // fetched document is tagged with its OWN facility-specific artifact + // id in this column -- never the umbrella request id itself. So a + // naive exact-match filter here would only ever surface ONE facility + // (whichever artifact happened to be passed in) and silently hide + // every sibling facility's documents from the same consent session. + // Expand the filter to include every known artifact id recorded for + // this consent_request_id (see abdm_hiu_consent_artifacts), plus the + // filter value itself, so "Show Data" for a session returns ALL of + // its facilities' documents (verified 2026-07-30). + $sessionIds = [$filterConsentRequestId]; + if ($this->db->tableExists('abdm_hiu_consent_artifacts')) { + $artifactRows = $this->db->table('abdm_hiu_consent_artifacts') + ->select('artifact_id') + ->where('consent_request_id', $filterConsentRequestId) + ->get() + ->getResultArray(); + foreach ($artifactRows as $artifactRow) { + $artifactId = trim((string) ($artifactRow['artifact_id'] ?? '')); + if ($artifactId !== '') { + $sessionIds[] = $artifactId; + } + } + } + $builder->whereIn('d.consent_request_id', array_values(array_unique($sessionIds))); } if ($q !== '') { @@ -2268,6 +2314,99 @@ class Patient extends BaseController return $this->response->setJSON($this->getAbdmConsentRequestsList($abhaAddress)); } + /** + * Live status check — actively calls the ABDM bridge's + * GET /v1/hiu/consent/status (via M3HiuWorkflowService's consent_reconcile + * operation) for this patient's most recent consent request instead of + * only re-reading whatever was last saved in abdm_hiu_workflows. Needed + * because HMS otherwise only learns about a GRANTED consent when a cron + * (`php spark abdm:hiu-poll`) or a webhook callback updates the DB — if + * neither has run yet, the UI can keep showing a stale REQUESTED status + * even though the bridge/ABDM sandbox already shows GRANTED. If the + * reconcile confirms GRANTED, this also cascades into fetching decrypted + * data for EVERY known consent artifact under this consent request (one + * per linked HIP facility) via M3HiuWorkflowService::fetchAllArtifactsAfterGrant(), + * so a manual click surfaces ALL facilities' records instead of just one. + */ + public function abdm_check_live_status(int $pno) + { + if (! $this->db->tableExists('patient_master')) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'patient_master table not found', + ]); + } + + $patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray(); + if (! is_array($patientRow)) { + return $this->response->setStatusCode(404)->setJSON([ + 'ok' => 0, + 'error' => 'Patient not found', + ]); + } + + $abhaContext = $this->buildAbhaProfileContext($patientRow); + $abhaAddress = trim((string) ($abhaContext['abha_address'] ?? '')); + if ($abhaAddress === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error' => 'ABHA Address not available for this patient.', + ]); + } + + $snapshot = $this->getLatestAbdmSyncSnapshot($abhaAddress); + $requestId = trim((string) ($snapshot['request_id'] ?? '')); + $consentRequestId = trim((string) ($snapshot['consent_request_id'] ?? '')); + $consentId = trim((string) ($snapshot['consent_id'] ?? '')); + if ($requestId === '' && $consentRequestId === '' && $consentId === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'ok' => 0, + 'error' => 'No consent request found for this patient to check.', + ]); + } + + $reconcilePayload = [ + 'abha_address' => $abhaAddress, + 'request_id' => $requestId, + 'abdm_consent_request_id' => $consentRequestId, + 'abdm_consent_artifact_id' => $consentId, + 'consent_id' => $consentId, + ]; + + $service = new \App\Libraries\Abdm\M3HiuWorkflowService(); + try { + $reconcile = $service->runOperation('consent_reconcile', $reconcilePayload); + } catch (\Throwable $e) { + return $this->response->setStatusCode(500)->setJSON([ + 'ok' => 0, + 'error' => 'Unable to check live status: ' . $e->getMessage(), + ]); + } + + $reconcileOk = (int) ($reconcile['ok'] ?? 0) === 1; + + $fetchSummary = ['granted' => false, 'data_updates' => 0, 'failed' => 0, 'artifact_ids' => []]; + if ($reconcileOk) { + try { + $fetchSummary = $service->fetchAllArtifactsAfterGrant($reconcilePayload, $reconcile); + } catch (\Throwable $e) { + // Status was successfully reconciled even if the data-fetch + // cascade below fails transiently; don't hide that from the UI. + $fetchSummary['fetch_error'] = $e->getMessage(); + } + } + + return $this->response->setJSON([ + 'ok' => 1, + 'reconcile_ok' => $reconcileOk ? 1 : 0, + 'reconcile_error' => $reconcileOk ? '' : (string) ($reconcile['error_text'] ?? 'Live status check failed.'), + 'granted' => $fetchSummary['granted'] ? 1 : 0, + 'artifacts_fetched' => count($fetchSummary['artifact_ids'] ?? []) + ($fetchSummary['granted'] ? 1 : 0), + 'data_fetch_updates' => (int) ($fetchSummary['data_updates'] ?? 0), + 'data_fetch_failed' => (int) ($fetchSummary['failed'] ?? 0), + ] + $this->getAbdmConsentRequestsList($abhaAddress)); + } + public function abdm_timeline(int $pno) { if (! $this->db->tableExists('patient_master')) { @@ -2747,6 +2886,17 @@ class Patient extends BaseController if ($gatewayMobile !== '' && in_array('mphone1', $pmFields, true)) { $updates['mphone1'] = preg_replace('/\D/', '', $gatewayMobile); } + $dobDb = $this->normalizeGatewayDobToDb($gatewayDob); + if ($dobDb !== '' && in_array('dob', $pmFields, true)) { + $updates['dob'] = $dobDb; + if (in_array('estimate_dob', $pmFields, true)) { + $updates['estimate_dob'] = 0; + } + } + $genderDb = $this->toPatientGenderDbValue($gatewayGender); + if ($genderDb !== null && in_array('gender', $pmFields, true)) { + $updates['gender'] = $genderDb; + } if ($gatewayAddress !== '' && in_array('add1', $pmFields, true)) { $updates['add1'] = $gatewayAddress; } @@ -3138,6 +3288,50 @@ class Patient extends BaseController ]; } + /** + * ABDM verify-otp/eKYC responses return DOB as "DD-MM-YYYY". Convert to + * MySQL "YYYY-MM-DD" for storage. Returns '' if unparseable/empty. + */ + private function normalizeGatewayDobToDb(string $dob): string + { + $dob = trim($dob); + if ($dob === '') { + return ''; + } + + if (preg_match('/^(\d{2})-(\d{2})-(\d{4})$/', $dob, $m)) { + return $m[3] . '-' . $m[2] . '-' . $m[1]; + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob)) { + return $dob; + } + + return ''; + } + + /** + * Map ABDM gateway gender string (M/F/O, MALE/FEMALE/OTHER, or already 1/2/3) + * to patient_master's numeric gender code. Returns null if unrecognised. + */ + private function toPatientGenderDbValue(string $gender): ?int + { + $g = strtoupper(trim($gender)); + if ($g === '') { + return null; + } + if ($g === 'M' || $g === '1' || $g === 'MALE') { + return 1; + } + if ($g === 'F' || $g === '2' || $g === 'FEMALE') { + return 2; + } + if ($g === 'O' || $g === '3' || $g === 'OTHER') { + return 3; + } + + return null; + } + /** * @return array{saved:bool,path:string,error:string} */ @@ -3575,6 +3769,34 @@ class Patient extends BaseController return $snapshot; } + // Scope this snapshot to the CURRENT (most recent) consent request + // "session" only. Without this, the phase-priority scan below picks + // whichever row ANYWHERE in the last 40 rows reached the highest + // lifecycle stage (e.g. an older, already-GRANTED/COMPLETED consent), + // even when the patient has since started a brand-new consent_request + // that is still just REQUESTED -- silently resurrecting a stale/ + // unrelated session's consent_id and causing "Fetch Records"/live + // status checks to operate on the wrong consent (confirmed 2026-07-30: + // a new 4-facility consent request stayed masked behind a prior + // single-facility consent that had already reached GRANTED earlier). + $latestConsentRequestRow = $this->db->table('abdm_hiu_workflows') + ->select('id') + ->where('abha_address', $abhaAddress) + ->where('operation', 'consent_request') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $latestSessionStartId = (int) ($latestConsentRequestRow['id'] ?? 0); + if ($latestSessionStartId > 0) { + $rows = array_values(array_filter( + $rows, + static fn (array $row): bool => (int) ($row['id'] ?? 0) >= $latestSessionStartId + )); + if ($rows === []) { + return $snapshot; + } + } + $best = null; $bestPriority = -1; $bestGrantedOrCompleted = null; @@ -3827,8 +4049,24 @@ class Patient extends BaseController return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.']; } - // Sessions are chronological (oldest first); the last one is the most recent. - return $this->computeConsentSessionDetail(end($sessions), $abhaAddress); + // Sessions are chronological (oldest first). Skip synthetic "orphan" + // sessions with no owning CONSENT_REQUEST row (stray per-artifact + // re-poll rows that never correlated back to a real request, e.g. a + // one-off manual test call) so the most recent REAL request is used. + for ($i = count($sessions) - 1; $i >= 0; $i--) { + $hasAnchor = false; + foreach ($sessions[$i] as $sessionRow) { + if (strtoupper(trim((string) ($sessionRow['operation'] ?? ''))) === 'CONSENT_REQUEST') { + $hasAnchor = true; + break; + } + } + if ($hasAnchor) { + return $this->computeConsentSessionDetail($sessions[$i], $abhaAddress); + } + } + + return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.']; } /** @@ -3851,6 +4089,23 @@ class Patient extends BaseController $sessions = $this->groupWorkflowRowsIntoSessions($rows); $requests = []; foreach ($sessions as $sessionRows) { + // Skip synthetic "orphan" sessions with no owning CONSENT_REQUEST + // row (stray per-artifact re-poll rows whose internal request_id + // never correlated back to a real request -- e.g. a one-off manual + // test call). These don't represent an actual request the user + // initiated and would otherwise show up as extra incomplete rows + // in the Consent Request History table. + $hasAnchor = false; + foreach ($sessionRows as $sessionRow) { + if (strtoupper(trim((string) ($sessionRow['operation'] ?? ''))) === 'CONSENT_REQUEST') { + $hasAnchor = true; + break; + } + } + if (! $hasAnchor) { + continue; + } + $detail = $this->computeConsentSessionDetail($sessionRows, $abhaAddress); if ((int) ($detail['ok'] ?? 0) === 1) { $requests[] = $detail['consent']; @@ -3931,11 +4186,31 @@ class Patient extends BaseController /** * Splits a DESC-by-id set of workflow rows into distinct consent request - * "sessions" — each CONSENT_REQUEST row starts a new session, and every - * subsequent row (status/reconcile/data_fetch/callback) belongs to that - * session until the next CONSENT_REQUEST row appears. Returns sessions in - * chronological order (oldest session first); rows within CONSENT_REQUEST - * are always the first row of their session. + * "sessions", correlating rows by their STABLE identifiers (request_id / + * abdm_consent_request_id / consent_id) rather than simple chronological + * proximity to the most recent CONSENT_REQUEST row. + * + * IMPORTANT: a naive "everything after the newest CONSENT_REQUEST row + * belongs to it" split (the original implementation) breaks as soon as + * background polling (M3HiuWorkflowService::pollNatGateway()) reconciles + * EVERY historical consent_request in a single batch -- those older + * sessions' consent_reconcile/data_fetch rows all land chronologically + * AFTER a brand new consent_request row and would be misattributed to it, + * silently merging an unrelated older session's facility documents into + * the newest session's "Show Data" view (verified 2026-07-30: a 4-facility + * session displayed a completely different, older single-facility + * session's documents because of this). + * + * Each CONSENT_REQUEST row starts a new session keyed by its own + * `request_id`. Every other row is attributed to a session by (in order + * of preference): (1) a previously-learned abdm_consent_request_id match, + * (2) a previously-learned request_id/consent_id match, (3) falling back + * to the most recently opened session that has not yet resolved its own + * abdm_consent_request_id (covers the very first reconcile call right + * after creation, which sometimes uses the gateway's own freshly-minted + * request_id instead of preserving HMS's tracking id). Once a session's + * abdm_consent_request_id/request_id is known, that binding persists for + * every later row regardless of how much later it arrives. * * @param array> $rows rows ordered DESC by id * @return array>> @@ -3945,20 +4220,71 @@ class Patient extends BaseController $chronological = array_reverse($rows); $sessions = []; - $current = []; + $order = []; + $abdmIdToAnchor = []; + $requestIdToAnchor = []; + $openAnchor = null; + foreach ($chronological as $row) { $operation = strtoupper(trim((string) ($row['operation'] ?? ''))); - if ($operation === 'CONSENT_REQUEST' && $current !== []) { - $sessions[] = $current; - $current = []; + $rowRequestId = trim((string) ($row['request_id'] ?? '')); + $rowAbdmConsentRequestId = trim((string) ($row['abdm_consent_request_id'] ?? '')); + $rowConsentId = trim((string) ($row['consent_id'] ?? '')); + + if ($operation === 'CONSENT_REQUEST') { + $anchorKey = 'anchor_' . count($order); + $order[] = $anchorKey; + $sessions[$anchorKey] = [$row]; + if ($rowRequestId !== '') { + $requestIdToAnchor[$rowRequestId] = $anchorKey; + } + $openAnchor = $anchorKey; + continue; + } + + $anchorKey = null; + if ($rowAbdmConsentRequestId !== '' && isset($abdmIdToAnchor[$rowAbdmConsentRequestId])) { + $anchorKey = $abdmIdToAnchor[$rowAbdmConsentRequestId]; + } elseif ($rowRequestId !== '' && isset($requestIdToAnchor[$rowRequestId])) { + $anchorKey = $requestIdToAnchor[$rowRequestId]; + } elseif ($rowConsentId !== '' && isset($requestIdToAnchor[$rowConsentId])) { + $anchorKey = $requestIdToAnchor[$rowConsentId]; + } elseif ($openAnchor !== null) { + $anchorKey = $openAnchor; + } + + if ($anchorKey === null) { + // No matching anchor at all (should not normally happen) -- + // start a synthetic session so the row is not silently dropped. + $anchorKey = 'orphan_' . count($order); + $order[] = $anchorKey; + $sessions[$anchorKey] = []; + } + + $sessions[$anchorKey][] = $row; + + if ($anchorKey === $openAnchor) { + $openAnchor = null; + } + if ($rowAbdmConsentRequestId !== '') { + $abdmIdToAnchor[$rowAbdmConsentRequestId] = $anchorKey; + } + if ($rowRequestId !== '') { + $requestIdToAnchor[$rowRequestId] = $anchorKey; + } + if ($rowConsentId !== '') { + $requestIdToAnchor[$rowConsentId] = $anchorKey; } - $current[] = $row; - } - if ($current !== []) { - $sessions[] = $current; } - return $sessions; + $result = []; + foreach ($order as $anchorKey) { + if (! empty($sessions[$anchorKey])) { + $result[] = $sessions[$anchorKey]; + } + } + + return $result; } /** @@ -4057,8 +4383,49 @@ class Patient extends BaseController } $phase = (string) $best['_phase']; - $consentId = trim((string) ($best['abdm_consent_artifact_id'] ?? $best['consent_id'] ?? $bestDecoded['consent_id'] ?? $bestDecoded['consentId'] ?? '')); - $consentRequestId = trim((string) ($best['abdm_consent_request_id'] ?? $bestDecoded['abdm_consent_request_id'] ?? $bestDecoded['consent_request_id'] ?? $bestDecoded['consentRequestId'] ?? '')); + + // IMPORTANT: do NOT read consent_id/consent_request_id only from the + // "best" (highest-priority phase) row. The initial consent_request row + // and its immediate consent_reconcile/consent_status follow-up often + // tie on priority (both still "REQUESTED" phase) while only the LATER + // row actually carries the real consent_id assigned by the bridge — + // and the priority tie-break above keeps whichever row was seen FIRST + // (chronologically oldest), so the blank consent_id from the original + // consent_request row was winning and hiding the real id (confirmed + // via abdm_hiu_workflows: consent_request row had consent_id='' while + // its own consent_reconcile row moments later had a real UUID). Scan + // every row in the session (already chronological, oldest first) and + // keep the LAST non-empty value found so the most recent known id wins. + $consentId = ''; + $consentRequestId = ''; + foreach ($rows as $row) { + $rowDecoded = json_decode((string) ($row['response_json'] ?? ''), true); + if (! is_array($rowDecoded)) { + $rowDecoded = []; + } + + $rowConsentId = trim((string) ( + $row['abdm_consent_artifact_id'] + ?? $row['consent_id'] + ?? $rowDecoded['consent_id'] + ?? $rowDecoded['consentId'] + ?? '' + )); + if ($rowConsentId !== '') { + $consentId = $rowConsentId; + } + + $rowConsentRequestId = trim((string) ( + $row['abdm_consent_request_id'] + ?? $rowDecoded['abdm_consent_request_id'] + ?? $rowDecoded['consent_request_id'] + ?? $rowDecoded['consentRequestId'] + ?? '' + )); + if ($rowConsentRequestId !== '') { + $consentRequestId = $rowConsentRequestId; + } + } // The session should start with its own CONSENT_REQUEST row. $consentRequestRow = null; diff --git a/app/Database/Migrations/2026-07-28-002500_CreateAbdmHiuConsentArtifacts.php b/app/Database/Migrations/2026-07-28-002500_CreateAbdmHiuConsentArtifacts.php index d28c65f..2f5c133 100644 --- a/app/Database/Migrations/2026-07-28-002500_CreateAbdmHiuConsentArtifacts.php +++ b/app/Database/Migrations/2026-07-28-002500_CreateAbdmHiuConsentArtifacts.php @@ -29,11 +29,11 @@ class CreateAbdmHiuConsentArtifacts extends Migration ], 'consent_request_id' => [ 'type' => 'VARCHAR', - 'constraint' => 180, + 'constraint' => 100, ], 'artifact_id' => [ 'type' => 'VARCHAR', - 'constraint' => 180, + 'constraint' => 100, ], 'abha_address' => [ 'type' => 'VARCHAR', diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php index ebf2116..a2ca0c6 100644 --- a/app/Libraries/Abdm/M3HiuWorkflowService.php +++ b/app/Libraries/Abdm/M3HiuWorkflowService.php @@ -231,91 +231,11 @@ class M3HiuWorkflowService if ((int) ($consentResult['ok'] ?? 0) === 1) { $summary['consent_updates']++; - $consentState = strtolower(trim((string) ( - $consentResult['workflow_state'] - ?? $consentResult['status'] - ?? $consentResult['consent_status'] - ?? '' - ))); - if ($consentState !== 'granted') { + $cascade = $this->fetchAllArtifactsAfterGrant($payload, $consentResult); + $summary['data_updates'] += $cascade['data_updates']; + $summary['failed'] += $cascade['failed']; + if (! $cascade['granted']) { $summary['skipped']++; - continue; - } - - // IMPORTANT: use the UMBRELLA consent_request_id here, not a single - // resolved consent artifact id. Multi-facility ABDM M3 consents - // produce ONE consent artifact PER linked HIP facility under the - // same consent_request_id. Per the bridge's own contract, calling - // GET /v1/hiu/data/fetch with the consent_request_id (instead of - // one specific artifact id) returns ALL sessions/artifacts merged - // in a single response (verified 2026-07-28: querying by - // consent_request_id surfaced a sibling artifact that had NEVER - // been seen by HMS before, alongside the originally-tracked one). - // Falls back to a single artifact id only if no request id is - // available (should not normally happen for a granted consent). - $resolvedConsentId = trim((string) ( - $consentResult['abdm_consent_request_id'] - ?? $consentResult['consent_request_id'] - ?? $consentResult['abdm_consent_artifact_id'] - ?? $consentResult['consent_id'] - ?? '' - )); - if ($resolvedConsentId === '' || $this->isGatewayRequestIdPattern($resolvedConsentId)) { - $summary['skipped']++; - continue; - } - - $dataPayload = $payload; - $dataPayload['consent_id'] = $resolvedConsentId; - $dataPayload['consent_request_id'] = $resolvedConsentId; - $dataPayload['abdm_consent_artifact_id'] = $resolvedConsentId; - $dataPayload['abdm_consent_request_id'] = $resolvedConsentId; - - $dataResult = $this->runOperation('data_fetch', $dataPayload); - if ((int) ($dataResult['ok'] ?? 0) === 1) { - $summary['data_updates']++; - } else { - $summary['failed']++; - } - - // Merged-by-request-id fetch discovers NEW sibling artifacts, but - // does not reliably keep returning sessions for artifacts already - // established earlier (verified: an already-"received" artifact - // stopped appearing in the merged list while still returning real - // data when queried by its own artifact id). So remember every - // artifact id ever seen for this consent request, and re-poll each - // one individually every cycle too, for full coverage. - $singleArtifactId = trim((string) ( - $consentResult['abdm_consent_artifact_id'] ?? '' - )); - $fetchSessions = is_array($dataResult['sessions'] ?? null) ? $dataResult['sessions'] : []; - $this->recordDiscoveredArtifacts( - $resolvedConsentId, - $fetchSessions, - $payload['abha_address'], - $payload['hfr_id'] - ); - if ($singleArtifactId !== '' && ! $this->isGatewayRequestIdPattern($singleArtifactId)) { - $this->rememberArtifact($resolvedConsentId, $singleArtifactId, $payload['abha_address'], $payload['hfr_id']); - } - - foreach ($this->getKnownArtifactIds($resolvedConsentId) as $artifactId) { - if ($artifactId === '' || $artifactId === $resolvedConsentId) { - continue; - } - $artifactPayload = $payload; - $artifactPayload['consent_id'] = $artifactId; - $artifactPayload['consent_request_id'] = $artifactId; - $artifactPayload['abdm_consent_artifact_id'] = $artifactId; - $artifactPayload['abdm_consent_request_id'] = $artifactId; - - $artifactResult = $this->runOperation('data_fetch', $artifactPayload); - if ((int) ($artifactResult['ok'] ?? 0) === 1) { - $summary['data_updates']++; - $this->markArtifactPolled($resolvedConsentId, $artifactId, (string) ($artifactResult['consent_status'] ?? '')); - } else { - $summary['failed']++; - } } } else { $summary['failed']++; @@ -325,6 +245,120 @@ class M3HiuWorkflowService return $summary; } + /** + * Shared GRANTED-consent cascade: once a consent_reconcile call confirms + * GRANTED, this fetches decrypted data for EVERY known consent artifact + * under the umbrella consent_request_id -- not just one facility/HIP -- + * since a single M3 HIU consent can produce one artifact PER linked HIP + * facility (verified 2026-07-30: a 4-facility consent only surfaced one + * facility's records when data_fetch was called for a single artifact id). + * Used by both the `abdm:hiu-poll` cron (pollNatGateway) and the + * patient-facing "Check Live Status" action so a manual click gets the + * same full multi-facility fetch the cron would eventually perform. + * + * @param array $payload Same shape as the consent_reconcile payload + * (request_id, transaction_id, consent_id, + * abdm_consent_request_id, + * abdm_consent_artifact_id, abha_address, hfr_id). + * @param array $consentResult The result already returned by + * runOperation('consent_reconcile', $payload). + */ + public function fetchAllArtifactsAfterGrant(array $payload, array $consentResult): array + { + $result = ['granted' => false, 'data_updates' => 0, 'failed' => 0, 'artifact_ids' => []]; + + $consentState = strtolower(trim((string) ( + $consentResult['workflow_state'] + ?? $consentResult['status'] + ?? $consentResult['consent_status'] + ?? '' + ))); + if ($consentState !== 'granted') { + return $result; + } + $result['granted'] = true; + + // IMPORTANT: use the UMBRELLA consent_request_id here, not a single + // resolved consent artifact id. Multi-facility ABDM M3 consents + // produce ONE consent artifact PER linked HIP facility under the + // same consent_request_id. Per the bridge's own contract, calling + // GET /v1/hiu/data/fetch with the consent_request_id (instead of + // one specific artifact id) returns ALL sessions/artifacts merged + // in a single response (verified 2026-07-28: querying by + // consent_request_id surfaced a sibling artifact that had NEVER + // been seen by HMS before, alongside the originally-tracked one). + // Falls back to a single artifact id only if no request id is + // available (should not normally happen for a granted consent). + $resolvedConsentId = trim((string) ( + $consentResult['abdm_consent_request_id'] + ?? $consentResult['consent_request_id'] + ?? $consentResult['abdm_consent_artifact_id'] + ?? $consentResult['consent_id'] + ?? '' + )); + if ($resolvedConsentId === '' || $this->isGatewayRequestIdPattern($resolvedConsentId)) { + return $result; + } + + $dataPayload = $payload; + $dataPayload['consent_id'] = $resolvedConsentId; + $dataPayload['consent_request_id'] = $resolvedConsentId; + $dataPayload['abdm_consent_artifact_id'] = $resolvedConsentId; + $dataPayload['abdm_consent_request_id'] = $resolvedConsentId; + + $dataResult = $this->runOperation('data_fetch', $dataPayload); + if ((int) ($dataResult['ok'] ?? 0) === 1) { + $result['data_updates']++; + } else { + $result['failed']++; + } + + // Merged-by-request-id fetch discovers NEW sibling artifacts, but + // does not reliably keep returning sessions for artifacts already + // established earlier (verified: an already-"received" artifact + // stopped appearing in the merged list while still returning real + // data when queried by its own artifact id). So remember every + // artifact id ever seen for this consent request, and re-poll each + // one individually every cycle too, for full coverage. + $singleArtifactId = trim((string) ( + $consentResult['abdm_consent_artifact_id'] ?? '' + )); + $fetchSessions = is_array($dataResult['sessions'] ?? null) ? $dataResult['sessions'] : []; + $this->recordDiscoveredArtifacts( + $resolvedConsentId, + $fetchSessions, + $payload['abha_address'] ?? '', + $payload['hfr_id'] ?? '' + ); + if ($singleArtifactId !== '' && ! $this->isGatewayRequestIdPattern($singleArtifactId)) { + $this->rememberArtifact($resolvedConsentId, $singleArtifactId, $payload['abha_address'] ?? '', $payload['hfr_id'] ?? ''); + } + + foreach ($this->getKnownArtifactIds($resolvedConsentId) as $artifactId) { + if ($artifactId === '' || $artifactId === $resolvedConsentId) { + continue; + } + $result['artifact_ids'][] = $artifactId; + + $artifactPayload = $payload; + $artifactPayload['consent_id'] = $artifactId; + $artifactPayload['consent_request_id'] = $artifactId; + $artifactPayload['abdm_consent_artifact_id'] = $artifactId; + $artifactPayload['abdm_consent_request_id'] = $artifactId; + + $artifactResult = $this->runOperation('data_fetch', $artifactPayload); + if ((int) ($artifactResult['ok'] ?? 0) === 1) { + $result['data_updates']++; + $this->markArtifactPolled($resolvedConsentId, $artifactId, (string) ($artifactResult['consent_status'] ?? '')); + } else { + $result['failed']++; + } + } + + return $result; + } + + /** * Extracts and upserts every distinct artifact id found in a data_fetch * result's sessions[] array (each session's consent_id is typically diff --git a/app/Views/billing/Patient_Profile_Opd_V.php b/app/Views/billing/Patient_Profile_Opd_V.php index ec6f99b..3405a63 100644 --- a/app/Views/billing/Patient_Profile_Opd_V.php +++ b/app/Views/billing/Patient_Profile_Opd_V.php @@ -341,7 +341,13 @@ if ($patientPhotoPath === '') {
Consent Request History
- +
+ + +
@@ -538,6 +544,7 @@ $(function() { var abdmFetchOnlyUrl = 'id ?? 0)) ?>'; var abdmConsentDetailUrl = 'id ?? 0)) ?>'; var abdmConsentRequestsUrl = 'id ?? 0)) ?>'; + var abdmCheckLiveStatusUrl = 'id ?? 0)) ?>'; var abdmCustomRequestUrl = 'id ?? 0)) ?>'; var abdmDocDetailBaseUrl = 'id ?? 0)) ?>'; var currentAbdmRows = []; @@ -1190,6 +1197,40 @@ $(function() { loadAbdmConsentRequests(); }); + $(document).off('click.abdmOpd', '#btnCheckLiveAbdmStatus').on('click.abdmOpd', '#btnCheckLiveAbdmStatus', function() { + var $btn = $(this); + if ($btn.prop('disabled')) { + return; + } + $btn.prop('disabled', true); + $('#abdmLiveStatusSpinner').removeClass('d-none'); + setAbdmStatus('Checking live status with ABDM bridge…'); + + fetch(abdmCheckLiveStatusUrl, { credentials: 'same-origin' }) + .then(function(resp) { return resp.json(); }) + .then(function(data) { + if (!data || data.ok !== 1) { + throw new Error((data && data.error) || 'Live status check failed.'); + } + currentAbdmRequests = Array.isArray(data.requests) ? data.requests : []; + renderAbdmRequestsTable(currentAbdmRequests); + if (!data.reconcile_ok) { + setAbdmStatus('Refreshed from last saved status (bridge check failed: ' + (data.reconcile_error || 'unknown error') + ').', true); + } else if (data.granted) { + setAbdmStatus('Live status: GRANTED. Fetched data for ' + (data.artifacts_fetched || 0) + ' facility artifact(s)' + (data.data_fetch_failed ? (', ' + data.data_fetch_failed + ' failed') : '') + '.'); + } else { + setAbdmStatus('Live status refreshed from ABDM bridge.'); + } + }) + .catch(function(err) { + setAbdmStatus('Live status check failed: ' + (err.message || err), true); + }) + .then(function() { + $btn.prop('disabled', false); + $('#abdmLiveStatusSpinner').addClass('d-none'); + }); + }); + $(document).off('click.abdmOpd', '#btnSearchAbdmDocs').on('click.abdmOpd', '#btnSearchAbdmDocs', function() { loadAbdmDocs(); }); diff --git a/app/Views/billing/Patient_V.php b/app/Views/billing/Patient_V.php index 7d5a033..0ecb9c2 100644 --- a/app/Views/billing/Patient_V.php +++ b/app/Views/billing/Patient_V.php @@ -40,6 +40,7 @@ .abha-node.done .abha-node-label { color:#28a745; } .abha-panel { border-left:3px solid #007bff; padding-left:16px; } +
@@ -891,6 +892,33 @@ $('#abha_get_otp_btn').trigger('click'); }); + function renderWizardStep4Result(resp) { + var abhaNum = resp.abha_number || ''; + var abhaDisp = abhaNum.replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4'); + var abhaRaw = abhaNum.replace(/\D/g, ''); + var patMsg = ''; + if (resp.patient_id > 0) { + patMsg = resp.is_new_patient + ? 'New Patient Registered' + : 'Patient Found'; + patMsg += ' HMS ID: ' + (resp.p_code || '') + ''; + } + var cardBtn = abhaRaw.length === 14 + ? ' View/Print ABHA Card' + : ''; + $('#abha_created_result').html( + '
' + + '' + + 'ABHA Number Created!
' + + (abhaDisp ? 'ABHA: ' + abhaDisp + '
' : '') + + (resp.name ? 'Name: ' + resp.name + '
' : '') + + patMsg + + cardBtn + + '
' + ); + abhaStep(4); + } + // Step 2 → Verify OTP $('#abha_verify_otp_btn').on('click', function() { var otp = $('#abha_otp_input').val().trim(); @@ -912,30 +940,13 @@ $('#abha_verify_otp_btn').prop('disabled', false); if (resp && resp.ok == 1) { txnId = resp.txn_id || txnId; - var abhaNum = resp.abha_number || ''; - var abhaDisp = abhaNum.replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4'); - var abhaRaw = abhaNum.replace(/\D/g, ''); - var patMsg = ''; - if (resp.patient_id > 0) { - patMsg = resp.is_new_patient - ? 'New Patient Registered' - : 'Patient Found'; - patMsg += ' HMS ID: ' + (resp.p_code || '') + ''; + if (resp.need_confirmation) { + window.AbhaPatientMatchModal.open(resp, resp.candidates || [], function(finalResp) { + renderWizardStep4Result(finalResp); + }); + } else { + renderWizardStep4Result(resp); } - var cardBtn = abhaRaw.length === 14 - ? ' View/Print ABHA Card' - : ''; - $('#abha_created_result').html( - '
' + - '' + - 'ABHA Number Created!
' + - (abhaDisp ? 'ABHA: ' + abhaDisp + '
' : '') + - (resp.name ? 'Name: ' + resp.name + '
' : '') + - patMsg + - cardBtn + - '
' - ); - abhaStep(4); } else { showAlert('abha_step2_alert', 'danger', pickApiMessage(resp, 'Invalid OTP. Please try again.')); } @@ -994,6 +1005,33 @@ $('#abha_link_send_otp_btn').trigger('click'); }); + function renderLinkResult(resp) { + var abhaNum = resp.abha_number || ''; + var abhaDisp = abhaNum.replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4'); + var abhaRaw = abhaNum.replace(/\D/g, ''); + var patMsg = ''; + if (resp.patient_id > 0) { + patMsg = resp.is_new_patient + ? 'New Patient Registered' + : 'Patient Found'; + patMsg += ' HMS ID: ' + (resp.p_code || '') + ''; + } + var cardBtn = abhaRaw.length === 14 + ? '
View/Print ABHA Card' + : ''; + $('#link_result').html( + '
' + + '' + + 'ABHA Found!
' + + (abhaDisp ? 'ABHA: ' + abhaDisp + '
' : '') + + (resp.name ? 'Name: ' + resp.name + '
' : '') + + (resp.gender ? 'Gender: ' + resp.gender + '
' : '') + + (resp.dob ? 'DOB: ' + resp.dob + '
' : '') + + patMsg + cardBtn + + '
' + ); + } + $('#abha_link_verify_otp_btn').on('click', function() { var otp = $('#abha_link_otp_input').val().trim(); if (!/^\d{6}$/.test(otp)) { @@ -1013,30 +1051,13 @@ $('#abha_link_verify_spinner').addClass('d-none'); $('#abha_link_verify_otp_btn').prop('disabled', false); if (resp && resp.ok == 1) { - var abhaNum = resp.abha_number || ''; - var abhaDisp = abhaNum.replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4'); - var abhaRaw = abhaNum.replace(/\D/g, ''); - var patMsg = ''; - if (resp.patient_id > 0) { - patMsg = resp.is_new_patient - ? 'New Patient Registered' - : 'Patient Found'; - patMsg += ' HMS ID: ' + (resp.p_code || '') + ''; + if (resp.need_confirmation) { + window.AbhaPatientMatchModal.open(resp, resp.candidates || [], function(finalResp) { + renderLinkResult(finalResp); + }); + } else { + renderLinkResult(resp); } - var cardBtn = abhaRaw.length === 14 - ? '
View/Print ABHA Card' - : ''; - $('#link_result').html( - '
' + - '' + - 'ABHA Found!
' + - (abhaDisp ? 'ABHA: ' + abhaDisp + '
' : '') + - (resp.name ? 'Name: ' + resp.name + '
' : '') + - (resp.gender ? 'Gender: ' + resp.gender + '
' : '') + - (resp.dob ? 'DOB: ' + resp.dob + '
' : '') + - patMsg + cardBtn + - '
' - ); } else { linkAlert('link_step2_alert', 'danger', pickApiMessage(resp, 'OTP verification failed.')); } @@ -1462,7 +1483,13 @@ $('#' + spinId).addClass('d-none'); $('#' + btnId).prop('disabled', false); if (resp && resp.ok == 1) { - showRegResult(resp); + if (resp.need_confirmation) { + window.AbhaPatientMatchModal.open(resp, resp.candidates || [], function(finalResp) { + showRegResult(finalResp); + }); + } else { + showRegResult(resp); + } } else { regAlert(alertId, 'danger', pickApiMessage(resp, 'OTP verification failed.')); } diff --git a/app/Views/partials/abha_patient_match_modal.php b/app/Views/partials/abha_patient_match_modal.php new file mode 100644 index 0000000..ae320ec --- /dev/null +++ b/app/Views/partials/abha_patient_match_modal.php @@ -0,0 +1,270 @@ + + * Then call: window.AbhaPatientMatchModal.open(profileResp, candidates, function(confirmResp) { ... }); + */ +?> +
+ + + + + + +
Gender—
DOB / Age—
Mobile—
Address—
+
+
+ + + +
+
+
Matching Patients in HMS
+ 0 found +
+
+
+ No matching patient found in HMS by name, age or gender. You can create a new patient record. +
+
+ + + + + + + +