Fix opening balance scope and add async cash ledger loading
This commit is contained in:
@@ -252,6 +252,7 @@ def cash_payment_review(
|
||||
|
||||
@router.post("/ledgers")
|
||||
async def cash_payment_ledgers(request: Request):
|
||||
"""Return cached ledgers immediately or start a non-blocking Tally master job."""
|
||||
form = await request.form()
|
||||
validate_csrf(request, str(form.get("csrf_token") or ""))
|
||||
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)
|
||||
tally_guid = str(form.get("tally_guid") or "").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:
|
||||
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)
|
||||
@@ -278,14 +280,52 @@ async def cash_payment_ledgers(request: Request):
|
||||
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,
|
||||
"accounting_cash_payment_ledgers_start",
|
||||
{
|
||||
**_accounting_storage_payload(selected_client, fy),
|
||||
"tally_guid": tally_guid,
|
||||
"force_refresh": force_refresh,
|
||||
},
|
||||
timeout_seconds=20,
|
||||
)
|
||||
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": 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 {})})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -38,6 +38,33 @@ def _near(a, b, tolerance=0.01):
|
||||
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):
|
||||
rows = list(
|
||||
db.execute(
|
||||
@@ -308,8 +335,8 @@ def create_comparison_run(
|
||||
)
|
||||
|
||||
ledger_matches = _match_ledgers(
|
||||
list(previous_masters.get("ledgers") or []),
|
||||
list(current_masters.get("ledgers") or []),
|
||||
_balance_sheet_ledgers(previous_masters),
|
||||
_balance_sheet_ledgers(current_masters),
|
||||
ledger_map,
|
||||
)
|
||||
stock_matches = _match_stock(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime
|
||||
|
||||
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.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.documents.services import get_active_storage_node_for_branch
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/tools/accounting/opening-balances",
|
||||
@@ -277,6 +281,101 @@ def page(
|
||||
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")
|
||||
def compare(
|
||||
request: Request,
|
||||
|
||||
@@ -49,8 +49,13 @@
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -200,27 +205,81 @@
|
||||
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>';
|
||||
const ledgerRefreshButton = document.getElementById('cash-refresh-ledgers');
|
||||
const ledgerProgress = document.getElementById('cash-ledger-progress');
|
||||
const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar');
|
||||
const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct');
|
||||
const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage');
|
||||
let ledgerPollTimer = null;
|
||||
|
||||
function populateLedgers(data) {
|
||||
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' : '');
|
||||
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; }
|
||||
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.';
|
||||
}
|
||||
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() {
|
||||
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>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -77,6 +77,10 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.22.14"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.22.15"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.22.14"
|
||||
__version__ = "1.22.15"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -22,6 +22,8 @@ _CASH_CACHE_THREADS_LOCK = threading.Lock()
|
||||
_CASH_CACHE_CANCEL_REQUESTS: set[str] = set()
|
||||
_CASH_CACHE_STARTING: dict[str, dict[str, Any]] = {}
|
||||
_CASH_PROGRESS_FILE = Path(__file__).resolve().parents[1] / "data" / "cash_payment_progress.json"
|
||||
_CASH_LEDGER_THREADS: dict[str, threading.Thread] = {}
|
||||
_CASH_LEDGER_THREADS_LOCK = threading.Lock()
|
||||
|
||||
def _cash_progress_snapshot(payload: dict[str, Any]) -> None:
|
||||
"""Publish one lightweight cash-job snapshot for the local dashboard.
|
||||
@@ -106,6 +108,10 @@ class AgentCommandProcessor:
|
||||
result = self._bank_reconciliation_extract(payload)
|
||||
elif action == "accounting_cash_payment_ledgers":
|
||||
result = self._cash_payment_ledgers(payload)
|
||||
elif action == "accounting_cash_payment_ledgers_start":
|
||||
result = self._cash_payment_ledgers_start(payload)
|
||||
elif action == "accounting_cash_payment_ledgers_status":
|
||||
result = self._cash_payment_ledgers_status(payload)
|
||||
elif action == "accounting_cash_payment_cache_start":
|
||||
result = self._cash_payment_cache_start(payload)
|
||||
elif action == "accounting_cash_payment_cache_status":
|
||||
@@ -166,6 +172,9 @@ class AgentCommandProcessor:
|
||||
"cash_payment_background_sync_capability": True,
|
||||
"cash_payment_ledger_confirmation_capability": True,
|
||||
"cash_payment_dual_progress_capability": True,
|
||||
"cash_payment_async_ledger_master_capability": True,
|
||||
"cash_payment_ledger_cache_capability": True,
|
||||
"opening_balance_balance_sheet_only_capability": True,
|
||||
"tally_writeback_capability": True,
|
||||
}
|
||||
|
||||
@@ -706,18 +715,21 @@ class AgentCommandProcessor:
|
||||
}
|
||||
|
||||
|
||||
def _cash_payment_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
rows = self.tally.export_master_collection(company_name, "ledgers") or []
|
||||
ledgers = []
|
||||
for row in rows:
|
||||
@staticmethod
|
||||
def _cash_ledger_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
ledgers: list[dict[str, Any]] = []
|
||||
for row in rows or []:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
parent = str(row.get("parent") or "").strip()
|
||||
reserved = str(row.get("reserved_name") or "").strip()
|
||||
parent_key = parent.casefold().replace("-", " ")
|
||||
is_cash = parent_key == "cash in hand" or reserved.casefold() == "cash" or name.casefold() == "cash"
|
||||
is_cash = (
|
||||
parent_key == "cash in hand"
|
||||
or reserved.casefold() == "cash"
|
||||
or name.casefold() == "cash"
|
||||
)
|
||||
ledgers.append({
|
||||
"name": name,
|
||||
"parent": parent,
|
||||
@@ -726,12 +738,245 @@ class AgentCommandProcessor:
|
||||
"is_cash_candidate": bool(is_cash),
|
||||
})
|
||||
ledgers.sort(key=lambda x: (not x["is_cash_candidate"], x["name"].casefold()))
|
||||
return ledgers
|
||||
|
||||
def _ensure_cash_ledger_schema(self, client_id: int) -> None:
|
||||
with self.store.connect(client_id) as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cash_payment_ledger_cache (
|
||||
client_id INTEGER NOT NULL,
|
||||
tally_guid TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
ledger_name TEXT NOT NULL,
|
||||
parent_name TEXT NOT NULL DEFAULT '',
|
||||
reserved_name TEXT NOT NULL DEFAULT '',
|
||||
closing_balance REAL,
|
||||
is_cash_candidate INTEGER NOT NULL DEFAULT 0,
|
||||
synced_at_utc TEXT NOT NULL,
|
||||
PRIMARY KEY (client_id, tally_guid, ledger_name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS cash_payment_ledger_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
client_id INTEGER NOT NULL,
|
||||
tally_guid TEXT NOT NULL,
|
||||
company_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
stage TEXT NOT NULL DEFAULT '',
|
||||
percent INTEGER NOT NULL DEFAULT 0,
|
||||
ledger_count INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at_utc TEXT NOT NULL,
|
||||
finished_at_utc TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def _cash_payment_cached_ledgers(self, client_id: int, tally_guid: str) -> list[dict[str, Any]]:
|
||||
self._ensure_cash_ledger_schema(client_id)
|
||||
with self.store.connect(client_id) as db:
|
||||
rows = db.execute(
|
||||
"""SELECT ledger_name, parent_name, reserved_name, closing_balance, is_cash_candidate
|
||||
FROM cash_payment_ledger_cache
|
||||
WHERE client_id=? AND tally_guid=?
|
||||
ORDER BY is_cash_candidate DESC, lower(ledger_name), ledger_name""",
|
||||
(int(client_id), str(tally_guid or "")),
|
||||
).fetchall()
|
||||
return [{
|
||||
"name": str(row[0] or ""),
|
||||
"parent": str(row[1] or ""),
|
||||
"reserved_name": str(row[2] or ""),
|
||||
"closing_balance": row[3],
|
||||
"is_cash_candidate": bool(row[4]),
|
||||
} for row in rows]
|
||||
|
||||
def _cash_payment_ledgers(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Backward-compatible non-blocking ledger request.
|
||||
|
||||
If a cached ledger master exists it is returned immediately. Otherwise a
|
||||
background master-sync job is started and the caller receives its job id.
|
||||
"""
|
||||
client_id = int(payload.get("client_id"))
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
tally_guid = str(company.get("guid") or payload.get("tally_guid") or "").strip()
|
||||
cached = self._cash_payment_cached_ledgers(client_id, tally_guid)
|
||||
if cached:
|
||||
return {
|
||||
"status": "cached",
|
||||
"company": company,
|
||||
"company_name": company_name,
|
||||
"ledgers": cached,
|
||||
"cash_candidates": [x for x in cached if x["is_cash_candidate"]],
|
||||
"ledger_count": len(cached),
|
||||
"from_cache": True,
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
return self._cash_payment_ledgers_start(payload)
|
||||
|
||||
def _cash_payment_ledgers_start(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
tally_guid = str(company.get("guid") or payload.get("tally_guid") or "").strip()
|
||||
self._ensure_cash_ledger_schema(client_id)
|
||||
|
||||
cached = self._cash_payment_cached_ledgers(client_id, tally_guid)
|
||||
force_refresh = bool(payload.get("force_refresh"))
|
||||
if cached and not force_refresh:
|
||||
return {
|
||||
"status": "cached",
|
||||
"company": company,
|
||||
"company_name": company_name,
|
||||
"ledgers": cached,
|
||||
"cash_candidates": [x for x in cached if x["is_cash_candidate"]],
|
||||
"ledger_count": len(cached),
|
||||
"from_cache": True,
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
with self.store.connect(client_id) as db:
|
||||
running = db.execute(
|
||||
"""SELECT job_id FROM cash_payment_ledger_jobs
|
||||
WHERE client_id=? AND tally_guid=? AND status IN ('queued','running')
|
||||
ORDER BY started_at_utc DESC LIMIT 1""",
|
||||
(client_id, tally_guid),
|
||||
).fetchone()
|
||||
if running:
|
||||
return self._cash_payment_ledgers_status({**payload, "job_id": str(running[0])})
|
||||
|
||||
job_id = "CASHLEDGER-" + uuid.uuid4().hex[:16].upper()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db.execute(
|
||||
"""INSERT INTO cash_payment_ledger_jobs
|
||||
(job_id, client_id, tally_guid, company_name, status, stage, percent, ledger_count, error, started_at_utc, finished_at_utc)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(job_id, client_id, tally_guid, company_name, "queued", "Queued for Tally ledger master sync", 2, 0, "", now, ""),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
_cash_progress_snapshot({
|
||||
"job_id": job_id,
|
||||
"job_kind": "ledger_master",
|
||||
"status": "queued",
|
||||
"stage": "Queued for Tally ledger master sync",
|
||||
"percent": 2,
|
||||
"company_name": company_name,
|
||||
"ledger_count": 0,
|
||||
})
|
||||
|
||||
def worker():
|
||||
try:
|
||||
with self.store.connect(client_id) as db:
|
||||
db.execute(
|
||||
"UPDATE cash_payment_ledger_jobs SET status='running',stage=?,percent=? WHERE job_id=?",
|
||||
("Connected to Tally; reading ledger master", 15, job_id),
|
||||
)
|
||||
db.commit()
|
||||
_cash_progress_snapshot({
|
||||
"job_id": job_id, "job_kind": "ledger_master", "status": "running",
|
||||
"stage": "Connected to Tally; reading ledger master", "percent": 15,
|
||||
"company_name": company_name, "ledger_count": 0,
|
||||
})
|
||||
# The Tally export is intentionally isolated in this background thread
|
||||
# so websocket/tunnel keepalives and the HTTP command ACK remain responsive.
|
||||
rows = self.tally.export_master_collection(company_name, "ledgers") or []
|
||||
ledgers = self._cash_ledger_rows(rows)
|
||||
_cash_progress_snapshot({
|
||||
"job_id": job_id, "job_kind": "ledger_master", "status": "running",
|
||||
"stage": f"Received {len(ledgers)} ledgers; saving local cache", "percent": 82,
|
||||
"company_name": company_name, "ledger_count": len(ledgers),
|
||||
})
|
||||
synced = datetime.now(timezone.utc).isoformat()
|
||||
with self.store.connect(client_id) as db:
|
||||
db.execute(
|
||||
"DELETE FROM cash_payment_ledger_cache WHERE client_id=? AND tally_guid=?",
|
||||
(client_id, tally_guid),
|
||||
)
|
||||
db.executemany(
|
||||
"""INSERT INTO cash_payment_ledger_cache
|
||||
(client_id,tally_guid,company_name,ledger_name,parent_name,reserved_name,closing_balance,is_cash_candidate,synced_at_utc)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
[(
|
||||
client_id, tally_guid, company_name, row["name"], row["parent"],
|
||||
row["reserved_name"], row.get("closing_balance"),
|
||||
1 if row["is_cash_candidate"] else 0, synced,
|
||||
) for row in ledgers],
|
||||
)
|
||||
db.execute(
|
||||
"""UPDATE cash_payment_ledger_jobs
|
||||
SET status='completed',stage=?,percent=100,ledger_count=?,finished_at_utc=?
|
||||
WHERE job_id=?""",
|
||||
(f"{len(ledgers)} ledgers cached and ready", len(ledgers), synced, job_id),
|
||||
)
|
||||
db.commit()
|
||||
self.logger.info(
|
||||
"Cash ledger master sync completed client_id=%s company=%s rows=%s",
|
||||
client_id, company_name, len(ledgers),
|
||||
)
|
||||
_cash_progress_snapshot({
|
||||
"job_id": job_id, "job_kind": "ledger_master", "status": "completed",
|
||||
"stage": f"{len(ledgers)} ledgers cached and ready", "percent": 100,
|
||||
"company_name": company_name, "ledger_count": len(ledgers),
|
||||
})
|
||||
except Exception as exc:
|
||||
finished = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with self.store.connect(client_id) as db:
|
||||
db.execute(
|
||||
"""UPDATE cash_payment_ledger_jobs
|
||||
SET status='failed',stage='Ledger master sync failed',error=?,finished_at_utc=?
|
||||
WHERE job_id=?""",
|
||||
(str(exc), finished, job_id),
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
self.logger.exception("Cash ledger master background sync failed job_id=%s: %s", job_id, exc)
|
||||
_cash_progress_snapshot({
|
||||
"job_id": job_id, "job_kind": "ledger_master", "status": "failed",
|
||||
"stage": "Ledger master sync failed", "percent": 100,
|
||||
"company_name": company_name, "ledger_count": 0, "error": str(exc),
|
||||
})
|
||||
finally:
|
||||
with _CASH_LEDGER_THREADS_LOCK:
|
||||
_CASH_LEDGER_THREADS.pop(job_id, None)
|
||||
|
||||
thread = threading.Thread(target=worker, name=f"cash-ledgers-{job_id[-6:]}", daemon=True)
|
||||
with _CASH_LEDGER_THREADS_LOCK:
|
||||
_CASH_LEDGER_THREADS[job_id] = thread
|
||||
thread.start()
|
||||
return self._cash_payment_ledgers_status({**payload, "job_id": job_id})
|
||||
|
||||
def _cash_payment_ledgers_status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
job_id = str(payload.get("job_id") or "").strip()
|
||||
if not job_id:
|
||||
raise ValueError("Cash ledger master job id is required.")
|
||||
self._ensure_cash_ledger_schema(client_id)
|
||||
with self.store.connect(client_id) as db:
|
||||
row = db.execute(
|
||||
"""SELECT job_id,tally_guid,company_name,status,stage,percent,ledger_count,error,started_at_utc,finished_at_utc
|
||||
FROM cash_payment_ledger_jobs WHERE job_id=? AND client_id=?""",
|
||||
(job_id, client_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("Cash ledger master job was not found.")
|
||||
job = {
|
||||
"job_id": str(row[0]), "tally_guid": str(row[1]), "company_name": str(row[2]),
|
||||
"status": str(row[3]), "stage": str(row[4]), "percent": int(row[5] or 0),
|
||||
"ledger_count": int(row[6] or 0), "error": str(row[7] or ""),
|
||||
"started_at_utc": str(row[8] or ""), "finished_at_utc": str(row[9] or ""),
|
||||
}
|
||||
ledgers: list[dict[str, Any]] = []
|
||||
if job["status"] == "completed":
|
||||
ledgers = self._cash_payment_cached_ledgers(client_id, job["tally_guid"])
|
||||
return {
|
||||
"job": job,
|
||||
"ledgers": ledgers,
|
||||
"cash_candidates": [x for x in ledgers if x["is_cash_candidate"]],
|
||||
"ledger_count": len(ledgers),
|
||||
"ledger_count": len(ledgers) if ledgers else job["ledger_count"],
|
||||
"company_name": job["company_name"],
|
||||
"from_cache": bool(ledgers),
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ function gb(n){return (n/1073741824).toFixed(1)+' GB'}
|
||||
function setProgress(title,pct,text,active=true){let b=document.getElementById('progressBox');b.style.display='block';document.getElementById('progressTitle').textContent=title;let p=Math.max(0,Math.min(100,Number(pct)||0));lastServerPercent=Math.max(lastServerPercent,p);document.getElementById('progressPct').textContent=Math.round(p)+'%';document.getElementById('progressFill').style.width=p+'%';document.getElementById('progressMsg').textContent=text||'';document.getElementById('progressFill').className='progressfill'+(active&&p<100?' pulse':'')}
|
||||
function applyProgress(u){let p=(u&&u.progress)||{};if(!p.operation)return;let title=p.operation==='check'?'Checking for Updates':p.operation==='download'?'Downloading Update':'Applying Update';setProgress(title,p.percent||0,p.message||p.phase||'',!['updated','downloaded','current','available','error'].includes(p.status));if(p.operation==='install'&&p.target_version){installTarget=p.target_version}}
|
||||
function setCashStep(id,state){let e=document.getElementById(id);e.className='cashstep'+(state?' '+state:'')}
|
||||
async function refreshCashProgress(){try{const r=await fetch('/api/cash-progress',{cache:'no-store'});const d=await r.json();const j=d.job||{};if(!j.job_id){document.getElementById('cashJobSection').style.display='none';return}document.getElementById('cashJobSection').style.display='block';let p=Math.max(0,Math.min(100,Number(j.percent)||0));document.getElementById('cashJobPct').textContent=Math.round(p)+'%';document.getElementById('cashJobFill').style.width=p+'%';document.getElementById('cashJobStage').textContent=j.stage||j.status||'Working…';document.getElementById('cashJobSub').textContent=(j.company_name||'Tally company')+(j.cash_ledgers&&j.cash_ledgers.length?' • Cash ledger: '+j.cash_ledgers.join(', '):'');document.getElementById('cashDays').textContent=String(j.completed_days||0)+' / '+String(j.total_days||0);document.getElementById('cashVouchers').textContent=String(j.vouchers_cached||0);document.getElementById('cashRequests').textContent=String(j.tally_requests||0);document.getElementById('cashDate').textContent=j.current_date||'—';let st=String(j.stage||'').toLowerCase(),status=String(j.status||'').toLowerCase();setCashStep('cs1',(p>=10?'done':(p>0?'run':'')));setCashStep('cs2',(p>=20?'done':(st.includes('ledger')?'run':'')));setCashStep('cs3',(p>=93?'done':(p>=20?'run':'')));setCashStep('cs4',(status==='completed'?'done':(st.includes('analys')?'run':'')));setCashStep('cs5',(status==='completed'?'done':''));let er=document.getElementById('cashError');if(j.error){er.style.display='block';er.textContent=j.error}else{er.style.display='none'}}catch(e){}}
|
||||
async function refreshCashProgress(){try{const r=await fetch('/api/cash-progress',{cache:'no-store'});const d=await r.json();const j=d.job||{};if(!j.job_id){document.getElementById('cashJobSection').style.display='none';return}document.getElementById('cashJobSection').style.display='block';let p=Math.max(0,Math.min(100,Number(j.percent)||0));document.getElementById('cashJobPct').textContent=Math.round(p)+'%';document.getElementById('cashJobFill').style.width=p+'%';document.getElementById('cashJobStage').textContent=j.stage||j.status||'Working…';let isLedger=String(j.job_kind||'')==='ledger_master';document.querySelector('#cashJobSection h2').textContent=isLedger?'Cash Payment — Tally Ledger Sync':'Cash Payment Compliance';document.getElementById('cashJobSub').textContent=(j.company_name||'Tally company')+(j.cash_ledgers&&j.cash_ledgers.length?' • Cash ledger: '+j.cash_ledgers.join(', '):'');if(isLedger){document.getElementById('cashDays').parentElement.querySelector('.label').textContent='Ledgers';document.getElementById('cashDays').textContent=String(j.ledger_count||0);document.getElementById('cashVouchers').parentElement.querySelector('.label').textContent='Status';document.getElementById('cashVouchers').textContent=j.status||'working';document.getElementById('cashRequests').parentElement.querySelector('.label').textContent='Progress';document.getElementById('cashRequests').textContent=Math.round(p)+'%';document.getElementById('cashDate').parentElement.querySelector('.label').textContent='Source';document.getElementById('cashDate').textContent='Tally ledger master';}else{document.getElementById('cashDays').parentElement.querySelector('.label').textContent='Days';document.getElementById('cashDays').textContent=String(j.completed_days||0)+' / '+String(j.total_days||0);document.getElementById('cashVouchers').parentElement.querySelector('.label').textContent='Cash vouchers';document.getElementById('cashVouchers').textContent=String(j.vouchers_cached||0);document.getElementById('cashRequests').parentElement.querySelector('.label').textContent='Tally requests';document.getElementById('cashRequests').textContent=String(j.tally_requests||0);document.getElementById('cashDate').parentElement.querySelector('.label').textContent='Current date';document.getElementById('cashDate').textContent=j.current_date||'—';}let st=String(j.stage||'').toLowerCase(),status=String(j.status||'').toLowerCase();if(isLedger){setCashStep('cs1',(p>=15?'done':(p>0?'run':'')));setCashStep('cs2',(status==='completed'?'done':'run'));setCashStep('cs3','');setCashStep('cs4','');setCashStep('cs5',(status==='completed'?'done':''));}else{setCashStep('cs1',(p>=10?'done':(p>0?'run':'')));setCashStep('cs2',(p>=20?'done':(st.includes('ledger')?'run':'')));setCashStep('cs3',(p>=93?'done':(p>=20?'run':'')));setCashStep('cs4',(status==='completed'?'done':(st.includes('analys')?'run':'')));setCashStep('cs5',(status==='completed'?'done':''));}let er=document.getElementById('cashError');if(j.error){er.style.display='block';er.textContent=j.error}else{er.style.display='none'}}catch(e){}}
|
||||
async function refresh(){try{const r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw new Error('status unavailable');const s=await r.json();offlineTicks=0;
|
||||
let c=s.agent.connection_state||'unknown';let ce=document.getElementById('connection');ce.textContent=c.charAt(0).toUpperCase()+c.slice(1);ce.className='value '+(c==='connected'?'ok':'bad');
|
||||
document.getElementById('heartbeat').textContent=s.agent.last_heartbeat_utc?'Last heartbeat: '+s.agent.last_heartbeat_utc:'No heartbeat recorded';
|
||||
|
||||
Reference in New Issue
Block a user