Add SQLite cached slow Tally extraction for cash payment review

This commit is contained in:
A R R R Associates
2026-09-03 23:52:18 +05:30
parent 247c545d20
commit 4739ad321e
6 changed files with 682 additions and 56 deletions
@@ -22,6 +22,7 @@
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
<h2 class="text-lg font-semibold text-slate-900">Cash Payment Compliance Review</h2>
<form id="cash-review-form" method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<input type="hidden" id="cash-csrf-token" value="{{ csrf_token }}">
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
<select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required onchange="this.form.submit()">
@@ -42,8 +43,7 @@
</div>
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<input type="hidden" name="run" value="1">
<div class="md:col-span-2 xl:col-span-4"><button id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Run Tally Review</button><p class="mt-2 text-xs text-slate-500">Safe-paced mode reads Tally in monthly batches with a short pause between requests to reduce load on TallyPrime.</p></div>
<div class="md:col-span-2 xl:col-span-4"><button type="button" id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Extract / Refresh &amp; Analyze</button><p class="mt-2 text-xs text-slate-500">Transactions are copied very slowly into the Local Agent SQLite accounting cache one day at a time. Analysis starts only after TallyPrime has been released.</p></div>
</form>
</div>
@@ -61,10 +61,24 @@
</div>
</section>
{% if cache_job %}
<section class="rounded-2xl border border-sky-200 bg-sky-50 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="font-semibold text-sky-950">Local Tally SQLite Cache</h3>
<p class="mt-1 text-sm text-sky-800">Status: <strong>{{ cache_job.status }}</strong>{% if cache_job.stage %} · {{ cache_job.stage }}{% endif %}</p>
</div>
<div class="text-right text-sm text-sky-900"><div>{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)</div><div>{{ cache_job.vouchers_cached }} voucher(s) cached</div></div>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-sky-100"><div class="h-full bg-sky-600" style="width:{{ cache_job.percent or 0 }}%"></div></div>
{% if cache_job.error %}<p class="mt-3 text-sm font-medium text-amber-900">{{ cache_job.error }}</p>{% endif %}
</section>
{% endif %}
{% if review %}
<section class="space-y-4">
{% if review.safe_paced_mode %}
<div class="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">Completed in adaptive safe-paced mode using {{ review.tally_request_batches or 0 }} successful voucher batch(es) across {{ review.tally_request_count or 0 }} Tally request(s), with {{ review.tally_retry_count or 0 }} retry/retries and {{ review.tally_fallback_batches or 0 }} adaptive fallback(s). The agent pauses approximately {{ review.tally_pause_ms or 0 }} ms between Tally calls.</div>
{% if review.sqlite_cache %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Analysis completed from the Local Agent SQLite cache. TallyPrime was used only for the slow background extraction and was released before this analysis ran. {{ review.tally_request_count or 0 }} Tally request(s), {{ review.tally_retry_count or 0 }} retry/retries, and approximately {{ review.tally_pause_ms or 0 }} ms cooling between successful day reads.</div>
{% endif %}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
@@ -119,60 +133,126 @@
</section>
{% endif %}
<div id="cash-review-progress" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-2xl">
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-slate-900">Cash Payment Analysis in Progress</h3>
<p id="cash-progress-text" class="mt-1 text-sm text-slate-600">Sending the review request to the ERP Local Agent…</p>
<h3 class="text-lg font-semibold text-slate-900">Slow Tally Extraction to Local SQLite</h3>
<p id="cash-progress-text" class="mt-1 text-sm text-slate-600">Submitting a background extraction job to the ERP Local Agent…</p>
</div>
<div id="cash-progress-percent" class="text-lg font-semibold text-brand-700">5%</div>
<div id="cash-progress-percent" class="text-lg font-semibold text-brand-700">0%</div>
</div>
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
<div id="cash-progress-bar" class="h-full rounded-full bg-brand-600 transition-all duration-700" style="width:5%"></div>
<div id="cash-progress-bar" class="h-full rounded-full bg-brand-600 transition-all duration-500" style="width:0%"></div>
</div>
<div class="mt-4 grid grid-cols-4 gap-2 text-center text-[11px] font-semibold uppercase tracking-wide text-slate-400">
<span>Agent</span><span>Tally</span><span>Vouchers</span><span>Analysis</span>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Days</div><div id="cash-progress-days" class="mt-1 font-semibold">0 / 0</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Vouchers cached</div><div id="cash-progress-vouchers" class="mt-1 font-semibold">0</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Tally requests</div><div id="cash-progress-requests" class="mt-1 font-semibold">0</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Current date</div><div id="cash-progress-date" class="mt-1 font-semibold">—</div></div>
</div>
<p class="mt-4 text-xs text-slate-500">Tally is deliberately read in 7-day batches with pauses between requests. If a batch times out, the agent automatically retries and reduces it to 3-day and then 1-day batches. Please keep TallyPrime and the ERP Local Agent open until this finishes.</p>
<p class="mt-4 text-xs text-slate-500">Only one day is requested from TallyPrime at a time. The agent waits about 3 seconds after every successful day. If Tally responds slowly, it cools for 20 seconds and retries only once. The tunnel heartbeat stays responsive because progress is read from SQLite, not from Tally.</p>
<div id="cash-progress-error" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"></div>
<div class="mt-4 flex justify-end"><button type="button" id="cash-progress-close" class="hidden rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Close</button></div>
</div>
</div>
<script>
(function () {
const form = document.getElementById('cash-review-form');
if (!form) return;
const button = document.getElementById('cash-review-button');
if (!form || !button) return;
const overlay = document.getElementById('cash-review-progress');
const bar = document.getElementById('cash-progress-bar');
const pct = document.getElementById('cash-progress-percent');
const text = document.getElementById('cash-progress-text');
const button = document.getElementById('cash-review-button');
const stages = [
[8, 'Sending the review request to the ERP Local Agent…'],
[18, 'Connecting to TallyPrime on the workstation…'],
[32, 'Reading cash and ledger masters…'],
[42, 'Reading vouchers in safe 7-day batches…'],
[58, 'Continuing voucher extraction with 1.5 second pauses…'],
[70, 'Large batches automatically fall back to 3-day or 1-day reads if needed…'],
[78, 'Checking same-day cash payment aggregation…'],
[88, 'Checking possible split-payment patterns…'],
[94, 'Preparing the compliance report…']
];
form.addEventListener('submit', function (event) {
const submitter = event.submitter;
// Client dropdown auto-submit should remain quick and should not show a review progress dialog.
if (!submitter || submitter.id !== 'cash-review-button') return;
const days = document.getElementById('cash-progress-days');
const vouchers = document.getElementById('cash-progress-vouchers');
const requests = document.getElementById('cash-progress-requests');
const currentDate = document.getElementById('cash-progress-date');
const errorBox = document.getElementById('cash-progress-error');
const closeButton = document.getElementById('cash-progress-close');
let pollTimer = null;
function show() {
overlay.classList.remove('hidden');
overlay.classList.add('flex');
errorBox.classList.add('hidden');
closeButton.classList.add('hidden');
}
function stopPolling() {
if (pollTimer) window.clearTimeout(pollTimer);
pollTimer = null;
}
function fail(message) {
stopPolling();
button.disabled = false;
text.textContent = 'Extraction paused safely.';
errorBox.textContent = message || 'The extraction did not complete.';
errorBox.classList.remove('hidden');
closeButton.classList.remove('hidden');
}
function update(job) {
const value = Math.max(0, Math.min(100, Number(job.percent || 0)));
bar.style.width = value + '%';
pct.textContent = value + '%';
text.textContent = job.stage || job.status || 'Working…';
days.textContent = String(job.completed_days || 0) + ' / ' + String(job.total_days || 0);
vouchers.textContent = String(job.vouchers_cached || 0);
requests.textContent = String(job.tally_requests || 0);
currentDate.textContent = job.current_date || '—';
}
async function poll(jobId, clientId, dateFrom, tallyGuid, dateTo) {
try {
const url = new URL('/tools/accounting/cash-payments/cache/progress', window.location.origin);
url.searchParams.set('client_id', clientId);
url.searchParams.set('job_id', jobId);
url.searchParams.set('date_from', dateFrom);
const response = await fetch(url.toString(), {headers: {'Accept': 'application/json'}});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not read extraction progress.');
const job = data.job || {};
update(job);
if (job.status === 'completed') {
text.textContent = 'SQLite extraction complete. Running cash-payment analysis locally…';
bar.style.width = '100%'; pct.textContent = '100%';
const target = new URL('/tools/accounting/cash-payments', window.location.origin);
target.searchParams.set('client_id', clientId);
target.searchParams.set('tally_guid', tallyGuid);
target.searchParams.set('date_from', dateFrom);
target.searchParams.set('date_to', dateTo);
target.searchParams.set('job_id', jobId);
target.searchParams.set('analyze', '1');
window.location.assign(target.toString());
return;
}
if (job.status === 'paused' || job.status === 'failed') {
fail(job.error || 'Tally extraction paused. Restart/verify TallyPrime and click Extract / Refresh & Analyze again to resume.');
return;
}
pollTimer = window.setTimeout(() => poll(jobId, clientId, dateFrom, tallyGuid, dateTo), 2000);
} catch (err) {
fail(err && err.message ? err.message : String(err));
}
}
button.addEventListener('click', async function () {
if (!form.reportValidity()) return;
const fd = new FormData(form);
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
button.disabled = true;
let i = 0;
const advance = function () {
if (i >= stages.length) return;
const stage = stages[i++];
bar.style.width = stage[0] + '%';
pct.textContent = stage[0] + '%';
text.textContent = stage[1];
if (i < stages.length) window.setTimeout(advance, i < 4 ? 1800 : 4500);
};
advance();
show();
try {
const response = await fetch('/tools/accounting/cash-payments/cache/start', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not start extraction.');
const job = data.job || {};
update(job);
poll(job.job_id, fd.get('client_id'), fd.get('date_from'), fd.get('tally_guid'), fd.get('date_to'));
} catch (err) {
fail(err && err.message ? err.message : String(err));
}
});
closeButton.addEventListener('click', function () {
overlay.classList.add('hidden'); overlay.classList.remove('flex');
});
})();
</script>