Fix opening balance scope and add async cash ledger loading

This commit is contained in:
A R R R Associates
2026-09-04 15:30:39 +05:30
parent 58752e30e1
commit 3ad20087be
9 changed files with 508 additions and 34 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.14"
ERP_LOCAL_AGENT_VERSION = "1.22.15"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.22.14"
__version__ = "1.22.15"
AGENT_NAME = "ERP Local Agent"
@@ -22,6 +22,8 @@ _CASH_CACHE_THREADS_LOCK = threading.Lock()
_CASH_CACHE_CANCEL_REQUESTS: set[str] = set()
_CASH_CACHE_STARTING: dict[str, dict[str, Any]] = {}
_CASH_PROGRESS_FILE = Path(__file__).resolve().parents[1] / "data" / "cash_payment_progress.json"
_CASH_LEDGER_THREADS: dict[str, threading.Thread] = {}
_CASH_LEDGER_THREADS_LOCK = threading.Lock()
def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
"""Publish one lightweight cash-job snapshot for the local dashboard.
@@ -106,6 +108,10 @@ class AgentCommandProcessor:
result = self._bank_reconciliation_extract(payload)
elif action == "accounting_cash_payment_ledgers":
result = self._cash_payment_ledgers(payload)
elif action == "accounting_cash_payment_ledgers_start":
result = self._cash_payment_ledgers_start(payload)
elif action == "accounting_cash_payment_ledgers_status":
result = self._cash_payment_ledgers_status(payload)
elif action == "accounting_cash_payment_cache_start":
result = self._cash_payment_cache_start(payload)
elif action == "accounting_cash_payment_cache_status":
@@ -166,6 +172,9 @@ class AgentCommandProcessor:
"cash_payment_background_sync_capability": True,
"cash_payment_ledger_confirmation_capability": True,
"cash_payment_dual_progress_capability": True,
"cash_payment_async_ledger_master_capability": True,
"cash_payment_ledger_cache_capability": True,
"opening_balance_balance_sheet_only_capability": True,
"tally_writeback_capability": True,
}
@@ -706,18 +715,21 @@ class AgentCommandProcessor:
}
def _cash_payment_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
rows = self.tally.export_master_collection(company_name, "ledgers") or []
ledgers = []
for row in rows:
@staticmethod
def _cash_ledger_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
ledgers: list[dict[str, Any]] = []
for row in rows or []:
name = str(row.get("name") or "").strip()
if not name:
continue
parent = str(row.get("parent") or "").strip()
reserved = str(row.get("reserved_name") or "").strip()
parent_key = parent.casefold().replace("-", " ")
is_cash = parent_key == "cash in hand" or reserved.casefold() == "cash" or name.casefold() == "cash"
is_cash = (
parent_key == "cash in hand"
or reserved.casefold() == "cash"
or name.casefold() == "cash"
)
ledgers.append({
"name": name,
"parent": parent,
@@ -726,12 +738,245 @@ class AgentCommandProcessor:
"is_cash_candidate": bool(is_cash),
})
ledgers.sort(key=lambda x: (not x["is_cash_candidate"], x["name"].casefold()))
return {
"company": company,
return ledgers
def _ensure_cash_ledger_schema(self, client_id: int) -> None:
with self.store.connect(client_id) as db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS cash_payment_ledger_cache (
client_id INTEGER NOT NULL,
tally_guid TEXT NOT NULL,
company_name TEXT NOT NULL,
ledger_name TEXT NOT NULL,
parent_name TEXT NOT NULL DEFAULT '',
reserved_name TEXT NOT NULL DEFAULT '',
closing_balance REAL,
is_cash_candidate INTEGER NOT NULL DEFAULT 0,
synced_at_utc TEXT NOT NULL,
PRIMARY KEY (client_id, tally_guid, ledger_name)
);
CREATE TABLE IF NOT EXISTS cash_payment_ledger_jobs (
job_id TEXT PRIMARY KEY,
client_id INTEGER NOT NULL,
tally_guid TEXT NOT NULL,
company_name TEXT NOT NULL,
status TEXT NOT NULL,
stage TEXT NOT NULL DEFAULT '',
percent INTEGER NOT NULL DEFAULT 0,
ledger_count INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
started_at_utc TEXT NOT NULL,
finished_at_utc TEXT NOT NULL DEFAULT ''
);
"""
)
db.commit()
def _cash_payment_cached_ledgers(self, client_id: int, tally_guid: str) -> list[dict[str, Any]]:
self._ensure_cash_ledger_schema(client_id)
with self.store.connect(client_id) as db:
rows = db.execute(
"""SELECT ledger_name, parent_name, reserved_name, closing_balance, is_cash_candidate
FROM cash_payment_ledger_cache
WHERE client_id=? AND tally_guid=?
ORDER BY is_cash_candidate DESC, lower(ledger_name), ledger_name""",
(int(client_id), str(tally_guid or "")),
).fetchall()
return [{
"name": str(row[0] or ""),
"parent": str(row[1] or ""),
"reserved_name": str(row[2] or ""),
"closing_balance": row[3],
"is_cash_candidate": bool(row[4]),
} for row in rows]
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.
"""
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()
cached = self._cash_payment_cached_ledgers(client_id, tally_guid)
if cached:
return {
"status": "cached",
"company": company,
"company_name": company_name,
"ledgers": cached,
"cash_candidates": [x for x in cached if x["is_cash_candidate"]],
"ledger_count": len(cached),
"from_cache": True,
"agent": self._agent_info(),
}
return self._cash_payment_ledgers_start(payload)
def _cash_payment_ledgers_start(self, payload: dict[str, Any]) -> dict[str, Any]:
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()
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:
return {
"status": "cached",
"company": company,
"company_name": company_name,
"ledgers": cached,
"cash_candidates": [x for x in cached if x["is_cash_candidate"]],
"ledger_count": len(cached),
"from_cache": True,
"agent": self._agent_info(),
}
with self.store.connect(client_id) as db:
running = db.execute(
"""SELECT job_id FROM cash_payment_ledger_jobs
WHERE client_id=? AND tally_guid=? AND status IN ('queued','running')
ORDER BY started_at_utc DESC LIMIT 1""",
(client_id, tally_guid),
).fetchone()
if running:
return self._cash_payment_ledgers_status({**payload, "job_id": str(running[0])})
job_id = "CASHLEDGER-" + uuid.uuid4().hex[:16].upper()
now = datetime.now(timezone.utc).isoformat()
db.execute(
"""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, ""),
)
db.commit()
_cash_progress_snapshot({
"job_id": job_id,
"job_kind": "ledger_master",
"status": "queued",
"stage": "Queued for Tally ledger master sync",
"percent": 2,
"company_name": company_name,
"ledger_count": 0,
})
def worker():
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),
)
db.commit()
_cash_progress_snapshot({
"job_id": job_id, "job_kind": "ledger_master", "status": "running",
"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({
"job_id": job_id, "job_kind": "ledger_master", "status": "running",
"stage": f"Received {len(ledgers)} ledgers; saving local cache", "percent": 82,
"company_name": company_name, "ledger_count": len(ledgers),
})
synced = datetime.now(timezone.utc).isoformat()
with self.store.connect(client_id) as db:
db.execute(
"DELETE FROM cash_payment_ledger_cache WHERE client_id=? AND tally_guid=?",
(client_id, tally_guid),
)
db.executemany(
"""INSERT INTO cash_payment_ledger_cache
(client_id,tally_guid,company_name,ledger_name,parent_name,reserved_name,closing_balance,is_cash_candidate,synced_at_utc)
VALUES (?,?,?,?,?,?,?,?,?)""",
[(
client_id, tally_guid, company_name, row["name"], row["parent"],
row["reserved_name"], row.get("closing_balance"),
1 if row["is_cash_candidate"] else 0, synced,
) for row in ledgers],
)
db.execute(
"""UPDATE cash_payment_ledger_jobs
SET status='completed',stage=?,percent=100,ledger_count=?,finished_at_utc=?
WHERE job_id=?""",
(f"{len(ledgers)} ledgers cached and ready", len(ledgers), synced, job_id),
)
db.commit()
self.logger.info(
"Cash ledger master sync completed client_id=%s company=%s rows=%s",
client_id, company_name, len(ledgers),
)
_cash_progress_snapshot({
"job_id": job_id, "job_kind": "ledger_master", "status": "completed",
"stage": f"{len(ledgers)} ledgers cached and ready", "percent": 100,
"company_name": company_name, "ledger_count": len(ledgers),
})
except Exception as exc:
finished = datetime.now(timezone.utc).isoformat()
try:
with self.store.connect(client_id) as db:
db.execute(
"""UPDATE cash_payment_ledger_jobs
SET status='failed',stage='Ledger master sync failed',error=?,finished_at_utc=?
WHERE job_id=?""",
(str(exc), finished, job_id),
)
db.commit()
except Exception:
pass
self.logger.exception("Cash ledger master background sync failed job_id=%s: %s", job_id, exc)
_cash_progress_snapshot({
"job_id": job_id, "job_kind": "ledger_master", "status": "failed",
"stage": "Ledger master sync failed", "percent": 100,
"company_name": company_name, "ledger_count": 0, "error": str(exc),
})
finally:
with _CASH_LEDGER_THREADS_LOCK:
_CASH_LEDGER_THREADS.pop(job_id, None)
thread = threading.Thread(target=worker, name=f"cash-ledgers-{job_id[-6:]}", daemon=True)
with _CASH_LEDGER_THREADS_LOCK:
_CASH_LEDGER_THREADS[job_id] = thread
thread.start()
return self._cash_payment_ledgers_status({**payload, "job_id": job_id})
def _cash_payment_ledgers_status(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
job_id = str(payload.get("job_id") or "").strip()
if not job_id:
raise ValueError("Cash ledger master job id is required.")
self._ensure_cash_ledger_schema(client_id)
with self.store.connect(client_id) as db:
row = db.execute(
"""SELECT job_id,tally_guid,company_name,status,stage,percent,ledger_count,error,started_at_utc,finished_at_utc
FROM cash_payment_ledger_jobs WHERE job_id=? AND client_id=?""",
(job_id, client_id),
).fetchone()
if not row:
raise ValueError("Cash ledger master job was not found.")
job = {
"job_id": str(row[0]), "tally_guid": str(row[1]), "company_name": str(row[2]),
"status": str(row[3]), "stage": str(row[4]), "percent": int(row[5] or 0),
"ledger_count": int(row[6] or 0), "error": str(row[7] or ""),
"started_at_utc": str(row[8] or ""), "finished_at_utc": str(row[9] or ""),
}
ledgers: list[dict[str, Any]] = []
if job["status"] == "completed":
ledgers = self._cash_payment_cached_ledgers(client_id, job["tally_guid"])
return {
"job": job,
"ledgers": ledgers,
"cash_candidates": [x for x in ledgers if x["is_cash_candidate"]],
"ledger_count": len(ledgers),
"ledger_count": len(ledgers) if ledgers else job["ledger_count"],
"company_name": job["company_name"],
"from_cache": bool(ledgers),
"agent": self._agent_info(),
}
@@ -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…';document.getElementById('cashJobSub').textContent=(j.company_name||'Tally company')+(j.cash_ledgers&&j.cash_ledgers.length?' • Cash ledger: '+j.cash_ledgers.join(', '):'');document.getElementById('cashDays').textContent=String(j.completed_days||0)+' / '+String(j.total_days||0);document.getElementById('cashVouchers').textContent=String(j.vouchers_cached||0);document.getElementById('cashRequests').textContent=String(j.tally_requests||0);document.getElementById('cashDate').textContent=j.current_date||'—';let st=String(j.stage||'').toLowerCase(),status=String(j.status||'').toLowerCase();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',(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 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';