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
+44 -4
View File
@@ -252,6 +252,7 @@ def cash_payment_review(
@router.post("/ledgers") @router.post("/ledgers")
async def cash_payment_ledgers(request: Request): async def cash_payment_ledgers(request: Request):
"""Return cached ledgers immediately or start a non-blocking Tally master job."""
form = await request.form() form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or "")) validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -269,6 +270,7 @@ async def cash_payment_ledgers(request: Request):
return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400) return JSONResponse({"ok": False, "error": "Select a valid client."}, status_code=400)
tally_guid = str(form.get("tally_guid") or "").strip() tally_guid = str(form.get("tally_guid") or "").strip()
date_from = str(form.get("date_from") or date.today().isoformat()).strip() date_from = str(form.get("date_from") or date.today().isoformat()).strip()
force_refresh = str(form.get("force_refresh") or "").strip().lower() in {"1","true","yes","on"}
if not tally_guid: if not tally_guid:
return JSONResponse({"ok": False, "error": "Select a mapped Tally company."}, status_code=400) 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) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
@@ -278,14 +280,52 @@ async def cash_payment_ledgers(request: Request):
fy = _financial_year_for_date(date.fromisoformat(date_from)) fy = _financial_year_for_date(date.fromisoformat(date_from))
result = request_agent_command( result = request_agent_command(
node.node_code, node.node_code,
"accounting_cash_payment_ledgers", "accounting_cash_payment_ledgers_start",
{**_accounting_storage_payload(selected_client, fy), "tally_guid": tally_guid}, {
timeout_seconds=45, **_accounting_storage_payload(selected_client, fy),
"tally_guid": tally_guid,
"force_refresh": force_refresh,
},
timeout_seconds=20,
) )
except Exception as exc: except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502) return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"): 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": False, "error": str(result.get("error") or "Could not start Tally ledger sync.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})})
finally:
db.close()
@router.get("/ledgers/progress")
def cash_payment_ledgers_progress(request: Request, client_id: int, job_id: str, date_from: str):
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)
selected_client = next((row for row in clients if int(row.id) == int(client_id)), None)
if not selected_client:
return JSONResponse({"ok": False, "error": "Client not found."}, status_code=404)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return JSONResponse({"ok": False, "error": "ERP Local Agent is offline."}, status_code=409)
try:
fy = _financial_year_for_date(date.fromisoformat(date_from))
result = request_agent_command(
node.node_code,
"accounting_cash_payment_ledgers_status",
{
**_accounting_storage_payload(selected_client, fy),
"job_id": str(job_id),
},
timeout_seconds=15,
)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Could not read ledger-sync progress.")}, status_code=409)
return JSONResponse({"ok": True, **(result.get("result") or {})}) return JSONResponse({"ok": True, **(result.get("result") or {})})
finally: finally:
db.close() db.close()
@@ -38,6 +38,33 @@ def _near(a, b, tolerance=0.01):
return abs(float(a or 0) - float(b or 0)) <= tolerance return abs(float(a or 0) - float(b or 0)) <= tolerance
def _is_balance_sheet_ledger(row):
"""Return True only for ledger masters that belong to the Balance Sheet.
Tally exports ISREVENUE for Ledger masters. Revenue ledgers (income,
expense, purchase and sales accounts) must not carry an opening balance
into a new financial year. Stock Items are handled separately below.
"""
flag = _s((row or {}).get("is_revenue")).casefold()
if flag in {"yes", "y", "true", "1"}:
return False
if flag in {"no", "n", "false", "0"}:
return True
# Defensive fallback for older Tally responses that omitted ISREVENUE.
# Exclude only unmistakable P&L roots; retain Balance-Sheet masters.
parent = _norm((row or {}).get("parent"))
pnl_roots = (
"direct expenses", "indirect expenses", "direct incomes",
"indirect incomes", "sales accounts", "purchase accounts",
)
return parent not in pnl_roots
def _balance_sheet_ledgers(masters):
return [row for row in list((masters or {}).get("ledgers") or []) if _is_balance_sheet_ledger(row)]
def _mapping_index(db, *, tenant_id, client_id, master_type, previous_company_guid, current_company_guid): def _mapping_index(db, *, tenant_id, client_id, master_type, previous_company_guid, current_company_guid):
rows = list( rows = list(
db.execute( db.execute(
@@ -308,8 +335,8 @@ def create_comparison_run(
) )
ledger_matches = _match_ledgers( ledger_matches = _match_ledgers(
list(previous_masters.get("ledgers") or []), _balance_sheet_ledgers(previous_masters),
list(current_masters.get("ledgers") or []), _balance_sheet_ledgers(current_masters),
ledger_map, ledger_map,
) )
stock_matches = _match_stock( stock_matches = _match_stock(
+100 -1
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from io import BytesIO
from urllib.parse import urlencode from urllib.parse import urlencode
from datetime import datetime
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, StreamingResponse
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
@@ -29,6 +31,8 @@ from app.modules.accounting.ui import (
) )
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.documents.services import get_active_storage_node_for_branch from app.modules.documents.services import get_active_storage_node_for_branch
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
router = APIRouter( router = APIRouter(
prefix="/tools/accounting/opening-balances", prefix="/tools/accounting/opening-balances",
@@ -277,6 +281,101 @@ def page(
db.close() db.close()
@router.get("/download/{run_id}")
def download_report(request: Request, run_id: int, client_id: int | None = None):
"""Download the complete opening-balance comparison as an Excel workbook."""
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.tally.view")
if denied:
return denied
clients, scope = _visible_clients(db, request, user)
run = db.get(AccountingOpeningBalanceRun, int(run_id))
if not run or run.tenant_id != scope.tenant_id:
return _go(error="Opening balance comparison run was not found.")
selected = next((row for row in clients if int(row.id) == int(run.client_id)), None)
if not selected or (client_id and int(client_id) != int(run.client_id)):
return _go(error="Opening balance comparison run is not visible.")
ledger_rows = ledger_items(db, run_id=run.id)
stock_rows = stock_items(db, run_id=run.id)
wb = Workbook()
ws = wb.active
ws.title = "Balance Sheet Ledgers"
headers = [
"Previous Ledger", "Previous Group", "PY Closing", "Current Ledger",
"Current Group", "CY Opening", "Difference", "Status",
"Match Method", "Confidence %", "Correction Status", "Correction Note",
]
ws.append(headers)
for row in ledger_rows:
ws.append([
row.previous_name or "", row.previous_group or "", float(row.previous_closing_balance or 0),
row.current_name or "", row.current_group or "", float(row.current_opening_balance or 0),
float(row.difference or 0), row.match_status or "", row.match_method or "",
int(row.confidence or 0), row.correction_status or "", row.correction_note or "",
])
sws = wb.create_sheet("Stock Items")
stock_headers = [
"Previous Stock Item", "Previous Group", "HSN", "Unit", "PY Closing Qty",
"PY Closing Value", "Current Stock Item", "Current Group", "Current HSN",
"Current Unit", "CY Opening Qty", "CY Opening Value", "Qty Difference",
"Value Difference", "Status", "Match Method", "Confidence %",
"Correction Status", "Correction Note",
]
sws.append(stock_headers)
for row in stock_rows:
sws.append([
row.previous_name or "", row.previous_group or "", row.previous_hsn or "", row.previous_unit or "",
float(row.previous_closing_qty or 0), float(row.previous_closing_value or 0),
row.current_name or "", row.current_group or "", row.current_hsn or "", row.current_unit or "",
float(row.current_opening_qty or 0), float(row.current_opening_value or 0),
float(row.quantity_difference or 0), float(row.value_difference or 0),
row.match_status or "", row.match_method or "", int(row.confidence or 0),
row.correction_status or "", row.correction_note or "",
])
meta = wb.create_sheet("Summary", 0)
meta.append(["Opening Balance Review"])
meta.append(["Client", getattr(selected, "client_name", "") or getattr(selected, "name", "") or str(selected.id)])
meta.append(["Run ID", run.id])
meta.append(["Previous Tally Company", run.previous_company_name])
meta.append(["Current Tally Company", run.current_company_name])
meta.append(["Scope", "Balance Sheet ledgers and Stock Items only"])
meta.append(["Balance Sheet Ledger Rows", len(ledger_rows)])
meta.append(["Stock Item Rows", len(stock_rows)])
meta.append(["Generated", datetime.now().strftime("%Y-%m-%d %H:%M:%S")])
header_fill = PatternFill("solid", fgColor="DCE6F1")
for sheet in (ws, sws):
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = sheet.dimensions
for cell in sheet[1]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(vertical="center", wrap_text=True)
for col in sheet.columns:
letter = col[0].column_letter
width = min(38, max(10, max(len(str(c.value or "")) for c in col[:200]) + 2))
sheet.column_dimensions[letter].width = width
meta["A1"].font = Font(bold=True, size=14)
meta.column_dimensions["A"].width = 28
meta.column_dimensions["B"].width = 48
output = BytesIO()
wb.save(output)
output.seek(0)
filename = f"opening_balance_review_run_{run.id}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
finally:
db.close()
@router.post("/compare") @router.post("/compare")
def compare( def compare(
request: Request, request: Request,
@@ -49,8 +49,13 @@
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %} {% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
</select> </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> <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>
<button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh from Tally</button>
</div>
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Loads cached Tally ledgers instantly when available. Refresh from Tally runs safely in the Local Agent background and shows progress here and on the Local Agent dashboard.</p>
<div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing ledger sync…</span><span id="cash-ledger-progress-pct">0%</span></div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div>
</div> </div>
<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>
<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">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><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>
@@ -200,27 +205,81 @@
const ledgerSelect = document.getElementById('cash-ledger-select'); const ledgerSelect = document.getElementById('cash-ledger-select');
const ledgerHelp = document.getElementById('cash-ledger-help'); const ledgerHelp = document.getElementById('cash-ledger-help');
async function loadCashLedgers() { const ledgerRefreshButton = document.getElementById('cash-refresh-ledgers');
const fd = new FormData(form); const ledgerProgress = document.getElementById('cash-ledger-progress');
if (!fd.get('client_id') || !fd.get('tally_guid')) { ledgerHelp.textContent = 'Select a client and Tally company first.'; return; } const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar');
fd.set('csrf_token', document.getElementById('cash-csrf-token').value); const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct');
ledgerButton.disabled = true; ledgerHelp.textContent = 'Reading Tally ledger master…'; const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage');
try { let ledgerPollTimer = null;
const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}});
const data = await response.json(); function populateLedgers(data) {
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not load Tally ledgers.'); const previous = ledgerSelect.value;
const previous = ledgerSelect.value; ledgerSelect.innerHTML = '<option value="">Confirm Cash ledger</option>'; ledgerSelect.innerHTML = '<option value="">Confirm Cash ledger</option>';
(data.ledgers || []).forEach(function(row){ (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' : ''); 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; if ((previous && previous===row.name) || (!previous && row.is_cash_candidate && !ledgerSelect.value)) opt.selected=true;
ledgerSelect.appendChild(opt); ledgerSelect.appendChild(opt);
}); });
if (!ledgerSelect.value && (data.cash_candidates||[]).length===1) ledgerSelect.value=data.cash_candidates[0].name; 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.'; ledgerHelp.textContent = String(data.ledger_count || 0)+' ledger(s) ready from '+(data.company_name || 'Tally')+(data.from_cache?' (local cache).':'.')+' 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 updateLedgerProgress(job) {
const p=Math.max(0,Math.min(100,Number(job.percent||0)));
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width=p+'%';
ledgerProgressPct.textContent=Math.round(p)+'%';
ledgerProgressStage.textContent=job.stage||job.status||'Working…';
}
async function pollLedgers(jobId, clientId, dateFrom) {
try {
const url=new URL('/tools/accounting/cash-payments/ledgers/progress',window.location.origin);
url.searchParams.set('client_id',clientId); url.searchParams.set('job_id',jobId); url.searchParams.set('date_from',dateFrom);
const response=await fetch(url.toString(),{headers:{'Accept':'application/json'},cache:'no-store'});
const data=await response.json();
if(!response.ok||!data.ok) throw new Error(data.error||'Could not read Tally ledger progress.');
const job=data.job||{}; updateLedgerProgress(job);
if(job.status==='completed'){
populateLedgers(data); ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1800); return;
}
if(job.status==='failed') throw new Error(job.error||'Tally ledger sync failed.');
ledgerPollTimer=window.setTimeout(()=>pollLedgers(jobId,clientId,dateFrom),1500);
} catch(err) {
ledgerHelp.textContent=err&&err.message?err.message:String(err); ledgerButton.disabled=false;
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
ledgerProgressStage.textContent=ledgerHelp.textContent;
}
}
async function loadCashLedgers(forceRefresh) {
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);
if(forceRefresh) fd.set('force_refresh','1');
ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true;
ledgerHelp.textContent = forceRefresh ? 'Starting background refresh from Tally…' : 'Loading cached ledgers or starting background Tally sync…';
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.');
if ((data.ledgers||[]).length) {
populateLedgers(data); ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false; return;
}
const job=data.job||{};
if(!job.job_id) throw new Error('Local Agent did not return a ledger-sync job id.');
updateLedgerProgress(job);
pollLedgers(job.job_id,String(fd.get('client_id')||''),String(fd.get('date_from')||''));
} catch(err) {
ledgerHelp.textContent = err && err.message ? err.message : String(err);
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
}
}
if (ledgerButton) ledgerButton.addEventListener('click', function(){loadCashLedgers(false);});
if (ledgerRefreshButton) ledgerRefreshButton.addEventListener('click', function(){loadCashLedgers(true);});
function show() { function show() {
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
@@ -6,7 +6,7 @@
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Phase 21A v2</p> <p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Phase 21A v2</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Opening Balance Review & Correction</h1> <h1 class="mt-1 text-2xl font-semibold text-slate-900">Opening Balance Review & Correction</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500"> <p class="mt-1 max-w-5xl text-sm text-slate-500">
Summary-first review of previous-year closing balances against current-year openings. Matched and new zero-balance masters stay out of the main review; actionable differences are shown immediately and retain the existing controlled Tally correction workflow. Summary-first review of previous-year closing balances against current-year openings. Only Balance Sheet ledgers and Stock Items are checked; revenue, expense, purchase and sales ledgers are excluded. Matched and new zero-balance masters stay out of the main review; actionable differences retain the existing controlled Tally correction workflow.
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -77,6 +77,10 @@
{% endif %} {% endif %}
{% if selected_run %} {% if selected_run %}
<div class="mb-4 flex justify-end">
<a href="/tools/accounting/opening-balances/download/{{ selected_run.id }}?client_id={{ selected_client.id }}" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800">Download Excel Report</a>
</div>
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div> <div>
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.14" ERP_LOCAL_AGENT_VERSION = "1.22.15"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _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" AGENT_NAME = "ERP Local Agent"
@@ -22,6 +22,8 @@ _CASH_CACHE_THREADS_LOCK = threading.Lock()
_CASH_CACHE_CANCEL_REQUESTS: set[str] = set() _CASH_CACHE_CANCEL_REQUESTS: set[str] = set()
_CASH_CACHE_STARTING: dict[str, dict[str, Any]] = {} _CASH_CACHE_STARTING: dict[str, dict[str, Any]] = {}
_CASH_PROGRESS_FILE = Path(__file__).resolve().parents[1] / "data" / "cash_payment_progress.json" _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: def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
"""Publish one lightweight cash-job snapshot for the local dashboard. """Publish one lightweight cash-job snapshot for the local dashboard.
@@ -106,6 +108,10 @@ class AgentCommandProcessor:
result = self._bank_reconciliation_extract(payload) result = self._bank_reconciliation_extract(payload)
elif action == "accounting_cash_payment_ledgers": elif action == "accounting_cash_payment_ledgers":
result = self._cash_payment_ledgers(payload) 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": elif action == "accounting_cash_payment_cache_start":
result = self._cash_payment_cache_start(payload) result = self._cash_payment_cache_start(payload)
elif action == "accounting_cash_payment_cache_status": elif action == "accounting_cash_payment_cache_status":
@@ -166,6 +172,9 @@ class AgentCommandProcessor:
"cash_payment_background_sync_capability": True, "cash_payment_background_sync_capability": True,
"cash_payment_ledger_confirmation_capability": True, "cash_payment_ledger_confirmation_capability": True,
"cash_payment_dual_progress_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, "tally_writeback_capability": True,
} }
@@ -706,18 +715,21 @@ class AgentCommandProcessor:
} }
def _cash_payment_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]: @staticmethod
company, company_name = self._resolve_open_company(payload) def _cash_ledger_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows = self.tally.export_master_collection(company_name, "ledgers") or [] ledgers: list[dict[str, Any]] = []
ledgers = [] for row in rows or []:
for row in rows:
name = str(row.get("name") or "").strip() name = str(row.get("name") or "").strip()
if not name: if not name:
continue continue
parent = str(row.get("parent") or "").strip() parent = str(row.get("parent") or "").strip()
reserved = str(row.get("reserved_name") or "").strip() reserved = str(row.get("reserved_name") or "").strip()
parent_key = parent.casefold().replace("-", " ") 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({ ledgers.append({
"name": name, "name": name,
"parent": parent, "parent": parent,
@@ -726,12 +738,245 @@ class AgentCommandProcessor:
"is_cash_candidate": bool(is_cash), "is_cash_candidate": bool(is_cash),
}) })
ledgers.sort(key=lambda x: (not x["is_cash_candidate"], x["name"].casefold())) ledgers.sort(key=lambda x: (not x["is_cash_candidate"], x["name"].casefold()))
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 { return {
"status": "cached",
"company": company, "company": company,
"company_name": company_name, "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, "ledgers": ledgers,
"cash_candidates": [x for x in ledgers if x["is_cash_candidate"]], "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(), "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 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 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:'')} 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; 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'); 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'; document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded';