Fix ABDM M3 HIU multi-facility consent session grouping and document display

- Fix groupWorkflowRowsIntoSessions() to correlate workflow rows by stable
  request_id/abdm_consent_request_id instead of chronological proximity to
  the newest CONSENT_REQUEST row. Background polling reconciles all
  historical sessions in one batch, so older sessions' rows previously got
  silently merged into the newest session, causing 'Show Data' to display
  an unrelated older session's single facility instead of all consented
  facilities.
- Skip synthetic orphan sessions (stray rows with no owning CONSENT_REQUEST)
  in getAbdmConsentRequestsList()/getAbdmConsentArtifactDetail() so they
  don't show as phantom history rows or get picked as 'most recent'.
- Fix abdm_documents() consent_request_id filter to match all known sibling
  artifact IDs for a session (via abdm_hiu_consent_artifacts), not just an
  exact single artifact id, so all facilities under one consent request
  appear together.
- Fix CreateAbdmHiuConsentArtifacts migration: reduce VARCHAR(180) to
  VARCHAR(100) on consent_request_id/artifact_id to satisfy MySQL's
  1000-byte composite key length limit (was silently failing to migrate).
- Add fetchAllArtifactsAfterGrant() cascade in M3HiuWorkflowService to fetch
  every known artifact under a granted consent, used by both the cron poll
  and a new abdm_check_live_status() endpoint/button.
- getLatestAbdmSyncSnapshot() session-scoping fix to avoid an older granted
  session masking a newer in-progress one.
