Fix cash ledger SQLite result transport to VPS

This commit is contained in:
A R R R Associates
2026-09-04 23:27:29 +05:30
parent 8536dc0457
commit 9cceec0283
5 changed files with 634 additions and 268 deletions
+122 -29
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from urllib.parse import quote
import uuid
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse
@@ -252,10 +253,11 @@ def cash_payment_review(
@router.post("/ledgers")
async def cash_payment_ledgers(request: Request):
"""Load Cash ledgers from the common client .act SQLite master snapshot.
"""Queue Cash-ledger preparation and return a durable VPS-known job id.
Normal loads are local-only. A live Tally master refresh happens only when
the user explicitly clicks Refresh Master Data or when no local snapshot exists.
The Local Agent reads the common client .act master in the background. Normal
loads are SQLite-only. Refresh Master Data is the only path that is allowed to
contact TallyPrime for a master refresh.
"""
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
@@ -284,37 +286,72 @@ async def cash_payment_ledgers(request: Request):
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
# The VPS generates the job id before sending the command. Even when a
# WebSocket ACK is lost, the browser can poll this known id and retrieve the
# ledger payload once the Local Agent has completed the SQLite read.
job_id = "CASHMASTER-" + uuid.uuid4().hex[:16].upper()
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
payload = {
**_accounting_storage_payload(selected_client, fy),
"tally_guid": tally_guid,
"company_name": company_name,
"ledger_scope": ledger_scope,
"force_refresh": force_refresh,
"job_id": job_id,
"requested_by_user_id": int(user.id),
}
# Reuse the common accounting master tables already stored in the
# client .act SQLite file. This avoids repeatedly querying TallyPrime
# just to populate a ledger selector. The Local Agent performs a live
# master refresh only when force_refresh is explicitly requested, or
# when no usable local snapshot exists yet.
payload["ledger_scope"] = ledger_scope
payload["force_refresh"] = force_refresh
result = request_agent_command(
node.node_code,
"accounting_cash_payment_cash_ledgers",
payload,
timeout_seconds=180 if force_refresh else 45,
timeout_seconds=12,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not load Tally ledgers.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
if result.get("ok"):
body = result.get("result") or {}
job = body.get("job") or {
"job_id": job_id,
"status": "queued",
"stage": "Local Agent accepted Cash ledger job",
"percent": 5,
}
return JSONResponse({"ok": True, "accepted": True, "job": job, **body})
return JSONResponse({
"ok": False,
"error": str(result.get("error") or "Local Agent rejected the Cash ledger request."),
}, status_code=409)
except Exception:
# A transport timeout does not mean the Local Agent failed. The job id
# is durable and known to the VPS; return it immediately and let the
# browser poll. This is specifically designed for intermittent tunnel
# keepalive/ACK loss.
return JSONResponse({
"ok": True,
"accepted": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "queued",
"stage": "Command sent; waiting for Local Agent acknowledgment",
"percent": 5,
"company_name": company_name,
},
})
finally:
db.close()
@router.get("/ledgers/progress")
def cash_payment_ledgers_progress(request: Request, client_id: int, job_id: str, date_from: str):
def cash_payment_ledgers_progress(
request: Request,
client_id: int,
job_id: str,
date_from: str,
tally_guid: str = "",
company_name: str = "",
ledger_scope: str = "cash",
force_refresh: int = 0,
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
@@ -326,23 +363,79 @@ def cash_payment_ledgers_progress(request: Request, client_id: int, job_id: str,
return JSONResponse({"ok": False, "error": "Client not found."}, status_code=404)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "running",
"stage": "Waiting for ERP Local Agent tunnel",
"percent": 85,
"company_name": company_name,
},
})
fy = _financial_year_for_date(date.fromisoformat(date_from))
payload = {
**_accounting_storage_payload(selected_client, fy),
"job_id": str(job_id),
"tally_guid": str(tally_guid or ""),
"company_name": str(company_name or ""),
"ledger_scope": str(ledger_scope or "cash"),
"force_refresh": bool(force_refresh),
"requested_by_user_id": int(user.id),
}
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
result = request_agent_command(
node.node_code,
"accounting_cash_payment_ledgers_status",
{
**_accounting_storage_payload(selected_client, fy),
"job_id": str(job_id),
},
timeout_seconds=15,
payload,
timeout_seconds=10,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not read ledger-sync progress.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
except Exception:
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "running",
"stage": "Local work complete or running; waiting to transfer result to VPS",
"percent": 90,
"company_name": company_name,
},
})
if result.get("ok"):
return JSONResponse({"ok": True, **(result.get("result") or {})})
error = str(result.get("error") or "")
# If the initial start command itself was lost before reaching the Local
# Agent, resend the same deterministic job id. The Local Agent treats this
# idempotently, so this cannot create duplicate work.
if "not found" in error.casefold():
try:
restarted = request_agent_command(
node.node_code,
"accounting_cash_payment_cash_ledgers",
payload,
timeout_seconds=10,
)
if restarted.get("ok"):
return JSONResponse({"ok": True, **(restarted.get("result") or {})})
except Exception:
pass
return JSONResponse({
"ok": True,
"transport_pending": True,
"job": {
"job_id": job_id,
"status": "queued",
"stage": "Retrying Cash ledger job delivery to Local Agent",
"percent": 10,
"company_name": company_name,
},
})
return JSONResponse({"ok": False, "error": error or "Could not read Cash ledger progress."}, status_code=409)
finally:
db.close()
@@ -56,30 +56,35 @@
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Cash Ledger</label>
<label class="text-xs font-semibold uppercase text-slate-500">Ledger Scope</label>
<select id="cash-ledger-scope" name="ledger_scope" class="mt-1 w-full rounded-xl border-slate-300 text-xs">
<option value="cash" selected>Ledgers under Cash-in-Hand</option>
<option value="all">All Tally ledgers — manual selection</option>
</select>
<div class="mt-2 flex gap-2">
<select id="cash-ledger-select" name="cash_ledger_name" class="min-w-0 flex-1 rounded-xl border-slate-300 text-sm" required>
<option value="">Select Cash ledger</option>
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
</select>
<label class="mt-2 block text-xs font-semibold uppercase text-slate-500">Cash Ledger Selection</label>
<select id="cash-ledger-select" name="cash_ledger_name" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required>
<option value="">Select Cash ledger</option>
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
</select>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" id="cash-load-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Load Cash Ledgers</button>
<button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh Master Data</button>
</div>
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Reads the common ledger master already stored in the client .act SQLite database. TallyPrime is contacted only when you click “Refresh Master Data” or when no local master snapshot exists yet.</p>
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Load Cash Ledgers reads the common master from the client .act SQLite database only. Refresh Master Data explicitly refreshes that common master from TallyPrime.</p>
<div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing Cash ledger list…</span><span id="cash-ledger-progress-pct">0%</span></div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
<div id="cash-master-step-1" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">1. Local Master</div><div class="mt-1 text-slate-500">.act SQLite</div></div>
<div id="cash-master-step-2" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">2. Cash Filter</div><div class="mt-1 text-slate-500">Cash-in-Hand</div></div>
<div id="cash-master-step-3" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">3. Ledger Ready</div><div class="mt-1 text-slate-500">No Tally query</div></div>
<div id="cash-master-step-3" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">3. Transfer to VPS</div><div class="mt-1 text-slate-500">Tunnel result</div></div>
<div id="cash-master-step-4" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">4. Ledger Ready</div><div class="mt-1 text-slate-500">Dropdown</div></div>
</div>
<div id="cash-master-meta" class="mt-2 text-[11px] text-slate-500">Master source: local .act SQLite. Use Refresh Master Data only when Tally masters have changed.</div>
<div id="cash-master-meta" class="mt-2 text-[11px] text-slate-500">Master source: local .act SQLite. Normal loading never queries TallyPrime.</div>
</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>
@@ -241,11 +246,12 @@
const masterStep1 = document.getElementById('cash-master-step-1');
const masterStep2 = document.getElementById('cash-master-step-2');
const masterStep3 = document.getElementById('cash-master-step-3');
const masterStep4 = document.getElementById('cash-master-step-4');
const masterMeta = document.getElementById('cash-master-meta');
let ledgerPollTimer = null;
function paintMasterSteps(step) {
[masterStep1, masterStep2, masterStep3].forEach(function(el, idx) {
[masterStep1, masterStep2, masterStep3, masterStep4].forEach(function(el, idx) {
if (!el) return;
el.className = 'rounded-lg border px-2 py-2 text-center ' + (idx < step
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
@@ -286,7 +292,7 @@
from_cache: Boolean(data.from_cache)
};
renderLedgerOptions();
paintMasterSteps(3);
paintMasterSteps(4);
if (masterMeta) {
const synced = data.master_synced_at_utc ? new Date(data.master_synced_at_utc).toLocaleString() : 'time not available';
const source = data.master_refreshed ? 'TallyPrime → local .act SQLite (refreshed now)' : 'local .act SQLite';
@@ -300,27 +306,51 @@
ledgerProgressBar.style.width=p+'%';
ledgerProgressPct.textContent=Math.round(p)+'%';
ledgerProgressStage.textContent=job.stage||job.status||'Working…';
if (p >= 90) paintMasterSteps(3);
if (p >= 100) paintMasterSteps(4);
else if (p >= 90) paintMasterSteps(3);
else if (p >= 60) paintMasterSteps(2);
else if (p >= 15) paintMasterSteps(1);
}
async function pollLedgers(jobId, clientId, dateFrom) {
async function pollLedgers(jobId, clientId, dateFrom, tallyGuid, companyName, selectedScope, forceRefresh) {
try {
const url=new URL('/tools/accounting/cash-payments/ledgers/progress',window.location.origin);
url.searchParams.set('client_id',clientId); url.searchParams.set('job_id',jobId); url.searchParams.set('date_from',dateFrom);
url.searchParams.set('client_id',clientId);
url.searchParams.set('job_id',jobId);
url.searchParams.set('date_from',dateFrom);
url.searchParams.set('tally_guid',tallyGuid || '');
url.searchParams.set('company_name',companyName || '');
url.searchParams.set('ledger_scope',selectedScope || 'cash');
url.searchParams.set('force_refresh',forceRefresh ? '1' : '0');
const response=await fetch(url.toString(),{headers:{'Accept':'application/json'},cache:'no-store'});
const data=await response.json();
if(!response.ok||!data.ok) throw new Error(data.error||'Could not read Tally ledger progress.');
const job=data.job||{}; updateLedgerProgress(job);
if(job.status==='completed'){
populateLedgers(data); ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1800); return;
if(!response.ok||!data.ok) throw new Error(data.error||'Could not read Cash ledger transfer progress.');
const job=data.job||{};
updateLedgerProgress(job);
if(data.transport_pending){
ledgerHelp.textContent='Local Agent work is queued/running. Waiting for the tunnel to deliver the ledger result to the VPS…';
}
if(job.status==='failed') throw new Error(job.error||'Tally ledger sync failed.');
ledgerPollTimer=window.setTimeout(()=>pollLedgers(jobId,clientId,dateFrom),1500);
if(job.status==='completed' && Array.isArray(data.ledgers) && data.ledgers.length >= 0){
populateLedgers(data);
ledgerButton.disabled=false;
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width='100%';
ledgerProgressPct.textContent='100%';
ledgerProgressStage.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) transmitted to VPS';
paintMasterSteps(4);
ledgerHelp.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) received by VPS and ready for selection.';
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1800);
return;
}
if(job.status==='failed') throw new Error(job.error||'Cash ledger preparation failed.');
ledgerPollTimer=window.setTimeout(
()=>pollLedgers(jobId,clientId,dateFrom,tallyGuid,companyName,selectedScope,forceRefresh),
1500
);
} catch(err) {
ledgerHelp.textContent=err&&err.message?err.message:String(err); ledgerButton.disabled=false;
ledgerHelp.textContent=err&&err.message?err.message:String(err);
ledgerButton.disabled=false;
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
ledgerProgressStage.textContent=ledgerHelp.textContent;
}
@@ -364,14 +394,23 @@
ledgerProgressStage.textContent = data.cash_only
? String(data.ledger_count || 0)+' Cash-in-Hand ledger(s) ready from SQLite'
: String(data.ledger_count || 0)+' ledger(s) ready from SQLite';
paintMasterSteps(3);
paintMasterSteps(4);
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1200);
return;
}
const job=data.job||{};
if(!job.job_id) throw new Error('Local Agent did not return a ledger-sync job id.');
updateLedgerProgress(job);
pollLedgers(job.job_id,String(fd.get('client_id')||''),String(fd.get('date_from')||''));
if(data.transport_pending){ ledgerHelp.textContent='Command sent. Waiting for the Local Agent result to cross the tunnel back to VPS…'; }
pollLedgers(
job.job_id,
String(fd.get('client_id')||''),
String(fd.get('date_from')||''),
String(fd.get('tally_guid')||''),
companyName,
selectedScope,
Boolean(forceRefresh)
);
} catch(err) {
ledgerHelp.textContent = err && err.message ? err.message : String(err);
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;