ABDM OPD tab: fix duplicate fetched-document rows + add dedicated Fetch Records action

- M3HiuDocumentRepository::persistFromDataFetch no longer includes the
  per-call request_id in the document dedup hash. request_id is unique
  every time data_fetch runs, so it was causing a brand-new duplicate
  row to be inserted on every re-fetch of the exact same ABDM document.
  Now dedup key is transaction_id + consent_ref + care_context_reference
  + bundle_id, which stays stable across repeated fetches -> re-fetches
  UPDATE the existing row instead of duplicating it.
- Added Patient::abdm_content_fetch_only() + route
  billing/patient/abdm_content_fetch_only/(id): a lightweight action for
  when consent is already GRANTED/COMPLETED. It calls data_fetch directly
  using the saved consent reference, skipping consent_request + reconcile
  polling entirely, avoiding unnecessary bridge calls/noise.
- Patient_Profile_Opd_V.php: added a 'Fetch Records' button that only
  appears once consent phase is GRANTED or COMPLETED, wired to the new
  endpoint; on success it refreshes the document list in place. The
  original 'Fetch ABDM Records' button still owns the full consent
  request -> reconcile -> fetch flow and already stops/re-enables
  correctly on DENIED/FAILED (verified, no change needed there).
- One-time cleanup: deduped existing abdm_hiu_documents rows in dev DB
  (13 -> 3) and recomputed doc_hash with the new stable key.
This commit is contained in:
DevSoftTech Server 2026-07-28 04:34:08 +05:30
parent a5219bca2d
commit becff35929
13 changed files with 350 additions and 2 deletions

View File

