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")
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(
+100 -1
View File
@@ -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 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' : '');
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 = '<option value="">Confirm Cash ledger</option>';
(data.ledgers || []).forEach(function(row){
const opt=document.createElement('option'); opt.value=row.name; opt.textContent=row.name + (row.parent ? ' — '+row.parent : '') + (row.is_cash_candidate ? ' • Cash candidate' : '');
if ((previous && previous===row.name) || (!previous && row.is_cash_candidate && !ledgerSelect.value)) opt.selected=true;
ledgerSelect.appendChild(opt);
});
if (!ledgerSelect.value && (data.cash_candidates||[]).length===1) ledgerSelect.value=data.cash_candidates[0].name;
ledgerHelp.textContent = String(data.ledger_count || 0)+' ledger(s) loaded from '+(data.company_name || 'Tally')+'. Confirm the Cash ledger before extraction.';
} catch(err) { ledgerHelp.textContent = err && err.message ? err.message : String(err); }
finally { ledgerButton.disabled=false; }
if ((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');
@@ -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>