diff --git a/app/modules/accounting/cash_payment_ui.py b/app/modules/accounting/cash_payment_ui.py index e7b594f..9bb7a1d 100644 --- a/app/modules/accounting/cash_payment_ui.py +++ b/app/modules/accounting/cash_payment_ui.py @@ -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() diff --git a/app/modules/accounting/opening_balance_service.py b/app/modules/accounting/opening_balance_service.py index 106e88f..65d44bf 100644 --- a/app/modules/accounting/opening_balance_service.py +++ b/app/modules/accounting/opening_balance_service.py @@ -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( diff --git a/app/modules/accounting/opening_balance_ui.py b/app/modules/accounting/opening_balance_ui.py index 25a67ce..67b6877 100644 --- a/app/modules/accounting/opening_balance_ui.py +++ b/app/modules/accounting/opening_balance_ui.py @@ -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, diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html index fed7c77..c9a7d79 100644 --- a/app/modules/accounting/templates/accounting/cash_payment_review.html +++ b/app/modules/accounting/templates/accounting/cash_payment_review.html @@ -49,8 +49,13 @@ {% if selected_cash_ledger_name %}{% endif %} + + +
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.
+ -Reads the Tally ledger master and lets you confirm the exact Cash ledger.
@@ -200,27 +205,81 @@ const ledgerSelect = document.getElementById('cash-ledger-select'); const ledgerHelp = document.getElementById('cash-ledger-help'); - async function loadCashLedgers() { + 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 = ''; + (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) ready from '+(data.company_name || 'Tally')+(data.from_cache?' (local cache).':'.')+' Confirm the Cash ledger before extraction.'; + } + + 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); - ledgerButton.disabled = true; ledgerHelp.textContent = 'Reading Tally ledger master…'; + 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.'); - 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 ((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', loadCashLedgers); + if (ledgerButton) ledgerButton.addEventListener('click', function(){loadCashLedgers(false);}); + if (ledgerRefreshButton) ledgerRefreshButton.addEventListener('click', function(){loadCashLedgers(true);}); function show() { overlay.classList.remove('hidden'); diff --git a/app/modules/accounting/templates/accounting/opening_balances.html b/app/modules/accounting/templates/accounting/opening_balances.html index c55fc64..299f0eb 100644 --- a/app/modules/accounting/templates/accounting/opening_balances.html +++ b/app/modules/accounting/templates/accounting/opening_balances.html @@ -6,7 +6,7 @@Accounting · Phase 21A v2
- 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.