Fix VPS async cash ledger loading

This commit is contained in:
A R R R Associates
2026-09-04 16:13:28 +05:30
parent 87bfc13cbd
commit d6ef57b434
6 changed files with 66 additions and 20 deletions
@@ -1,2 +1,2 @@
__version__ = "1.22.15"
__version__ = "1.22.16"
AGENT_NAME = "ERP Local Agent"
@@ -794,17 +794,20 @@ class AgentCommandProcessor:
def _cash_payment_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Backward-compatible non-blocking ledger request.
If a cached ledger master exists it is returned immediately. Otherwise a
background master-sync job is started and the caller receives its job id.
This path must never perform a live Tally status call before acknowledging
the ERP request. A cached master is returned immediately; otherwise the
background start path is used.
"""
client_id = int(payload.get("client_id"))
company, company_name = self._resolve_open_company(payload)
tally_guid = str(company.get("guid") or payload.get("tally_guid") or "").strip()
tally_guid = str(payload.get("tally_guid") or "").strip()
if not tally_guid:
raise ValueError("Mapped Tally company GUID is required.")
company_name = str(payload.get("company_name") or "").strip()
cached = self._cash_payment_cached_ledgers(client_id, tally_guid)
if cached:
return {
"status": "cached",
"company": company,
"company": {"guid": tally_guid, "name": company_name},
"company_name": company_name,
"ledgers": cached,
"cash_candidates": [x for x in cached if x["is_cash_candidate"]],
@@ -815,18 +818,36 @@ class AgentCommandProcessor:
return self._cash_payment_ledgers_start(payload)
def _cash_payment_ledgers_start(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Create/return a ledger-master job without blocking on Tally.
Live company validation and the ledger export happen only inside the
background worker. This guarantees the VPS receives an immediate job ACK
even when Tally needs a minute or more to enumerate a large ledger master.
"""
client_id = int(payload.get("client_id"))
company, company_name = self._resolve_open_company(payload)
tally_guid = str(company.get("guid") or payload.get("tally_guid") or "").strip()
tally_guid = str(payload.get("tally_guid") or "").strip()
if not tally_guid:
raise ValueError("Mapped Tally company GUID is required.")
expected_company_name = str(payload.get("company_name") or "").strip()
self._ensure_cash_ledger_schema(client_id)
cached = self._cash_payment_cached_ledgers(client_id, tally_guid)
force_refresh = bool(payload.get("force_refresh"))
if cached and not force_refresh:
cached_company = expected_company_name
if not cached_company:
with self.store.connect(client_id) as db:
row = db.execute(
"""SELECT company_name FROM cash_payment_ledger_cache
WHERE client_id=? AND tally_guid=? AND company_name<>''
ORDER BY synced_at_utc DESC LIMIT 1""",
(client_id, tally_guid),
).fetchone()
cached_company = str(row[0] or "") if row else ""
return {
"status": "cached",
"company": company,
"company_name": company_name,
"company": {"guid": tally_guid, "name": cached_company},
"company_name": cached_company,
"ledgers": cached,
"cash_candidates": [x for x in cached if x["is_cash_candidate"]],
"ledger_count": len(cached),
@@ -850,7 +871,7 @@ class AgentCommandProcessor:
"""INSERT INTO cash_payment_ledger_jobs
(job_id, client_id, tally_guid, company_name, status, stage, percent, ledger_count, error, started_at_utc, finished_at_utc)
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
(job_id, client_id, tally_guid, company_name, "queued", "Queued for Tally ledger master sync", 2, 0, "", now, ""),
(job_id, client_id, tally_guid, expected_company_name, "queued", "Queued for Tally ledger master sync", 2, 0, "", now, ""),
)
db.commit()
@@ -860,16 +881,33 @@ class AgentCommandProcessor:
"status": "queued",
"stage": "Queued for Tally ledger master sync",
"percent": 2,
"company_name": company_name,
"company_name": expected_company_name,
"ledger_count": 0,
})
def worker():
company_name = expected_company_name
try:
with self.store.connect(client_id) as db:
db.execute(
"UPDATE cash_payment_ledger_jobs SET status='running',stage=?,percent=? WHERE job_id=?",
("Connected to Tally; reading ledger master", 15, job_id),
("Validating mapped company with Tally", 8, job_id),
)
db.commit()
_cash_progress_snapshot({
"job_id": job_id, "job_kind": "ledger_master", "status": "running",
"stage": "Validating mapped company with Tally", "percent": 8,
"company_name": company_name, "ledger_count": 0,
})
# Potentially slow Tally status/preflight is deliberately inside the
# worker so it can never delay the ERP command acknowledgement.
company, live_company_name = self._resolve_open_company(payload)
company_name = live_company_name
with self.store.connect(client_id) as db:
db.execute(
"UPDATE cash_payment_ledger_jobs SET company_name=?,stage=?,percent=? WHERE job_id=?",
(company_name, "Connected to Tally; reading ledger master", 15, job_id),
)
db.commit()
_cash_progress_snapshot({
@@ -877,8 +915,7 @@ class AgentCommandProcessor:
"stage": "Connected to Tally; reading ledger master", "percent": 15,
"company_name": company_name, "ledger_count": 0,
})
# The Tally export is intentionally isolated in this background thread
# so websocket/tunnel keepalives and the HTTP command ACK remain responsive.
rows = self.tally.export_master_collection(company_name, "ledgers") or []
ledgers = self._cash_ledger_rows(rows)
_cash_progress_snapshot({
@@ -904,9 +941,9 @@ class AgentCommandProcessor:
)
db.execute(
"""UPDATE cash_payment_ledger_jobs
SET status='completed',stage=?,percent=100,ledger_count=?,finished_at_utc=?
SET status='completed',company_name=?,stage=?,percent=100,ledger_count=?,finished_at_utc=?
WHERE job_id=?""",
(f"{len(ledgers)} ledgers cached and ready", len(ledgers), synced, job_id),
(company_name, f"{len(ledgers)} ledgers cached and ready", len(ledgers), synced, job_id),
)
db.commit()
self.logger.info(
@@ -945,6 +982,9 @@ class AgentCommandProcessor:
with _CASH_LEDGER_THREADS_LOCK:
_CASH_LEDGER_THREADS[job_id] = thread
thread.start()
# Important: do not call Tally or wait for the worker here. Return the job
# record immediately so the VPS can poll progress independently.
return self._cash_payment_ledgers_status({**payload, "job_id": job_id})
def _cash_payment_ledgers_status(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -171,7 +171,7 @@ function gb(n){return (n/1073741824).toFixed(1)+' GB'}
function setProgress(title,pct,text,active=true){let b=document.getElementById('progressBox');b.style.display='block';document.getElementById('progressTitle').textContent=title;let p=Math.max(0,Math.min(100,Number(pct)||0));lastServerPercent=Math.max(lastServerPercent,p);document.getElementById('progressPct').textContent=Math.round(p)+'%';document.getElementById('progressFill').style.width=p+'%';document.getElementById('progressMsg').textContent=text||'';document.getElementById('progressFill').className='progressfill'+(active&&p<100?' pulse':'')}
function applyProgress(u){let p=(u&&u.progress)||{};if(!p.operation)return;let title=p.operation==='check'?'Checking for Updates':p.operation==='download'?'Downloading Update':'Applying Update';setProgress(title,p.percent||0,p.message||p.phase||'',!['updated','downloaded','current','available','error'].includes(p.status));if(p.operation==='install'&&p.target_version){installTarget=p.target_version}}
function setCashStep(id,state){let e=document.getElementById(id);e.className='cashstep'+(state?' '+state:'')}
async function refreshCashProgress(){try{const r=await fetch('/api/cash-progress',{cache:'no-store'});const d=await r.json();const j=d.job||{};if(!j.job_id){document.getElementById('cashJobSection').style.display='none';return}document.getElementById('cashJobSection').style.display='block';let p=Math.max(0,Math.min(100,Number(j.percent)||0));document.getElementById('cashJobPct').textContent=Math.round(p)+'%';document.getElementById('cashJobFill').style.width=p+'%';document.getElementById('cashJobStage').textContent=j.stage||j.status||'Working…';let isLedger=String(j.job_kind||'')==='ledger_master';document.querySelector('#cashJobSection h2').textContent=isLedger?'Cash Payment — Tally Ledger Sync':'Cash Payment Compliance';document.getElementById('cashJobSub').textContent=(j.company_name||'Tally company')+(j.cash_ledgers&&j.cash_ledgers.length?' • Cash ledger: '+j.cash_ledgers.join(', '):'');if(isLedger){document.getElementById('cashDays').parentElement.querySelector('.label').textContent='Ledgers';document.getElementById('cashDays').textContent=String(j.ledger_count||0);document.getElementById('cashVouchers').parentElement.querySelector('.label').textContent='Status';document.getElementById('cashVouchers').textContent=j.status||'working';document.getElementById('cashRequests').parentElement.querySelector('.label').textContent='Progress';document.getElementById('cashRequests').textContent=Math.round(p)+'%';document.getElementById('cashDate').parentElement.querySelector('.label').textContent='Source';document.getElementById('cashDate').textContent='Tally ledger master';}else{document.getElementById('cashDays').parentElement.querySelector('.label').textContent='Days';document.getElementById('cashDays').textContent=String(j.completed_days||0)+' / '+String(j.total_days||0);document.getElementById('cashVouchers').parentElement.querySelector('.label').textContent='Cash vouchers';document.getElementById('cashVouchers').textContent=String(j.vouchers_cached||0);document.getElementById('cashRequests').parentElement.querySelector('.label').textContent='Tally requests';document.getElementById('cashRequests').textContent=String(j.tally_requests||0);document.getElementById('cashDate').parentElement.querySelector('.label').textContent='Current date';document.getElementById('cashDate').textContent=j.current_date||'—';}let st=String(j.stage||'').toLowerCase(),status=String(j.status||'').toLowerCase();if(isLedger){setCashStep('cs1',(p>=15?'done':(p>0?'run':'')));setCashStep('cs2',(status==='completed'?'done':'run'));setCashStep('cs3','');setCashStep('cs4','');setCashStep('cs5',(status==='completed'?'done':''));}else{setCashStep('cs1',(p>=10?'done':(p>0?'run':'')));setCashStep('cs2',(p>=20?'done':(st.includes('ledger')?'run':'')));setCashStep('cs3',(p>=93?'done':(p>=20?'run':'')));setCashStep('cs4',(status==='completed'?'done':(st.includes('analys')?'run':'')));setCashStep('cs5',(status==='completed'?'done':''));}let er=document.getElementById('cashError');if(j.error){er.style.display='block';er.textContent=j.error}else{er.style.display='none'}}catch(e){}}
async function refreshCashProgress(){try{const r=await fetch('/api/cash-progress',{cache:'no-store'});const d=await r.json();const j=d.job||{};if(!j.job_id){document.getElementById('cashJobSection').style.display='none';return}document.getElementById('cashJobSection').style.display='block';let p=Math.max(0,Math.min(100,Number(j.percent)||0));document.getElementById('cashJobPct').textContent=Math.round(p)+'%';document.getElementById('cashJobFill').style.width=p+'%';document.getElementById('cashJobStage').textContent=j.stage||j.status||'Working…';let isLedger=String(j.job_kind||'')==='ledger_master';document.querySelector('#cashJobSection h2').textContent=isLedger?'Cash Payment — Tally Ledger Sync':'Cash Payment Compliance';document.getElementById('cashJobSub').textContent=(j.company_name||'Tally company')+(j.cash_ledgers&&j.cash_ledgers.length?' • Cash ledger: '+j.cash_ledgers.join(', '):'');if(isLedger){document.getElementById('cashDays').parentElement.querySelector('.label').textContent='Ledgers';document.getElementById('cashDays').textContent=String(j.ledger_count||0);document.getElementById('cashVouchers').parentElement.querySelector('.label').textContent='Status';document.getElementById('cashVouchers').textContent=j.status||'working';document.getElementById('cashRequests').parentElement.querySelector('.label').textContent='Progress';document.getElementById('cashRequests').textContent=Math.round(p)+'%';document.getElementById('cashDate').parentElement.querySelector('.label').textContent='Source';document.getElementById('cashDate').textContent='Tally ledger master';}else{document.getElementById('cashDays').parentElement.querySelector('.label').textContent='Days';document.getElementById('cashDays').textContent=String(j.completed_days||0)+' / '+String(j.total_days||0);document.getElementById('cashVouchers').parentElement.querySelector('.label').textContent='Cash vouchers';document.getElementById('cashVouchers').textContent=String(j.vouchers_cached||0);document.getElementById('cashRequests').parentElement.querySelector('.label').textContent='Tally requests';document.getElementById('cashRequests').textContent=String(j.tally_requests||0);document.getElementById('cashDate').parentElement.querySelector('.label').textContent='Current date';document.getElementById('cashDate').textContent=j.current_date||'—';}let st=String(j.stage||'').toLowerCase(),status=String(j.status||'').toLowerCase();if(isLedger){setCashStep('cs1',(p>=15?'done':(p>0?'run':'')));setCashStep('cs2','');setCashStep('cs3','');setCashStep('cs4','');setCashStep('cs5','');}else{setCashStep('cs1',(p>=10?'done':(p>0?'run':'')));setCashStep('cs2',(p>=20?'done':(st.includes('ledger')?'run':'')));setCashStep('cs3',(p>=93?'done':(p>=20?'run':'')));setCashStep('cs4',(status==='completed'?'done':(st.includes('analys')?'run':'')));setCashStep('cs5',(status==='completed'?'done':''));}let er=document.getElementById('cashError');if(j.error){er.style.display='block';er.textContent=j.error}else{er.style.display='none'}}catch(e){}}
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw new Error('status unavailable');const s=await r.json();offlineTicks=0;
let c=s.agent.connection_state||'unknown';let ce=document.getElementById('connection');ce.textContent=c.charAt(0).toUpperCase()+c.slice(1);ce.className='value '+(c==='connected'?'ok':'bad');
document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded';