Add cash ledger extraction and dual progress UI

This commit is contained in:
A R R R Associates
2026-09-04 14:52:24 +05:30
parent c418dcb45a
commit 58752e30e1
6 changed files with 299 additions and 50 deletions
+48
View File
@@ -147,6 +147,7 @@ def cash_payment_review(
date_from: str = "",
date_to: str = "",
job_id: str = "",
cash_ledger_name: str = "",
analyze: int = 0,
saved: int = 0,
error: str = "",
@@ -238,6 +239,7 @@ def cash_payment_review(
review=review,
cache_job=cache_job,
active_job_id=str(job_id or ""),
selected_cash_ledger_name=str(cash_ledger_name or ""),
saved=bool(saved),
command_error=command_error,
allocation_plan=None,
@@ -248,6 +250,47 @@ def cash_payment_review(
db.close()
@router.post("/ledgers")
async def cash_payment_ledgers(request: Request):
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
clients, scope = _visible_clients(db, request, user)
try:
client_id = int(form.get("client_id") or 0)
except Exception:
client_id = 0
selected_client = next((row for row in clients if int(row.id) == client_id), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
tally_guid = str(form.get("tally_guid") or "").strip()
date_from = str(form.get("date_from") or date.today().isoformat()).strip()
if not tally_guid:
return JSONResponse({"ok": False, "error": "Select a mapped Tally company."}, status_code=400)
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)
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
result = request_agent_command(
node.node_code,
"accounting_cash_payment_ledgers",
{**_accounting_storage_payload(selected_client, fy), "tally_guid": tally_guid},
timeout_seconds=45,
)
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 Tally ledgers.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
finally:
db.close()
@router.post("/cache/start")
async def start_cash_payment_cache(request: Request):
form = await request.form()
@@ -266,6 +309,7 @@ async def start_cash_payment_cache(request: Request):
if not selected_client:
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
tally_guid = str(form.get("tally_guid") or "").strip()
cash_ledger_name = str(form.get("cash_ledger_name") or "").strip()
date_from = str(form.get("date_from") or "").strip()
date_to = str(form.get("date_to") or "").strip()
try:
@@ -277,6 +321,8 @@ async def start_cash_payment_cache(request: Request):
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
if not tally_guid:
return JSONResponse({"ok": False, "error": "Select a mapped Tally company."}, status_code=400)
if not cash_ledger_name:
return JSONResponse({"ok": False, "error": "Load the Tally ledgers and confirm the Cash ledger."}, status_code=400)
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)
@@ -290,6 +336,8 @@ async def start_cash_payment_cache(request: Request):
"tally_guid": tally_guid,
"date_from": date_from,
"date_to": date_to,
"cash_ledger_name": cash_ledger_name,
"cash_ledger_names": [cash_ledger_name],
"cash_limit": float(setting.cash_limit),
"split_window_days": int(setting.split_window_days),
"near_limit_percent": float(setting.near_limit_percent),
@@ -21,7 +21,7 @@
<section class="grid gap-4 lg:grid-cols-3">
<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">
<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-5">
<input type="hidden" id="cash-csrf-token" value="{{ csrf_token }}">
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
@@ -41,9 +41,20 @@
{% endif %}
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Cash Ledger</label>
<div class="mt-1 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="">Load and confirm cash ledger</option>
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
</select>
<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</button>
</div>
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Reads the Tally ledger master and lets you confirm the exact Cash ledger.</p>
</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>
<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>
<div class="md:col-span-2 xl:col-span-5"><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">Only vouchers containing the confirmed Cash ledger are stored in the cash-review cache. Extraction runs in the Local Agent background; the same graphical progress is visible here and on the Local Agent dashboard.</p></div>
</form>
</div>
@@ -68,7 +79,7 @@
<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 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 }} Cash-ledger voucher(s)</div>{% if cache_job.cash_ledgers %}<div>Ledger: {{ cache_job.cash_ledgers|join(', ') }}</div>{% endif %}</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 %}
@@ -136,7 +147,7 @@
<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">Slow Tally Extraction to Local SQLite</h3>
<h3 class="text-lg font-semibold text-slate-900">Cash Ledger Extraction & Compliance Progress</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">0%</div>
@@ -146,11 +157,18 @@
</div>
<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">Cash vouchers</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">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 class="mt-4 grid gap-2 text-xs md:grid-cols-5">
<div id="erp-cash-step-1" class="rounded-lg bg-slate-100 px-3 py-2">1. Connect</div>
<div id="erp-cash-step-2" class="rounded-lg bg-slate-100 px-3 py-2">2. Confirm ledger</div>
<div id="erp-cash-step-3" class="rounded-lg bg-slate-100 px-3 py-2">3. Extract</div>
<div id="erp-cash-step-4" class="rounded-lg bg-slate-100 px-3 py-2">4. Analyse</div>
<div id="erp-cash-step-5" class="rounded-lg bg-slate-100 px-3 py-2">5. Complete</div>
</div>
<p class="mt-4 text-xs text-slate-500">The confirmed Cash ledger is used throughout this job. The Local Agent reads Tally in safe one-day batches and stores only vouchers containing that ledger in the cash-review cache. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.</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 gap-2">
<button type="button" id="cash-progress-cancel" class="hidden rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900">Cancel Extraction</button>
@@ -178,6 +196,31 @@
let activeJobId = '';
let activeClientId = '';
let activeDateFrom = '';
const ledgerButton = document.getElementById('cash-load-ledgers');
const ledgerSelect = document.getElementById('cash-ledger-select');
const ledgerHelp = document.getElementById('cash-ledger-help');
async function loadCashLedgers() {
const fd = new FormData(form);
if (!fd.get('client_id') || !fd.get('tally_guid')) { ledgerHelp.textContent = 'Select a client and Tally company first.'; return; }
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
ledgerButton.disabled = true; ledgerHelp.textContent = 'Reading Tally ledger master…';
try {
const response = await fetch('/tools/accounting/cash-payments/ledgers', {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 load Tally ledgers.');
const previous = ledgerSelect.value; ledgerSelect.innerHTML = '<option value="">Confirm Cash ledger</option>';
(data.ledgers || []).forEach(function(row){
const opt=document.createElement('option'); opt.value=row.name; opt.textContent=row.name + (row.parent ? ' — '+row.parent : '') + (row.is_cash_candidate ? ' • Cash candidate' : '');
if ((previous && previous===row.name) || (!previous && row.is_cash_candidate && !ledgerSelect.value)) opt.selected=true;
ledgerSelect.appendChild(opt);
});
if (!ledgerSelect.value && (data.cash_candidates||[]).length===1) ledgerSelect.value=data.cash_candidates[0].name;
ledgerHelp.textContent = String(data.ledger_count || 0)+' ledger(s) loaded from '+(data.company_name || 'Tally')+'. Confirm the Cash ledger before extraction.';
} catch(err) { ledgerHelp.textContent = err && err.message ? err.message : String(err); }
finally { ledgerButton.disabled=false; }
}
if (ledgerButton) ledgerButton.addEventListener('click', loadCashLedgers);
function show() {
overlay.classList.remove('hidden');
@@ -208,6 +251,13 @@
vouchers.textContent = String(job.vouchers_cached || 0);
requests.textContent = String(job.tally_requests || 0);
currentDate.textContent = job.current_date || '—';
const stage = String(job.stage || '').toLowerCase();
const status = String(job.status || '').toLowerCase();
const steps = [1,2,3,4,5].map(n => document.getElementById('erp-cash-step-'+n));
steps.forEach(e => { if(e) e.className='rounded-lg bg-slate-100 px-3 py-2'; });
function done(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-emerald-50 px-3 py-2 font-semibold text-emerald-700'; }
function running(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-sky-50 px-3 py-2 font-semibold text-sky-700 ring-1 ring-sky-200'; }
if (value > 0) running(1); if (value >= 10) {done(1); running(2);} if (value >= 20) {done(2); running(3);} if (stage.includes('analys')) {done(3); running(4);} if (status === 'completed' && stage.includes('analysis complete')) {done(1);done(2);done(3);done(4);done(5);} else if (status === 'completed') {done(1);done(2);done(3);running(4);}
if (job.can_cancel) cancelButton.classList.remove('hidden'); else cancelButton.classList.add('hidden');
}
async function poll(jobId, clientId, dateFrom, tallyGuid, dateTo) {
@@ -230,6 +280,7 @@
target.searchParams.set('date_from', dateFrom);
target.searchParams.set('date_to', dateTo);
target.searchParams.set('job_id', jobId);
target.searchParams.set('cash_ledger_name', String(document.getElementById('cash-ledger-select').value || ''));
target.searchParams.set('analyze', '1');
window.location.assign(target.toString());
return;
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.13"
ERP_LOCAL_AGENT_VERSION = "1.22.14"
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.13"
__version__ = "1.22.14"
AGENT_NAME = "ERP Local Agent"
@@ -6,6 +6,7 @@ import time
import threading
import json
import uuid
from pathlib import Path
from typing import Any
from . import __version__
@@ -20,6 +21,38 @@ _CASH_CACHE_THREADS: dict[str, threading.Thread] = {}
_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"
def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
"""Publish one lightweight cash-job snapshot for the local dashboard.
This file is presentation state only. The authoritative extraction state
remains in the client .act SQLite database.
"""
try:
_CASH_PROGRESS_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = _CASH_PROGRESS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, default=str), encoding="utf-8")
tmp.replace(_CASH_PROGRESS_FILE)
except Exception:
pass
def _cash_stage_percent(status: str, stage: str, completed: int, total: int) -> int:
status_l = str(status or "").lower()
stage_l = str(stage or "").lower()
if status_l == "completed":
return 100
if "analysis" in stage_l:
return 95
if "ledger" in stage_l:
return 15
if "connect" in stage_l or status_l in {"starting", "queued"}:
return 5
if "cooling before" in stage_l:
return 20
if total > 0:
return min(92, 20 + int(round(max(0, completed) * 72 / max(1, total))))
return 10
def is_tally_cache_busy() -> bool:
return _CASH_CACHE_BUSY.is_set()
@@ -71,6 +104,8 @@ class AgentCommandProcessor:
result = self._bank_posting_preflight(payload)
elif action == "accounting_bank_reconciliation_extract":
result = self._bank_reconciliation_extract(payload)
elif action == "accounting_cash_payment_ledgers":
result = self._cash_payment_ledgers(payload)
elif action == "accounting_cash_payment_cache_start":
result = self._cash_payment_cache_start(payload)
elif action == "accounting_cash_payment_cache_status":
@@ -129,6 +164,8 @@ class AgentCommandProcessor:
"cash_payment_compliance_capability": True,
"cash_payment_sqlite_cache_capability": True,
"cash_payment_background_sync_capability": True,
"cash_payment_ledger_confirmation_capability": True,
"cash_payment_dual_progress_capability": True,
"tally_writeback_capability": True,
}
@@ -669,6 +706,35 @@ 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:
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"
ledgers.append({
"name": name,
"parent": parent,
"reserved_name": reserved,
"closing_balance": row.get("closing_balance"),
"is_cash_candidate": bool(is_cash),
})
ledgers.sort(key=lambda x: (not x["is_cash_candidate"], x["name"].casefold()))
return {
"company": company,
"company_name": company_name,
"ledgers": ledgers,
"cash_candidates": [x for x in ledgers if x["is_cash_candidate"]],
"ledger_count": len(ledgers),
"agent": self._agent_info(),
}
def _ensure_cash_cache_schema(self, client_id: int) -> None:
with self.store.connect(client_id) as db:
db.executescript(
@@ -711,6 +777,16 @@ class AgentCommandProcessor:
);
CREATE INDEX IF NOT EXISTS ix_cash_cache_days_status
ON cash_payment_cache_days(job_id, status, voucher_date);
CREATE TABLE IF NOT EXISTS cash_payment_cache_vouchers (
job_id TEXT NOT NULL,
voucher_date TEXT NOT NULL,
voucher_key TEXT NOT NULL,
voucher_json TEXT NOT NULL,
PRIMARY KEY(job_id, voucher_key),
FOREIGN KEY(job_id) REFERENCES cash_payment_cache_jobs(job_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_cash_cache_vouchers_date
ON cash_payment_cache_vouchers(job_id, voucher_date);
"""
)
@@ -737,6 +813,28 @@ class AgentCommandProcessor:
sql = "UPDATE cash_payment_cache_jobs SET " + ", ".join(f"{name}=?" for name in cols) + " WHERE job_id=? AND client_id=?"
with self.store.connect(client_id) as db:
db.execute(sql, [values[name] for name in cols] + [job_id, client_id])
try:
row = self._cash_cache_job_row(client_id, job_id) or {}
total = max(1, int(row.get("total_days") or 1))
completed = int(row.get("completed_days") or 0)
status = str(row.get("status") or "")
stage = str(row.get("stage") or "")
settings = json.loads(str(row.get("settings_json") or "{}"))
cash_ledgers = json.loads(str(row.get("cash_ledgers_json") or "[]"))
_cash_progress_snapshot({
"job_id": job_id, "client_id": client_id, "company_name": row.get("company_name") or "",
"status": status, "stage": stage, "current_date": row.get("current_date") or "",
"date_from": row.get("date_from") or "", "date_to": row.get("date_to") or "",
"total_days": total, "completed_days": completed,
"percent": _cash_stage_percent(status, stage, completed, total),
"vouchers_cached": int(row.get("vouchers_cached") or 0),
"tally_requests": int(row.get("tally_requests") or 0),
"retry_count": int(row.get("retry_count") or 0),
"cash_ledgers": cash_ledgers, "cash_limit": settings.get("cash_limit"),
"error": row.get("error_message") or "", "updated_at_utc": row.get("updated_at_utc"),
})
except Exception:
pass
def _cash_payment_cache_start(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
@@ -745,6 +843,13 @@ class AgentCommandProcessor:
date_to = str(payload.get("date_to") or "").strip()
if not tally_guid:
raise ValueError("Select a mapped Tally company before starting extraction.")
selected_cash_ledgers = [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()]
single_cash_ledger = str(payload.get("cash_ledger_name") or "").strip()
if single_cash_ledger and single_cash_ledger not in selected_cash_ledgers:
selected_cash_ledgers.append(single_cash_ledger)
if not selected_cash_ledgers:
raise ValueError("Load the Tally ledgers and confirm the Cash ledger before starting extraction.")
payload["cash_ledger_names"] = selected_cash_ledgers
try:
start_day = _dt_date.fromisoformat(date_from)
end_day = _dt_date.fromisoformat(date_to)
@@ -784,12 +889,14 @@ class AgentCommandProcessor:
"sqlite_cache": True,
"can_resume": False,
"can_cancel": True,
"cash_ledgers": list(selected_cash_ledgers),
}
with _CASH_CACHE_THREADS_LOCK:
if _CASH_CACHE_BUSY.is_set() or any(t.is_alive() for t in _CASH_CACHE_THREADS.values()):
raise ValueError("Another Tally extraction is already running on this workstation. Please wait for it to finish.")
_CASH_CACHE_STARTING[job_id] = dict(starting)
_CASH_CACHE_CANCEL_REQUESTS.discard(job_id)
_cash_progress_snapshot(dict(starting))
worker_payload = dict(payload)
worker_payload["tally_pause_seconds"] = pause_seconds
@@ -818,6 +925,7 @@ class AgentCommandProcessor:
"split_window_days": int(payload.get("split_window_days") or 3),
"near_limit_percent": float(payload.get("near_limit_percent") or 80.0),
"requested_by_user_id": payload.get("requested_by_user_id"),
"cash_ledger_names": [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()],
}
now = datetime.now(timezone.utc).isoformat()
try:
@@ -845,7 +953,10 @@ class AgentCommandProcessor:
(client_id, tally_guid, date_from, date_to),
).fetchone()
prior = dict(prior) if prior else None
resume = bool(prior and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling", "cancelled"})
selected_keys = sorted(x.casefold() for x in settings.get("cash_ledger_names") or [])
prior_settings = json.loads(str(prior.get("settings_json") or "{}")) if prior else {}
prior_keys = sorted(str(x).casefold() for x in (prior_settings.get("cash_ledger_names") or []))
resume = bool(prior and selected_keys == prior_keys and str(prior.get("status") or "") in {"failed", "paused", "interrupted", "running", "cooling", "cancelled"})
if resume:
db_job_id = str(prior.get("job_id"))
# Keep the instant-ACK id stable in the UI by copying the
@@ -868,6 +979,14 @@ class AgentCommandProcessor:
"INSERT INTO cash_payment_cache_days(job_id, voucher_date, status, voucher_count, attempts, last_error, updated_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?)",
[(job_id, r[0], r[1], int(r[2] or 0), int(r[3] or 0), r[4], now) for r in rows],
)
cached_vouchers = db.execute(
"SELECT voucher_date, voucher_key, voucher_json FROM cash_payment_cache_vouchers WHERE job_id=?",
(db_job_id,),
).fetchall()
db.executemany(
"INSERT OR REPLACE INTO cash_payment_cache_vouchers(job_id, voucher_date, voucher_key, voucher_json) VALUES (?, ?, ?, ?)",
[(job_id, r[0], r[1], r[2]) for r in cached_vouchers],
)
else:
db.execute(
"""INSERT INTO cash_payment_cache_jobs(
@@ -944,16 +1063,14 @@ class AgentCommandProcessor:
time.sleep(15.0)
if masters is None:
raise ValueError(f"TallyPrime did not return ledger masters after a slow retry. Details: {master_error}")
cash_ledgers = set()
for ledger in masters:
name = str(ledger.get("name") or "").strip()
parent = str(ledger.get("parent") or "").strip().casefold().replace("-", " ")
reserved = str(ledger.get("reserved_name") or "").strip().casefold()
if name and (parent == "cash in hand" or reserved == "cash" or name.casefold() == "cash"):
cash_ledgers.add(name.casefold())
if not cash_ledgers:
cash_ledgers.add("cash")
self._cash_cache_update(client_id, job_id, cash_ledgers_json=json.dumps(sorted(cash_ledgers)), stage="Cooling before voucher extraction")
master_by_key = {str(x.get("name") or "").strip().casefold(): str(x.get("name") or "").strip() for x in masters if str(x.get("name") or "").strip()}
requested_ledgers = [str(x or "").strip() for x in (payload.get("cash_ledger_names") or []) if str(x or "").strip()]
missing = [x for x in requested_ledgers if x.casefold() not in master_by_key]
if missing:
raise ValueError("The confirmed Cash ledger is no longer available in TallyPrime: " + ", ".join(missing))
cash_ledgers = {x.casefold() for x in requested_ledgers}
confirmed_names = [master_by_key[x.casefold()] for x in requested_ledgers]
self._cash_cache_update(client_id, job_id, cash_ledgers_json=json.dumps(confirmed_names), stage="Cash ledger confirmed; cooling before voucher extraction")
time.sleep(max(5.0, pause_seconds))
with self.store.connect(client_id) as db:
@@ -993,16 +1110,25 @@ class AgentCommandProcessor:
)
raise ValueError(f"Extraction paused at {day} because TallyPrime did not answer two deliberately slow one-day requests. Restart/verify TallyPrime and run again to resume from this date. Details: {last_exc}")
self.store.replace_transaction_snapshot(
client_id,
mapping=mapping,
transactions={"date_from": day, "date_to": day, "vouchers": list(vouchers)},
requested_by_user_id=int(requested_by) if requested_by not in (None, "") else None,
)
relevant_vouchers = []
for voucher in vouchers:
entries = list(voucher.get("ledger_entries") or [])
if any(str(entry.get("ledger_name") or "").strip().casefold() in cash_ledgers for entry in entries):
relevant_vouchers.append(voucher)
now_day = datetime.now(timezone.utc).isoformat()
with self.store.connect(client_id) as db:
db.execute("DELETE FROM cash_payment_cache_vouchers WHERE job_id=? AND voucher_date=?", (job_id, day))
for idx, voucher in enumerate(relevant_vouchers):
key = str(voucher.get("guid") or voucher.get("master_id") or voucher.get("voucher_key") or "").strip()
if not key:
key = f"{day}|{voucher.get('voucher_type_name') or ''}|{voucher.get('voucher_number') or ''}|{idx}"
db.execute(
"INSERT OR REPLACE INTO cash_payment_cache_vouchers(job_id, voucher_date, voucher_key, voucher_json) VALUES (?, ?, ?, ?)",
(job_id, day, key, json.dumps(voucher, ensure_ascii=False, default=str)),
)
db.execute(
"UPDATE cash_payment_cache_days SET status='completed', voucher_count=?, attempts=attempts+1, last_error=NULL, updated_at_utc=? WHERE job_id=? AND voucher_date=?",
(len(vouchers), datetime.now(timezone.utc).isoformat(), job_id, day),
(len(relevant_vouchers), now_day, job_id, day),
)
agg = db.execute(
"SELECT COUNT(*), COALESCE(SUM(voucher_count),0) FROM cash_payment_cache_days WHERE job_id=? AND status='completed'",
@@ -1055,12 +1181,22 @@ class AgentCommandProcessor:
raise ValueError("Cash payment cache job was not found on this workstation.")
total = max(1, int(row.get("total_days") or 1))
completed = int(row.get("completed_days") or 0)
percent = min(100, int(round(completed * 100 / total)))
status_value = str(row.get("status") or "")
stage_value = str(row.get("stage") or "")
percent = _cash_stage_percent(status_value, stage_value, completed, total)
try:
settings = json.loads(str(row.get("settings_json") or "{}"))
except Exception:
settings = {}
try:
selected_cash_ledgers = json.loads(str(row.get("cash_ledgers_json") or "[]"))
except Exception:
selected_cash_ledgers = []
return {
"job": {
"job_id": row["job_id"],
"status": row.get("status"),
"stage": row.get("stage"),
"status": status_value,
"stage": stage_value,
"current_date": row.get("current_date"),
"date_from": row.get("date_from"),
"date_to": row.get("date_to"),
@@ -1071,6 +1207,9 @@ class AgentCommandProcessor:
"tally_requests": int(row.get("tally_requests") or 0),
"retry_count": int(row.get("retry_count") or 0),
"pause_seconds": float(row.get("pause_seconds") or 0),
"company_name": row.get("company_name") or "",
"cash_ledgers": selected_cash_ledgers,
"cash_limit": settings.get("cash_limit"),
"error": row.get("error_message") or "",
"started_at_utc": row.get("started_at_utc"),
"updated_at_utc": row.get("updated_at_utc"),
@@ -1100,27 +1239,21 @@ class AgentCommandProcessor:
cash_limit = round(float(payload.get("cash_limit") or settings.get("cash_limit") or 10000.0), 2)
split_window_days = int(payload.get("split_window_days") or settings.get("split_window_days") or 3)
near_limit_percent = float(payload.get("near_limit_percent") or settings.get("near_limit_percent") or 80.0)
cash_ledgers = set(json.loads(str(row.get("cash_ledgers_json") or "[]"))) or {"cash"}
cash_ledger_names = list(json.loads(str(row.get("cash_ledgers_json") or "[]"))) or ["Cash"]
cash_ledgers = {str(x or "").strip().casefold() for x in cash_ledger_names if str(x or "").strip()} or {"cash"}
self._cash_cache_update(client_id, job_id, stage="Analysing confirmed Cash-ledger transactions")
with self.store.connect(client_id) as db:
voucher_rows = db.execute(
"""SELECT * FROM tally_vouchers WHERE tally_guid=? AND voucher_date>=? AND voucher_date<=?
ORDER BY voucher_date, id""",
(tally_guid, date_from, date_to),
cached_rows = db.execute(
"SELECT voucher_json FROM cash_payment_cache_vouchers WHERE job_id=? ORDER BY voucher_date, voucher_key",
(job_id,),
).fetchall()
vouchers = []
for vr in voucher_rows:
entries = db.execute(
"SELECT ledger_name, amount, is_deemed_positive FROM tally_voucher_ledger_entries WHERE voucher_id=? ORDER BY line_no",
(int(vr["id"]),),
).fetchall()
vouchers.append({
"date": vr["voucher_date"], "effective_date": vr["effective_date"],
"voucher_number": vr["voucher_number"], "voucher_type_name": vr["voucher_type_name"],
"reference": vr["reference"], "narration": vr["narration"], "party_ledger_name": vr["party_ledger_name"],
"is_cancelled": vr["is_cancelled"], "is_optional": vr["is_optional"],
"ledger_entries": [dict(e) for e in entries],
})
for cached in cached_rows:
try:
vouchers.append(json.loads(str(cached[0] or "{}")))
except Exception:
continue
payments = []
for voucher in vouchers:
@@ -1197,11 +1330,12 @@ class AgentCommandProcessor:
split_patterns.append({"party": party_name, "date_from": window[0][0].isoformat(), "date_to": window[-1][0].isoformat(), "total_amount": total, "days": [{"date": d.isoformat(), "amount": a} for d, a in window], "review_only": True})
exceptions.sort(key=lambda x: (x["date"], x["party"].casefold()))
split_patterns.sort(key=lambda x: (x["date_from"], x["party"].casefold()))
self._cash_cache_update(client_id, job_id, status="completed", stage="Cash-payment analysis complete")
return {
"cash_payment_review": {
"company_name": row.get("company_name"), "company_guid": tally_guid,
"date_from": date_from, "date_to": date_to, "cash_limit": cash_limit,
"cash_ledgers": sorted(cash_ledgers),
"cash_ledgers": cash_ledger_names,
"summary": {"vouchers_reviewed": len(vouchers), "cash_payment_vouchers": len(payments), "single_voucher_exceptions": single_count, "same_day_exceptions": same_day_count, "possible_split_patterns": len(split_patterns)},
"exceptions": exceptions, "possible_split_payments": split_patterns, "cash_payments": payments,
"read_only": True, "sqlite_cache": True, "background_extraction": True,
@@ -56,6 +56,8 @@ class AgentDashboard:
return self._json(dashboard.status())
if self.path.startswith("/api/history"):
return self._json(dashboard.history())
if self.path.startswith("/api/cash-progress"):
return self._json(dashboard.cash_progress())
self.send_error(404)
def do_POST(self):
@@ -122,6 +124,17 @@ class AgentDashboard:
"dashboard": {"host": self.config.dashboard_host, "port": self.config.dashboard_port},
}
def cash_progress(self) -> dict[str, Any]:
path = self.install_dir / "data" / "cash_payment_progress.json"
if not path.exists():
return {"active": False, "job": None}
try:
job = json.loads(path.read_text(encoding="utf-8")) or {}
except Exception:
return {"active": False, "job": None}
status = str(job.get("status") or "").lower()
return {"active": status not in {"completed", "cancelled", "failed", "paused", ""}, "job": job}
def history(self) -> dict[str, Any]:
return {
"connections": self.db.recent_rows("connection_history", 20),
@@ -136,7 +149,7 @@ class AgentDashboard:
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ERP Local Agent</title>
<style>
body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}.progressbox{margin-top:14px;padding:14px;border:1px solid #dbe3ea;border-radius:10px;background:#f8fafc}.progresshead{display:flex;justify-content:space-between;gap:12px;align-items:center}.progressbar{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:10px}.progressfill{height:100%;width:0%;background:#111827;transition:width .45s ease}.progressmsg{margin-top:8px;font-size:13px;color:#4b5563}.pulse{animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{0%,100%{opacity:.65}50%{opacity:1}}
body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;margin:0}.wrap{max-width:1100px;margin:28px auto;padding:0 18px}h1{margin:0 0 4px}.muted{color:#6b7280}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin:22px 0}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;box-shadow:0 1px 2px rgba(0,0,0,.04)}.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:#6b7280}.value{font-size:22px;font-weight:650;margin-top:8px}.ok{color:#087f5b}.bad{color:#c92a2a}.warn{color:#b26a00}button{border:0;border-radius:8px;padding:10px 14px;margin:4px 6px 4px 0;font-weight:600;cursor:pointer;background:#111827;color:#fff}button.secondary{background:#e5e7eb;color:#111827}button:disabled{opacity:.5;cursor:not-allowed}.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:18px;margin:14px 0}table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:8px;border-bottom:1px solid #eee;vertical-align:top}#message{margin-top:10px;padding:10px;border-radius:8px;display:none}.msgok{display:block!important;background:#e6fcf5;color:#087f5b}.msgbad{display:block!important;background:#fff5f5;color:#c92a2a}code{background:#f3f4f6;padding:2px 5px;border-radius:4px}.progressbox{margin-top:14px;padding:14px;border:1px solid #dbe3ea;border-radius:10px;background:#f8fafc}.progresshead{display:flex;justify-content:space-between;gap:12px;align-items:center}.progressbar{height:12px;background:#e5e7eb;border-radius:999px;overflow:hidden;margin-top:10px}.progressfill{height:100%;width:0%;background:#111827;transition:width .45s ease}.progressmsg{margin-top:8px;font-size:13px;color:#4b5563}.pulse{animation:pulse 1.2s ease-in-out infinite}.cashsteps{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:8px;margin-top:12px}.cashstep{padding:9px 10px;border-radius:8px;background:#f3f4f6;font-size:12px}.cashstep.done{background:#e6fcf5;color:#087f5b}.cashstep.run{background:#e7f5ff;color:#1864ab;font-weight:700}.metricgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:12px}.metric{background:#f8fafc;border:1px solid #e5e7eb;border-radius:8px;padding:10px}.metric b{display:block;font-size:18px;margin-top:3px}@keyframes pulse{0%,100%{opacity:.65}50%{opacity:1}}
</style></head>
<body><div class="wrap"><h1>ERP Local Agent</h1><div class="muted">Local dashboard — available only on this computer</div>
<div class="grid">
@@ -149,6 +162,7 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg
<button id="check" onclick="checkUpdate()">Check for Update</button><button class="secondary" id="download" onclick="downloadUpdate()">Download Update</button><button id="install" onclick="installUpdate()">Install & Restart Safely</button><div id="message"></div>
<div id="progressBox" class="progressbox" style="display:none"><div class="progresshead"><strong id="progressTitle">Update</strong><span id="progressPct">0%</span></div><div class="progressbar"><div id="progressFill" class="progressfill"></div></div><div id="progressMsg" class="progressmsg">Waiting…</div></div></div>
<div class="section"><h2>Tally</h2><button onclick="action('/api/tally/check')">Test Tally Connection</button><p class="muted">Tally is checked locally at <code>127.0.0.1:9000</code>. Port 9000 is not exposed to the internet.</p></div>
<div class="section" id="cashJobSection" style="display:none"><div class="progresshead"><div><h2 style="margin:0">Cash Payment Compliance</h2><div id="cashJobSub" class="muted">Background Tally job</div></div><strong id="cashJobPct">0%</strong></div><div class="progressbar"><div id="cashJobFill" class="progressfill"></div></div><div id="cashJobStage" class="progressmsg">Waiting…</div><div class="metricgrid"><div class="metric"><span class="label">Days</span><b id="cashDays">0 / 0</b></div><div class="metric"><span class="label">Cash vouchers</span><b id="cashVouchers">0</b></div><div class="metric"><span class="label">Tally requests</span><b id="cashRequests">0</b></div><div class="metric"><span class="label">Current date</span><b id="cashDate">—</b></div></div><div class="cashsteps"><div id="cs1" class="cashstep">1. Connect to Tally</div><div id="cs2" class="cashstep">2. Confirm Cash ledger</div><div id="cs3" class="cashstep">3. Extract transactions</div><div id="cs4" class="cashstep">4. Analyse threshold</div><div id="cs5" class="cashstep">5. Complete</div></div><div id="cashError" class="msgbad" style="margin-top:10px"></div></div>
<div class="section"><h2>Recent History</h2><div id="history" class="muted">Loading…</div></div>
</div>
<script>
@@ -156,6 +170,8 @@ let installWatching=false,installTarget='',offlineTicks=0,lastServerPercent=0;
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 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';
@@ -164,7 +180,7 @@ let te=document.getElementById('tally');let known=s.tally.connected!==null;te.te
document.getElementById('storage').textContent=gb(s.storage.free_bytes);document.getElementById('storageRoot').textContent=s.storage.root;
document.getElementById('check').disabled=!!s.update.busy;document.getElementById('download').disabled=!s.update.update_available||!!s.update.busy;document.getElementById('install').disabled=!s.update.downloaded||!!s.update.busy;applyProgress(s.update);
if(installWatching&&installTarget&&s.agent.version===installTarget){setProgress('Update Complete',100,'ERP Local Agent '+installTarget+' is running. Updated dashboard loaded.',false);msg('Update completed successfully.',true);installWatching=false;setTimeout(()=>location.reload(),1200)}
await refreshHistory();}catch(e){if(installWatching){offlineTicks++;let estimated=Math.min(92,Math.max(lastServerPercent,35+offlineTicks*3));setProgress('Applying Update',estimated,'Local Agent is restarting. Waiting for the updated dashboard to come back online…',true)}else{console.log(e)}}}
await refreshHistory();await refreshCashProgress();}catch(e){if(installWatching){offlineTicks++;let estimated=Math.min(92,Math.max(lastServerPercent,35+offlineTicks*3));setProgress('Applying Update',estimated,'Local Agent is restarting. Waiting for the updated dashboard to come back online…',true)}else{console.log(e)}}}
async function refreshHistory(){try{const r=await fetch('/api/history',{cache:'no-store'});const h=await r.json();let rows=h.events.slice(0,12).map(x=>'<tr><td>'+x.occurred_at_utc+'</td><td>'+x.level+'</td><td>'+x.event_type+'</td><td>'+escapeHtml(x.message)+'</td></tr>').join('');document.getElementById('history').innerHTML='<table><thead><tr><th>Time UTC</th><th>Level</th><th>Event</th><th>Message</th></tr></thead><tbody>'+rows+'</tbody></table>'}catch(e){}}
function escapeHtml(v){return String(v||'').replace(/[&<>'"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#039;','"':'&quot;'}[m]))}
async function checkUpdate(){setProgress('Checking for Updates',10,'Checking ERP for the latest Local Agent version…',true);msg('Checking for updates…',true);try{const r=await fetch('/api/update/check',{method:'POST'});const j=await r.json();if(!r.ok||!j.ok)throw new Error(j.error||'Update check failed');applyProgress(j.update);msg(j.update.update_available?'Update '+j.update.latest_version+' is available.':'Local Agent is up to date.',true);await refresh()}catch(e){setProgress('Update Check',100,e.message,false);msg(e.message,false)}}