- Relabel Dr. Joshi Clinic documents (previously mislabeled 'Test Hospital'
  from the HIP's own FHIR bundle Organization name).
This commit is contained in:
DevSoftTech Server 2026-07-30 05:08:44 +05:30
parent 82b7b27273
commit 55bcaa7d09
8 changed files with 1449 additions and 291 deletions

View File

@ -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');

View File

@ -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<int, array<string, mixed>>
*/
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'),

View File

@ -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<int, array<string, mixed>> $rows rows ordered DESC by id
* @return array<int, array<int, array<string, mixed>>>
@ -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;

View File

@ -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',

View File

@ -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

View File

@ -341,7 +341,13 @@ if ($patientPhotoPath === '') {
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Consent Request History</div>
<button type="button" class="btn btn-link btn-sm p-0" id="btnRefreshAbdmRequests">Refresh</button>
<div>
<button type="button" class="btn btn-link btn-sm p-0 me-3" id="btnCheckLiveAbdmStatus">
<span class="spinner-border spinner-border-sm d-none" id="abdmLiveStatusSpinner"></span>
Check Live Status
</button>
<button type="button" class="btn btn-link btn-sm p-0" id="btnRefreshAbdmRequests">Refresh</button>
</div>
</div>
<div class="table-responsive border rounded">
<table class="table table-sm table-hover mb-0" id="abdmRequestsTable">
@ -538,6 +544,7 @@ $(function() {
var abdmFetchOnlyUrl = '<?= base_url('billing/patient/abdm_content_fetch_only/' . (int) ($patient->id ?? 0)) ?>';
var abdmConsentDetailUrl = '<?= base_url('billing/patient/abdm_consent_detail/' . (int) ($patient->id ?? 0)) ?>';
var abdmConsentRequestsUrl = '<?= base_url('billing/patient/abdm_consent_requests/' . (int) ($patient->id ?? 0)) ?>';
var abdmCheckLiveStatusUrl = '<?= base_url('billing/patient/abdm_check_live_status/' . (int) ($patient->id ?? 0)) ?>';
var abdmCustomRequestUrl = '<?= base_url('billing/patient/abdm_content_request_custom/' . (int) ($patient->id ?? 0)) ?>';
var abdmDocDetailBaseUrl = '<?= base_url('billing/patient/abdm_document_detail/' . (int) ($patient->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();
});

View File

@ -40,6 +40,7 @@
.abha-node.done .abha-node-label { color:#28a745; }
.abha-panel { border-left:3px solid #007bff; padding-left:16px; }
</style>
<?= view('partials/abha_patient_match_modal') ?>
<section class="content">
<div class="row">
<div class="col-md-12">
@ -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
? '<span class="badge bg-success ms-1">New Patient Registered</span>'
: '<span class="badge bg-info ms-1">Patient Found</span>';
patMsg += ' <small class="text-muted">HMS ID: <strong>' + (resp.p_code || '') + '</strong></small>';
}
var cardBtn = abhaRaw.length === 14
? ' <a href="<?= base_url('abha/card/') ?>' + abhaRaw + '" target="_blank" class="btn btn-sm btn-outline-primary mt-1"><i class="bi bi-card-image me-1"></i>View/Print ABHA Card</a>'
: '';
$('#abha_created_result').html(
'<div class="alert alert-success">' +
'<i class="bi bi-check-circle-fill me-2"></i>' +
'<strong>ABHA Number Created!</strong><br>' +
(abhaDisp ? 'ABHA: <strong>' + abhaDisp + '</strong><br>' : '') +
(resp.name ? 'Name: ' + resp.name + '<br>' : '') +
patMsg +
cardBtn +
'</div>'
);
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
? '<span class="badge bg-success ms-1">New Patient Registered</span>'
: '<span class="badge bg-info ms-1">Patient Found</span>';
patMsg += ' <small class="text-muted">HMS ID: <strong>' + (resp.p_code || '') + '</strong></small>';
if (resp.need_confirmation) {
window.AbhaPatientMatchModal.open(resp, resp.candidates || [], function(finalResp) {
renderWizardStep4Result(finalResp);
});
} else {
renderWizardStep4Result(resp);
}
var cardBtn = abhaRaw.length === 14
? ' <a href="<?= base_url('abha/card/') ?>' + abhaRaw + '" target="_blank" class="btn btn-sm btn-outline-primary mt-1"><i class="bi bi-card-image me-1"></i>View/Print ABHA Card</a>'
: '';
$('#abha_created_result').html(
'<div class="alert alert-success">' +
'<i class="bi bi-check-circle-fill me-2"></i>' +
'<strong>ABHA Number Created!</strong><br>' +
(abhaDisp ? 'ABHA: <strong>' + abhaDisp + '</strong><br>' : '') +
(resp.name ? 'Name: ' + resp.name + '<br>' : '') +
patMsg +
cardBtn +
'</div>'
);
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
? '<span class="badge bg-success ms-1">New Patient Registered</span>'
: '<span class="badge bg-info ms-1">Patient Found</span>';
patMsg += ' <small class="text-muted">HMS ID: <strong>' + (resp.p_code || '') + '</strong></small>';
}
var cardBtn = abhaRaw.length === 14
? '<br><a href="<?= base_url('abha/card/') ?>' + abhaRaw + '" target="_blank" class="btn btn-sm btn-outline-primary mt-2"><i class="bi bi-card-image me-1"></i>View/Print ABHA Card</a>'
: '';
$('#link_result').html(
'<div class="alert alert-success">' +
'<i class="bi bi-check-circle-fill me-2"></i>' +
'<strong>ABHA Found!</strong><br>' +
(abhaDisp ? 'ABHA: <strong>' + abhaDisp + '</strong><br>' : '') +
(resp.name ? 'Name: ' + resp.name + '<br>' : '') +
(resp.gender ? 'Gender: ' + resp.gender + '<br>' : '') +
(resp.dob ? 'DOB: ' + resp.dob + '<br>' : '') +
patMsg + cardBtn +
'</div>'
);
}
$('#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
? '<span class="badge bg-success ms-1">New Patient Registered</span>'
: '<span class="badge bg-info ms-1">Patient Found</span>';
patMsg += ' <small class="text-muted">HMS ID: <strong>' + (resp.p_code || '') + '</strong></small>';
if (resp.need_confirmation) {
window.AbhaPatientMatchModal.open(resp, resp.candidates || [], function(finalResp) {
renderLinkResult(finalResp);
});
} else {
renderLinkResult(resp);
}
var cardBtn = abhaRaw.length === 14
? '<br><a href="<?= base_url('abha/card/') ?>' + abhaRaw + '" target="_blank" class="btn btn-sm btn-outline-primary mt-2"><i class="bi bi-card-image me-1"></i>View/Print ABHA Card</a>'
: '';
$('#link_result').html(
'<div class="alert alert-success">' +
'<i class="bi bi-check-circle-fill me-2"></i>' +
'<strong>ABHA Found!</strong><br>' +
(abhaDisp ? 'ABHA: <strong>' + abhaDisp + '</strong><br>' : '') +
(resp.name ? 'Name: ' + resp.name + '<br>' : '') +
(resp.gender ? 'Gender: ' + resp.gender + '<br>' : '') +
(resp.dob ? 'DOB: ' + resp.dob + '<br>' : '') +
patMsg + cardBtn +
'</div>'
);
} 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.'));
}