@ -500,6 +500,7 @@ $routes->group('billing', function($routes) {
$routes->get('patient/abdm_document_detail/(:num)/(:num)', 'Patient::abdm_document_detail/$1/$2');
$routes->get('patient/abdm_content_request/(:num)', 'Patient::abdm_content_request/$1');
$routes->get('patient/abdm_content_auto_flow/(:num)', 'Patient::abdm_content_auto_flow/$1');
$routes->get('patient/abdm_content_fetch_only/(:num)', 'Patient::abdm_content_fetch_only/$1');
$routes->get('patient/abdm_timeline/(:num)', 'Patient::abdm_timeline/$1');
$routes->post('patient/save_profile_image/(:num)', 'Patient::save_profile_image/$1');
$routes->match(['GET', 'POST'], 'patient/patient_file_upload/(:num)', 'Patient::patient_file_upload/$1');

View File

@ -1756,6 +1756,103 @@ class Patient extends BaseController
]);
}
/**
* Fetch-only action for a patient whose consent is already GRANTED.
* Unlike abdm_content_auto_flow(), this does NOT create a new consent
* request and does NOT re-run consent reconcile polling — it directly
* pulls health data using the already-saved consent reference. This
* avoids spamming the bridge with duplicate consent_request/reconcile
* calls just to re-fetch already-granted records.
*/
public function abdm_content_fetch_only(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);
if ($abhaContext['abha_address'] === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'ABHA Address not available for this patient.',
]);
}
$lastSync = $this->getLatestAbdmSyncSnapshot((string) ($abhaContext['abha_address'] ?? ''));
$phase = strtoupper(trim((string) ($lastSync['phase'] ?? '')));
if (! in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'phase' => $phase !== '' ? $phase : 'IDLE',
'error' => 'Consent is not granted yet. Use "Fetch ABDM Records" to send/track a consent request first.',
]);
}
$consentRequestRef = trim((string) ($lastSync['consent_request_id'] ?? ''));
$consentArtifactRef = trim((string) ($lastSync['consent_id'] ?? ''));
if ($consentArtifactRef === '' && $consentRequestRef === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'phase' => $phase,
'error' => 'No saved consent reference found. Use "Fetch ABDM Records" to re-establish consent.',
]);
}
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($consentArtifactRef !== '') {
$fetchPayload['consentId'] = $consentArtifactRef;
} else {
$fetchPayload['consentRequestId'] = $consentRequestRef;
}
try {
$fetchResult = $service->runOperation('data_fetch', $fetchPayload);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'phase' => 'FETCH_FAILED',
'error' => 'Unable to fetch ABDM content: ' . $e->getMessage(),
]);
}
if ((int) ($fetchResult['ok'] ?? 0) !== 1) {
$httpCode = (int) ($fetchResult['http_code'] ?? 422);
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = 422;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'phase' => 'FETCH_FAILED',
'error' => (string) ($fetchResult['error_text'] ?? 'Data fetch failed.'),
'data' => ['data_fetch' => $fetchResult],
]);
}
return $this->response->setJSON([
'ok' => 1,
'phase' => 'COMPLETED',
'message' => 'Records fetched successfully using existing granted consent.',
'documents_persisted' => (int) ($fetchResult['documents_persisted'] ?? 0),
'documents_updated' => (int) ($fetchResult['documents_updated'] ?? 0),
'data' => ['data_fetch' => $fetchResult],
]);
}
public function abdm_timeline(int $pno)
{
if (! $this->db->tableExists('patient_master')) {

View File

@ -60,8 +60,13 @@ class M3HiuDocumentRepository
$resolvedAbha = $abhaAddress !== '' ? $abhaAddress : trim((string) ($summary['abha_address'] ?? ''));
$patientMap = $this->mapPatientByAbha($resolvedAbha, (string) ($summary['abha_number'] ?? ''));
// NOTE: request_id is intentionally excluded from the dedup key.
// It is unique per polling/fetch call, so including it here would
// create a brand-new duplicate row every time the same document is
// re-fetched (e.g. repeated "Fetch ABDM Records" clicks or retries).
// transaction_id + consent_ref + care_context_reference + bundle_id
// identify the same underlying document across repeated fetches.
$docHash = sha1(implode('|', [
$requestId,
$transactionId,
$consentRef,
$careContextRef,

View File

@ -316,6 +316,10 @@ if ($patientPhotoPath === '') {
<span class="abdm-btn-spinner spinner-border spinner-border-sm me-1 d-none" role="status" aria-hidden="true"></span>
<span id="btnAutoAbdmFlowLabel">Fetch ABDM Records</span>
</button>
<button type="button" class="btn btn-success d-none" id="btnFetchRecordsOnly">
<span class="abdm-btn-spinner spinner-border spinner-border-sm me-1 d-none" role="status" aria-hidden="true"></span>
<span id="btnFetchRecordsOnlyLabel">Fetch Records</span>
</button>
<div>
<button type="button" class="btn btn-link btn-sm p-0 mt-1" id="btnLoadAbdmDocs">Refresh list</button>
</div>
@ -421,6 +425,7 @@ $(function() {
var patientId = <?= (int) ($patient->id ?? 0) ?>;
var abdmDocumentsUrl = '<?= base_url('billing/patient/abdm_documents/' . (int) ($patient->id ?? 0)) ?>';
var abdmAutoFlowUrl = '<?= base_url('billing/patient/abdm_content_auto_flow/' . (int) ($patient->id ?? 0)) ?>';
var abdmFetchOnlyUrl = '<?= base_url('billing/patient/abdm_content_fetch_only/' . (int) ($patient->id ?? 0)) ?>';
var abdmDocDetailBaseUrl = '<?= base_url('billing/patient/abdm_document_detail/' . (int) ($patient->id ?? 0)) ?>';
var currentAbdmRows = [];
var autoFlowTimer = null;
@ -429,6 +434,7 @@ $(function() {
var autoFlowMaxAttempts = 15;
var autoFlowNeedsFreshRequest = false;
var autoFlowRunning = false;
var fetchOnlyRunning = false;
function updateConsentBadge(phase, needsFresh) {
var text = (phase || '').toString().toUpperCase();
@ -457,11 +463,24 @@ $(function() {
}
function applyConsentButtonState() {
$('#btnAutoAbdmFlow').prop('disabled', autoFlowRunning);
$('#btnAutoAbdmFlow').prop('disabled', autoFlowRunning || fetchOnlyRunning);
$('#btnAutoAbdmFlow .abdm-btn-spinner').toggleClass('d-none', !autoFlowRunning);
$('#btnAutoAbdmFlowLabel').text(autoFlowRunning ? 'Fetching...' : 'Fetch ABDM Records');
}
function applyFetchOnlyButtonState() {
$('#btnFetchRecordsOnly').prop('disabled', autoFlowRunning || fetchOnlyRunning);
$('#btnFetchRecordsOnly .abdm-btn-spinner').toggleClass('d-none', !fetchOnlyRunning);
$('#btnFetchRecordsOnlyLabel').text(fetchOnlyRunning ? 'Fetching...' : 'Fetch Records');
}
// Only show the standalone "Fetch Records" action once consent has actually
// been granted (or already completed once) — it re-pulls data using the
// saved consent reference without creating a brand-new consent request.
function showFetchRecordsButton(show) {
$('#btnFetchRecordsOnly').toggleClass('d-none', !show);
}
var zoom = 1;
var minZoom = 0.5;
var maxZoom = 3;
@ -677,6 +696,7 @@ $(function() {
setFlowProgress(0);
}
showFetchRecordsButton(lastPhase === 'GRANTED' || lastPhase === 'COMPLETED');
updateConsentBadge(lastPhase, autoFlowNeedsFreshRequest);
var baseMsg = 'Loaded ' + currentAbdmRows.length + ' fetched ABDM record(s).';
@ -763,6 +783,7 @@ $(function() {
} else if (phase === 'REQUESTED' || phase === 'PENDING') {
setFlowProgress(1);
}
showFetchRecordsButton(phase === 'GRANTED' || phase === 'COMPLETED');
updateConsentBadge(phase, autoFlowNeedsFreshRequest);
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text(info + (autoFlowRequestId ? (' | Request ID: ' + autoFlowRequestId) : ''));
@ -815,6 +836,46 @@ $(function() {
});
}
// Standalone fetch: consent is already GRANTED, so this skips consent_request
// and reconcile polling entirely and just pulls data using the saved consent
// reference. Used by the "Fetch Records" button once consent is granted.
function runFetchOnlyStep() {
if (fetchOnlyRunning || autoFlowRunning) {
return;
}
fetchOnlyRunning = true;
applyFetchOnlyButtonState();
applyConsentButtonState();
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text('Fetching latest records using existing granted consent...');
fetch(abdmFetchOnlyUrl, { credentials: 'same-origin' })
.then(function(resp) { return resp.json().then(function(data) { return { status: resp.status, data: data }; }); })
.then(function(wrapped) {
var data = wrapped.data;
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Fetch failed.');
}
setFlowProgress(3);
showFetchRecordsButton(true);
updateConsentBadge('COMPLETED', false);
var info = (data.message || 'Records fetched successfully.').toString();
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text(info);
fetchOnlyRunning = false;
applyFetchOnlyButtonState();
applyConsentButtonState();
loadAbdmDocs();
})
.catch(function(err) {
fetchOnlyRunning = false;
applyFetchOnlyButtonState();
applyConsentButtonState();
$('#abdmStatusBox').removeClass('text-muted').addClass('text-danger').text('Fetch failed: ' + (err.message || err));
});
}
$(document).off('click.abdmOpd', '#btnLoadAbdmDocs').on('click.abdmOpd', '#btnLoadAbdmDocs', function() {
loadAbdmDocs();
});
@ -854,6 +915,10 @@ $(function() {
runAutoFlowStep();
});
$(document).off('click.abdmOpd', '#btnFetchRecordsOnly').on('click.abdmOpd', '#btnFetchRecordsOnly', function() {
runFetchOnlyStep();
});
$(document).off('shown.bs.tab.abdmOpd', '[data-bs-target="#opd-abdm-tab"]').on('shown.bs.tab.abdmOpd', '[data-bs-target="#opd-abdm-tab"]', function() {
if (!currentAbdmRows.length) {
loadAbdmDocs();
@ -863,6 +928,7 @@ $(function() {
$('[data-bs-toggle="tooltip"]').tooltip();
applyConsentButtonState();
applyFetchOnlyButtonState();
updateConsentBadge('IDLE', false);
});
</script>

View File

@ -0,0 +1,16 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== Max id now ===\n";
print_r($db->query("SELECT MAX(id) maxid FROM abdm_hiu_workflows")->fetch_assoc());
echo "\n=== rows id > 89 for abha 91510165305101@sbx ===\n";
$res = $db->query("SELECT id, operation, workflow_state, status, http_code, request_id, gateway_request_id, created_at FROM abdm_hiu_workflows WHERE abha_address = '91510165305101@sbx' AND id > 89 ORDER BY id ASC");
while ($row = $res->fetch_assoc()) { print_r($row); }
echo "\n=== search for 6dbbe21b anywhere ===\n";
$res2 = $db->query("SELECT id, operation, request_id, gateway_request_id, created_at FROM abdm_hiu_workflows WHERE request_id LIKE '%6dbbe21b%' OR gateway_request_id LIKE '%6dbbe21b%'");
while ($row = $res2->fetch_assoc()) { print_r($row); }
echo "\n=== doc count for abha now ===\n";
print_r($db->query("SELECT COUNT(*) c FROM abdm_hiu_documents WHERE abha_address = '91510165305101@sbx'")->fetch_assoc());
$db->close();

15
check_hiu_documents.php Normal file
View File

@ -0,0 +1,15 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== abdm_hiu_documents columns ===\n";
$res = $db->query("SHOW COLUMNS FROM abdm_hiu_documents");
while ($row = $res->fetch_assoc()) { echo $row['Field'] . "\n"; }
echo "\n=== rows for abha 91510165305101@sbx ===\n";
$res2 = $db->query("SELECT id, workflow_id, request_id, transaction_id, consent_ref, abha_address, patient_id, patient_name, care_context_reference, document_title, document_date, created_at, updated_at FROM abdm_hiu_documents WHERE abha_address = '91510165305101@sbx'");
while ($row = $res2->fetch_assoc()) { print_r($row); }
echo "\n=== total row count in table ===\n";
$res3 = $db->query("SELECT COUNT(*) c FROM abdm_hiu_documents");
print_r($res3->fetch_assoc());
$db->close();

View File

@ -0,0 +1,22 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
$abha = '91510165305101@sbx';
echo "=== Columns ===\n";
$res = $db->query("SHOW COLUMNS FROM abdm_hiu_workflows");
while ($row = $res->fetch_assoc()) { echo $row['Field'] . " (" . $row['Type'] . ")\n"; }
echo "\n=== Latest 20 rows for {$abha} ===\n";
$res2 = $db->query("SELECT * FROM abdm_hiu_workflows WHERE abha_address = '{$abha}' ORDER BY id DESC LIMIT 20");
while ($row = $res2->fetch_assoc()) {
echo "--- id={$row['id']} ---\n";
foreach ($row as $k => $v) {
if (in_array($k, ['request_payload', 'response_payload', 'raw_response'])) {
echo "$k: " . substr((string)$v, 0, 500) . "\n";
} else {
echo "$k: $v\n";
}
}
echo "\n";
}
$db->close();

View File

@ -0,0 +1,11 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== patient_master rows matching Devender Singh or abha 91510165305101@sbx ===\n";
$res = $db->query("SELECT id, p_fname, p_lname FROM patient_master WHERE p_fname LIKE '%Devender%' OR p_fname LIKE '%devender%'");
while ($row = $res->fetch_assoc()) { print_r($row); }
echo "\n=== Search abha columns (guess col names) ===\n";
$res2 = $db->query("SHOW COLUMNS FROM patient_master LIKE '%abha%'");
while ($row = $res2->fetch_assoc()) { echo $row['Field'] . "\n"; }
$db->close();

17
check_rows_80_89.php Normal file
View File

@ -0,0 +1,17 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== Rows 80-89 for abha 91510165305101@sbx ===\n";
$res = $db->query("SELECT * FROM abdm_hiu_workflows WHERE abha_address = '91510165305101@sbx' AND id >= 80 ORDER BY id ASC");
while ($row = $res->fetch_assoc()) {
echo "--- id={$row['id']} ---\n";
foreach ($row as $k => $v) {
if (in_array($k, ['request_json', 'response_json'])) {
echo "$k: " . substr((string)$v, 0, 900) . "\n";
} else {
echo "$k: $v\n";
}
}
echo "\n";
}
$db->close();

52
dedupe_abdm_documents.php Normal file
View File

@ -0,0 +1,52 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== Before cleanup: total rows ===\n";
print_r($db->query("SELECT COUNT(*) c FROM abdm_hiu_documents")->fetch_assoc());
// Group by the new dedup key (transaction_id, consent_ref, care_context_reference, bundle_id)
// and keep only the newest row (highest id) per group; delete the rest.
$res = $db->query("
SELECT transaction_id, consent_ref, care_context_reference, bundle_id, GROUP_CONCAT(id ORDER BY id DESC) ids
FROM abdm_hiu_documents
GROUP BY transaction_id, consent_ref, care_context_reference, bundle_id
HAVING COUNT(*) > 1
");
$totalDeleted = 0;
while ($row = $res->fetch_assoc()) {
$ids = explode(',', $row['ids']);
$keepId = array_shift($ids); // newest (first in DESC order)
if (empty($ids)) {
continue;
}
$idsList = implode(',', array_map('intval', $ids));
$db->query("DELETE FROM abdm_hiu_documents WHERE id IN ($idsList)");
$totalDeleted += $db->affected_rows;
echo "Kept id={$keepId}, deleted ids=[{$idsList}]\n";
}
echo "\nTotal deleted: {$totalDeleted}\n";
// Recompute doc_hash for all remaining rows using the new (request_id-free) formula,
// so future fetches correctly match and UPDATE instead of INSERT duplicates.
echo "\n=== Recomputing doc_hash for remaining rows ===\n";
$res2 = $db->query("SELECT id, transaction_id, consent_ref, care_context_reference, bundle_id FROM abdm_hiu_documents");
$updated = 0;
while ($row = $res2->fetch_assoc()) {
$hash = sha1(implode('|', [
(string) $row['transaction_id'],
(string) $row['consent_ref'],
(string) $row['care_context_reference'],
(string) $row['bundle_id'],
]));
$escapedHash = $db->real_escape_string($hash);
$db->query("UPDATE abdm_hiu_documents SET doc_hash = '{$escapedHash}' WHERE id = " . (int) $row['id']);
$updated++;
}
echo "Recomputed hash for {$updated} rows.\n";
echo "\n=== After cleanup: total rows ===\n";
print_r($db->query("SELECT COUNT(*) c FROM abdm_hiu_documents")->fetch_assoc());
$db->close();

16
find_abdm_tables.php Normal file
View File

@ -0,0 +1,16 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== Max id now ===\n";
$res = $db->query("SELECT MAX(id) maxid FROM abdm_hiu_workflows");
print_r($res->fetch_assoc());
echo "\n=== Show tables like %health% or %record% or %m3% or %hiu% ===\n";
$res2 = $db->query("SHOW TABLES");
while ($row = $res2->fetch_array()) {
$t = $row[0];
if (stripos($t, 'health') !== false || stripos($t, 'record') !== false || stripos($t, 'm3') !== false || stripos($t, 'hiu') !== false || stripos($t, 'abdm') !== false) {
echo "$t\n";
}
}
$db->close();

24
find_request_6dbbe21b.php Normal file
View File

@ -0,0 +1,24 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
echo "=== Search request_id/gateway_request_id containing 6dbbe21b ===\n";
$res = $db->query("SELECT * FROM abdm_hiu_workflows WHERE request_id LIKE '%6dbbe21b%' OR gateway_request_id LIKE '%6dbbe21b%' OR hms_request_id LIKE '%6dbbe21b%'");
$found = 0;
while ($row = $res->fetch_assoc()) {
$found++;
echo "--- id={$row['id']} ---\n";
foreach ($row as $k => $v) {
if (in_array($k, ['request_json', 'response_json'])) {
echo "$k: " . substr((string)$v, 0, 800) . "\n";
} else {
echo "$k: $v\n";
}
}
echo "\n";
}
echo "Found: $found\n";
echo "\n=== Count of rows for abha 91510165305101@sbx ===\n";
$res2 = $db->query("SELECT COUNT(*) c, MAX(id) maxid FROM abdm_hiu_workflows WHERE abha_address = '91510165305101@sbx'");
print_r($res2->fetch_assoc());
$db->close();

6
verify_no_dupes.php Normal file
View File

@ -0,0 +1,6 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
print_r($db->query("SELECT COUNT(*) c FROM abdm_hiu_documents WHERE abha_address = '91510165305101@sbx'")->fetch_assoc());
$res = $db->query("SELECT id, request_id, care_context_reference, updated_at FROM abdm_hiu_documents WHERE abha_address = '91510165305101@sbx'");
while ($row = $res->fetch_assoc()) { print_r($row); }
$db->close();