From 58752e30e1c45313d48f049fee1ff740d035ee88 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Fri, 4 Sep 2026 14:52:24 +0530 Subject: [PATCH] Add cash ledger extraction and dual progress UI --- app/modules/accounting/cash_payment_ui.py | 48 ++++ .../accounting/cash_payment_review.html | 63 +++++- app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/commands.py | 214 ++++++++++++++---- .../erp_local_agent/dashboard.py | 20 +- 6 files changed, 299 insertions(+), 50 deletions(-) diff --git a/app/modules/accounting/cash_payment_ui.py b/app/modules/accounting/cash_payment_ui.py index b902336..e7b594f 100644 --- a/app/modules/accounting/cash_payment_ui.py +++ b/app/modules/accounting/cash_payment_ui.py @@ -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), diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html index 9146fa8..fed7c77 100644 --- a/app/modules/accounting/templates/accounting/cash_payment_review.html +++ b/app/modules/accounting/templates/accounting/cash_payment_review.html @@ -21,7 +21,7 @@

Cash Payment Compliance Review

-
+
@@ -41,9 +41,20 @@ {% endif %}
+
+ +
+ + +
+

Reads the Tally ledger master and lets you confirm the exact Cash ledger.

+
-

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.

+

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.

@@ -68,7 +79,7 @@

Local Tally SQLite Cache

Status: {{ cache_job.status }}{% if cache_job.stage %} · {{ cache_job.stage }}{% endif %}

-
{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)
{{ cache_job.vouchers_cached }} voucher(s) cached
+
{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)
{{ cache_job.vouchers_cached }} Cash-ledger voucher(s)
{% if cache_job.cash_ledgers %}
Ledger: {{ cache_job.cash_ledgers|join(', ') }}
{% endif %}
{% if cache_job.error %}

{{ cache_job.error }}

{% endif %} @@ -136,7 +147,7 @@
-

Slow Tally Extraction to Local SQLite

+

Cash Ledger Extraction & Compliance Progress

Submitting a background extraction job to the ERP Local Agent…

0%
@@ -146,11 +157,18 @@
Days
0 / 0
-
Vouchers cached
0
+
Cash vouchers
0
Tally requests
0
Current date
—
-

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.

+
+
1. Connect
+
2. Confirm ledger
+
3. Extract
+
4. Analyse
+
5. Complete
+
+

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.

@@ -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 = ''; + (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; diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 0698108..b2f8a20 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 95dc5bd..33a914f 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.22.13" +__version__ = "1.22.14" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 39965dc..c182f06 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -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], - }) + vouchers = [] + 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, diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py b/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py index 77ec812..d2948be 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/dashboard.py @@ -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: ERP Local Agent

ERP Local Agent

Local dashboard — available only on this computer
@@ -149,6 +162,7 @@ body{font-family:Segoe UI,Arial,sans-serif;background:#f4f6f8;color:#17202a;marg

Tally

Tally is checked locally at 127.0.0.1:9000. Port 9000 is not exposed to the internet.

+

Recent History

Loading…