View File

@ -0,0 +1,270 @@
<?php
/**
* Partial: ABHA "Matching Patients" confirmation modal.
*
* Shown after a successful ABHA OTP verification (Aadhaar or Mobile) when the
* server could not safely auto-resolve a local patient (see
* Abha::tryAutoLinkByDirectMatch / Abha::findMatchingCandidates).
*
* Left column — the verified ABHA profile (source of truth from the gateway).
* Right column — candidate patient_master rows matched by Name/Age/Gender
* (and Mobile/Aadhaar when available), with matching fields
* highlighted so the operator can quickly decide.
*
* The operator must explicitly choose "Update Existing" (pick one candidate)
* or "Create New Patient" — HMS never silently auto-creates a patient once
* this modal is shown.
*
* Include once per page: <?= view('partials/abha_patient_match_modal') ?>
* Then call: window.AbhaPatientMatchModal.open(profileResp, candidates, function(confirmResp) { ... });
*/
?>
<div class="modal fade" id="abhaMatchModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-people-fill me-2"></i>Possible Matching Patients Found</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="abhaMatch_alert"></div>
<div class="row g-3">
<!-- Left: ABHA profile -->
<div class="col-md-5">
<div class="card h-100 border-primary">
<div class="card-header bg-primary text-white py-2">
<i class="bi bi-patch-check-fill me-1"></i>ABHA Verified Profile
</div>
<div class="card-body text-center">
<img id="abhaMatch_photo" src="" alt="Photo" class="rounded-circle d-none mb-2"
style="width:72px;height:72px;object-fit:cover;border:3px solid #0d6efd">
<i class="bi bi-person-circle text-primary d-block mb-2" id="abhaMatch_photo_ph" style="font-size:3.5rem"></i>
<h5 class="mb-1" id="abhaMatch_name">—</h5>
<div class="small text-muted mb-2" id="abhaMatch_abha">—</div>
<table class="table table-sm table-borderless text-start mb-0">
<tbody>
<tr><th class="text-muted small" style="width:40%">Gender</th><td id="abhaMatch_gender">—</td></tr>
<tr><th class="text-muted small">DOB / Age</th><td id="abhaMatch_dob">—</td></tr>
<tr><th class="text-muted small">Mobile</th><td id="abhaMatch_mobile">—</td></tr>
<tr><th class="text-muted small">Address</th><td id="abhaMatch_address">—</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Right: candidate list -->
<div class="col-md-7">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold"><i class="bi bi-search me-1"></i>Matching Patients in HMS</div>
<span class="badge bg-secondary" id="abhaMatch_count">0 found</span>
</div>
<div id="abhaMatch_candidates" style="max-height:360px;overflow-y:auto"></div>
<div id="abhaMatch_empty" class="alert alert-warning py-2 small d-none">
No matching patient found in HMS by name, age or gender. You can create a new patient record.
</div>
</div>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" id="abhaMatch_create_new_btn">
<i class="bi bi-person-plus me-1"></i>Create New Patient
</button>
<button type="button" class="btn btn-success" id="abhaMatch_update_existing_btn" disabled>
<i class="bi bi-link-45deg me-1"></i>Update Selected Patient
</button>
</div>
</div>
</div>
</div>
<script>
window.AbhaPatientMatchModal = (function () {
'use strict';
var _profile = null;
var _selectedId = 0;
var _onResolved = null;
var $modalEl;
var _bsModal;
function csrfPair() {
var inp = document.querySelector('input[name="<?= csrf_token() ?>"]');
return { name: '<?= csrf_token() ?>', value: inp ? inp.value : '<?= csrf_hash() ?>' };
}
function updateCsrf(r) {
if (!r || !r.csrfName || !r.csrfHash) { return; }
var inp = document.querySelector('input[name="' + r.csrfName + '"]');
if (inp) { inp.value = r.csrfHash; }
}
function esc(s) { return $('<div>').text(s == null ? '' : String(s)).html(); }
function genderText(g) {
g = String(g || '').toUpperCase();
if (g === 'M' || g === '1' || g === 'MALE') { return 'Male'; }
if (g === 'F' || g === '2' || g === 'FEMALE') { return 'Female'; }
if (g === 'O' || g === '3' || g === 'OTHER') { return 'Other'; }
return g || '—';
}
function maskMobile(m) {
m = String(m || '');
return m.length === 10 ? m.replace(/(\d{6})(\d{4})/, '******$2') : m;
}
function badge(matched, label) {
var cls = matched ? 'bg-success' : 'bg-light text-dark border';
return '<span class="badge ' + cls + ' me-1 mb-1">' + (matched ? '<i class="bi bi-check-lg"></i> ' : '') + label + '</span>';
}
function alertHtml(type, msg) {
return '<div class="alert alert-' + type + ' py-2">' + msg + '</div>';
}
function renderProfile(p) {
var abhaNum = p.abha_number || '';
var disp = abhaNum.replace(/(\d{2})(\d{4})(\d{4})(\d{4})/, '$1-$2-$3-$4');
$('#abhaMatch_name').text(p.name || '—');
$('#abhaMatch_abha').text(disp || '—');
$('#abhaMatch_gender').text(genderText(p.gender));
$('#abhaMatch_dob').text(p.dob || '—');
$('#abhaMatch_mobile').text(maskMobile(p.mobile));
$('#abhaMatch_address').text([p.address, p.district, p.state].filter(Boolean).join(', ') || '—');
if (p.photo) {
var src = String(p.photo).indexOf('data:') === 0 ? p.photo : 'data:image/jpeg;base64,' + p.photo;
$('#abhaMatch_photo').attr('src', src).removeClass('d-none');
$('#abhaMatch_photo_ph').addClass('d-none');
} else {
$('#abhaMatch_photo').addClass('d-none');
$('#abhaMatch_photo_ph').removeClass('d-none');
}
}
function renderCandidates(list) {
$('#abhaMatch_count').text(list.length + ' found');
var $wrap = $('#abhaMatch_candidates').empty();
if (!list.length) {
$('#abhaMatch_empty').removeClass('d-none');
return;
}
$('#abhaMatch_empty').addClass('d-none');
list.forEach(function (c) {
var m = c.match || {};
var conflictNote = c.abha_conflict
? '<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle-fill me-1"></i>Already has a different ABHA linked (' + esc(c.abha) + ')</div>'
: '';
var html = ''
+ '<div class="card mb-2 abhaMatch-candidate" data-id="' + c.id + '" style="cursor:pointer">'
+ ' <div class="card-body py-2">'
+ ' <div class="d-flex justify-content-between align-items-start">'
+ ' <div>'
+ ' <div class="fw-semibold">' + esc(c.name || '—') + ' <small class="text-muted">(' + esc(c.p_code || '') + ')</small></div>'
+ ' <div class="mt-1">'
+ badge(m.name, 'Name')
+ badge(m.age, 'Age ' + (c.age != null ? c.age : '?'))
+ badge(m.gender, genderText(c.gender))
+ badge(m.mobile, 'Mobile ' + maskMobile(c.mobile))
+ badge(m.aadhaar, 'Aadhaar')
+ ' </div>'
+ ' <div class="small text-muted mt-1">DOB: ' + esc(c.dob || '—') + ' | Mobile: ' + esc(maskMobile(c.mobile) || '—') + '</div>'
+ conflictNote
+ ' </div>'
+ ' <div class="form-check">'
+ ' <input class="form-check-input abhaMatch-radio" type="radio" name="abhaMatchPick" value="' + c.id + '"' + (c.abha_conflict ? ' disabled' : '') + '>'
+ ' </div>'
+ ' </div>'
+ ' </div>'
+ '</div>';
$wrap.append(html);
});
}
function selectCandidate(id) {
_selectedId = id;
$('.abhaMatch-candidate').removeClass('border-success bg-success-subtle');
$('.abhaMatch-candidate[data-id="' + id + '"]').addClass('border-success bg-success-subtle');
$('.abhaMatch-radio[value="' + id + '"]').prop('checked', true);
$('#abhaMatch_update_existing_btn').prop('disabled', !id);
}
function submitConfirm(action, patientId, $btn) {
$('#abhaMatch_alert').empty();
var origHtml = $btn.html();
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>Saving…');
$('#abhaMatch_create_new_btn,#abhaMatch_update_existing_btn').prop('disabled', true);
var c = csrfPair();
var payload = {
action: action,
patient_id: patientId || 0,
abha_number: _profile.abha_number || '',
name: _profile.name || '',
mobile: _profile.mobile || '',
gender: _profile.gender || '',
dob: _profile.dob || '',
abha_address: _profile.abha_address || '',
photo: _profile.photo || '',
verified_status: _profile.verified_status || '',
verification_type: _profile.verification_type || '',
kyc_verified: _profile.kyc_verified,
mobile_verified: _profile.mobile_verified,
address: _profile.address || '',
district: _profile.district || '',
state: _profile.state || '',
zip: _profile.zip || '',
email: _profile.email || ''
};
payload[c.name] = c.value;
$.post('<?= base_url('abha/create/confirm_patient') ?>', payload, function (resp) {
updateCsrf(resp);
$btn.prop('disabled', false).html(origHtml);
$('#abhaMatch_create_new_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId && $btn.attr('id') === 'abhaMatch_update_existing_btn');
$('#abhaMatch_create_new_btn').prop('disabled', false);
$('#abhaMatch_update_existing_btn').prop('disabled', !_selectedId);
if (!resp || resp.ok != 1) {
$('#abhaMatch_alert').html(alertHtml('danger', esc((resp && resp.error_text) || 'Failed to save patient link.')));
return;
}
if (_bsModal) { _bsModal.hide(); }
if (typeof _onResolved === 'function') {
_onResolved(Object.assign({}, _profile, resp));
}
}, 'json').fail(function () {
$btn.prop('disabled', false).html(origHtml);
$('#abhaMatch_create_new_btn').prop('disabled', false);
$('#abhaMatch_update_existing_btn').prop('disabled', !_selectedId);
$('#abhaMatch_alert').html(alertHtml('danger', 'Server error while saving. Please try again.'));
});
}
$(function () {
$modalEl = $('#abhaMatchModal');
_bsModal = window.bootstrap ? new bootstrap.Modal($modalEl[0]) : null;
$(document).on('click', '.abhaMatch-candidate', function () {
if ($(this).find('.abhaMatch-radio').is(':disabled')) { return; }
selectCandidate(parseInt($(this).data('id'), 10));
});
$('#abhaMatch_create_new_btn').on('click', function () {
submitConfirm('new', 0, $(this));
});
$('#abhaMatch_update_existing_btn').on('click', function () {
if (!_selectedId) { return; }
submitConfirm('existing', _selectedId, $(this));
});
});
return {
open: function (profile, candidates, onResolved) {
_profile = profile || {};
_selectedId = 0;
_onResolved = onResolved;
$('#abhaMatch_alert').empty();
$('#abhaMatch_update_existing_btn').prop('disabled', true);
renderProfile(_profile);
renderCandidates(candidates || []);
if (_bsModal) { _bsModal.show(); } else { $modalEl.modal('show'); }
}
};
})();
</script>