Unify accounting analysis on local SQLite mirror

This commit is contained in:
A R R R Associates
2026-09-06 20:13:51 +05:30
parent 6c2a2081ed
commit 7adbbde220
11 changed files with 848 additions and 129 deletions
+265 -28
View File
@@ -1,13 +1,17 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from io import BytesIO
import json
import textwrap
from pathlib import Path
import re
from urllib.parse import quote
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse
from sqlalchemy import select
from openpyxl import Workbook
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
@@ -213,6 +217,7 @@ def tally_tool(
online = _node_online(node)
live_result = None
analysis_history = []
command_error = error or ""
should_query_agent = bool(refresh or selected_client)
@@ -236,6 +241,17 @@ def tally_tool(
if response_data.get("ok"):
live_result = response_data.get("result") or {}
_enrich_gstin_display(live_result, registrations)
try:
hist = request_agent_command(
node.node_code,
"accounting_analysis_history",
{**payload, "limit": 20},
timeout_seconds=20,
)
if hist.get("ok"):
analysis_history = (hist.get("result") or {}).get("runs") or []
except Exception:
analysis_history = []
else:
command_error = str(response_data.get("error") or "Local agent command failed.")
except Exception as exc:
@@ -265,6 +281,161 @@ def tally_tool(
transaction_date_from=transaction_date_from,
transaction_date_to=transaction_date_to,
command_error=command_error,
analysis_history=analysis_history,
)
finally:
db.close()
def _analysis_flat_lines(value, prefix=""):
lines = []
if isinstance(value, dict):
for key, item in value.items():
label = f"{prefix}.{key}" if prefix else str(key)
lines.extend(_analysis_flat_lines(item, label))
elif isinstance(value, list):
for idx, item in enumerate(value, 1):
label = f"{prefix}[{idx}]"
lines.extend(_analysis_flat_lines(item, label))
else:
lines.append((prefix, "" if value is None else str(value)))
return lines
def _simple_pdf_bytes(title: str, lines: list[str]) -> bytes:
def esc(text):
return str(text).replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
page_chunks = [lines[i:i+48] for i in range(0, max(1, len(lines)), 48)] or [[]]
objects = []
# Object 1 catalog, 2 pages, 3 font. Page/content objects follow.
page_refs = []
obj_no = 4
page_objects = []
for chunk in page_chunks:
page_no = obj_no
content_no = obj_no + 1
page_refs.append(f"{page_no} 0 R")
content = ["BT", "/F1 10 Tf", "48 790 Td", f"({esc(title)}) Tj", "0 -20 Td"]
for line in chunk:
content.append(f"({esc(line[:150])}) Tj")
content.append("0 -14 Td")
content.append("ET")
stream = "\n".join(content).encode("latin-1", "replace")
page_objects.append((page_no, f"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 3 0 R >> >> /MediaBox [0 0 595 842] /Contents {content_no} 0 R >>".encode()))
page_objects.append((content_no, b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream"))
obj_no += 2
objects.append((1, b"<< /Type /Catalog /Pages 2 0 R >>"))
objects.append((2, f"<< /Type /Pages /Kids [{' '.join(page_refs)}] /Count {len(page_refs)} >>".encode()))
objects.append((3, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"))
objects.extend(page_objects)
objects.sort()
out = bytearray(b"%PDF-1.4\n")
offsets = {0: 0}
for number, body in objects:
offsets[number] = len(out)
out.extend(f"{number} 0 obj\n".encode())
out.extend(body)
out.extend(b"\nendobj\n")
xref = len(out)
max_obj = max(offsets)
out.extend(f"xref\n0 {max_obj+1}\n".encode())
out.extend(b"0000000000 65535 f \n")
for i in range(1, max_obj + 1):
out.extend(f"{offsets.get(i,0):010d} 00000 n \n".encode())
out.extend(f"trailer\n<< /Size {max_obj+1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF".encode())
return bytes(out)
@router.get("/analysis/{analysis_run_id}/download")
def download_analysis_report(
request: Request,
analysis_run_id: int,
client_id: int,
financial_year: str,
format: str = "xlsx",
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return response
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _denied()
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return RedirectResponse(
url=f"/tools/tally?client_id={client_id}&error={quote('ERP Local Agent is offline for the active branch.')}",
status_code=303,
)
result = request_agent_command(
node.node_code,
"accounting_analysis_get",
{
**_accounting_storage_payload(client, financial_year),
"run_id": int(analysis_run_id),
},
timeout_seconds=30,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Analysis report was not found."))
run = (result.get("result") or {}).get("run") or {}
safe_type = re.sub(r"[^A-Za-z0-9_-]+", "_", str(run.get("analysis_type") or "analysis")).strip("_")
filename_base = f"{safe_type}_{analysis_run_id}"
if str(format).lower() == "pdf":
lines = [
f"Client: {client.client_name}",
f"Analysis: {run.get('analysis_type','')}",
f"Financial year: {run.get('financial_year','')}",
f"Period: {run.get('period_from','')} to {run.get('period_to','')}",
f"Company: {run.get('company_name','')}",
f"Completed: {run.get('completed_at_utc','')}",
"",
"Summary",
]
for key, value in (run.get("summary") or {}).items():
lines.append(f"{str(key).replace('_',' ').title()}: {value}")
lines.append("")
lines.append("Details")
for key, value in _analysis_flat_lines(run.get("result") or {}):
lines.append(f"{key}: {value}")
payload = _simple_pdf_bytes(f"{safe_type.replace('_',' ')} Report", lines)
return StreamingResponse(
BytesIO(payload),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename_base}.pdf"'},
)
wb = Workbook()
ws = wb.active
ws.title = "Summary"
ws.append(["Field", "Value"])
for row in (
("Client", client.client_name),
("Analysis Type", run.get("analysis_type", "")),
("Financial Year", run.get("financial_year", "")),
("Period From", run.get("period_from", "")),
("Period To", run.get("period_to", "")),
("Company", run.get("company_name", "")),
("Completed At UTC", run.get("completed_at_utc", "")),
):
ws.append(list(row))
ws.append([])
ws.append(["Summary", ""])
for key, value in (run.get("summary") or {}).items():
ws.append([str(key).replace("_", " ").title(), value])
details = wb.create_sheet("Details")
details.append(["Path", "Value"])
for key, value in _analysis_flat_lines(run.get("result") or {}):
details.append([key, value])
stream = BytesIO()
wb.save(stream)
stream.seek(0)
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename_base}.xlsx"'},
)
finally:
db.close()
@@ -621,7 +792,6 @@ def full_accounting_export_status(request: Request, client_id: int):
def depreciation_it_tool(
request: Request,
client_id: int | None = None,
tally_guid: str = "",
fy_start: str = "",
fy_end: str = "",
calculated: int = 0,
@@ -633,32 +803,100 @@ def depreciation_it_tool(
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.depreciation_it")
if response: return response
if response:
return response
clients, scope = _visible_clients(db, request, user)
selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
online = _node_online(node)
today = date.today(); start_year = today.year if today.month >= 4 else today.year - 1
start_text = fy_start or date(start_year,4,1).isoformat(); end_text = fy_end or date(start_year+1,3,31).isoformat()
live_result = None; preview = None; depreciation_run = None; command_error = error or ""
today = date.today()
start_year = today.year if today.month >= 4 else today.year - 1
start_text = fy_start or date(start_year, 4, 1).isoformat()
end_text = fy_end or date(start_year + 1, 3, 31).isoformat()
preview = None
depreciation_run = None
mirror_status = None
command_error = error or ""
analysis_history = []
if selected_client and node and online:
storage_payload = _accounting_storage_payload(
selected_client, _financial_year_for_date(date.fromisoformat(start_text))
)
try:
status_response = request_agent_command(node.node_code,"phase6_status",_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),timeout_seconds=20)
if status_response.get("ok"): live_result=status_response.get("result") or {}
else: command_error=str(status_response.get("error") or "Local Agent status failed.")
chosen_guid=str(tally_guid or "").strip()
if chosen_guid:
preview_response=request_agent_command(node.node_code,"accounting_depreciation_preview",{**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),"tally_guid":chosen_guid,"fy_start":start_text,"fy_end":end_text},timeout_seconds=60)
if preview_response.get("ok"): preview=(preview_response.get("result") or {}).get("preview")
else: command_error=str(preview_response.get("error") or "Depreciation preview failed.")
status_response = request_agent_command(
node.node_code, "accounting_mirror_status", storage_payload, timeout_seconds=20
)
if status_response.get("ok"):
mirror_status = status_response.get("result") or {}
else:
command_error = str(status_response.get("error") or "Accounting Mirror status failed.")
if mirror_status and (mirror_status.get("mirror") or {}).get("ready"):
preview_response = request_agent_command(
node.node_code,
"accounting_depreciation_preview",
{
**storage_payload,
"fy_start": start_text,
"fy_end": end_text,
"requested_by_user_id": int(user.id),
},
timeout_seconds=60,
)
if preview_response.get("ok"):
preview = (preview_response.get("result") or {}).get("preview")
else:
command_error = str(preview_response.get("error") or "Depreciation preview failed.")
if run_id:
run_response=request_agent_command(node.node_code,"accounting_get_it_depreciation_run",{**_accounting_storage_payload(selected_client, _financial_year_for_date(date.fromisoformat(start_text))),"run_id":int(run_id)},timeout_seconds=30)
if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation")
except Exception as exc: command_error=str(exc)
base={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}
base.update({"title":"Income-tax Depreciation","clients":clients,"selected_client":selected_client,"storage_node":node,"agent_online":online,"live_result":live_result,"preview":preview,"selected_tally_guid":str(tally_guid or ""),"fy_start":start_text,"fy_end":end_text,"calculated":bool(calculated),"approved":bool(approved),"posted":bool(posted),"depreciation_run":depreciation_run,"command_error":command_error})
return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html",base)
finally: db.close()
run_response = request_agent_command(
node.node_code,
"accounting_get_it_depreciation_run",
{**storage_payload, "run_id": int(run_id)},
timeout_seconds=30,
)
if run_response.get("ok"):
depreciation_run = (run_response.get("result") or {}).get("depreciation")
hist = request_agent_command(
node.node_code,
"accounting_analysis_history",
{**storage_payload, "analysis_type": "DEPRECIATION_IT", "limit": 20},
timeout_seconds=20,
)
if hist.get("ok"):
analysis_history = (hist.get("result") or {}).get("runs") or []
except Exception as exc:
command_error = str(exc)
base = {
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
}
base.update({
"title": "Income-tax Depreciation",
"clients": clients,
"selected_client": selected_client,
"storage_node": node,
"agent_online": online,
"mirror_status": mirror_status,
"preview": preview,
"fy_start": start_text,
"fy_end": end_text,
"calculated": bool(calculated),
"approved": bool(approved),
"posted": bool(posted),
"depreciation_run": depreciation_run,
"analysis_history": analysis_history,
"command_error": command_error,
})
return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html", base)
finally:
db.close()
@router.post("/depreciation/calculate")
@@ -673,7 +911,6 @@ async def calculate_it_depreciation(request: Request):
except Exception as exc: return RedirectResponse(url=f"/tools/tally/depreciation?error={quote('Invalid client or financial-year dates.')}",status_code=303)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client: return _denied()
tally_guid=str(form.get("tally_guid") or "").strip()
assignments=[]
for key,value in form.multi_items():
if not str(key).startswith("rate__"): continue
@@ -682,14 +919,14 @@ async def calculate_it_depreciation(request: Request):
except Exception: rate=-1
assignments.append({"ledger_key":ledger_key,"rate_percent":rate})
node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id)
if not node or not _node_online(node): return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303)
if not node or not _node_online(node): return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303)
try:
result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{**_accounting_storage_payload(client, _financial_year_for_date(start)),"tally_guid":tally_guid,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"requested_by_user_id":int(user.id)},timeout_seconds=120)
result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{**_accounting_storage_payload(client, _financial_year_for_date(start)),"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"financial_year":_financial_year_for_date(start),"requested_by_user_id":int(user.id)},timeout_seconds=120)
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Income-tax depreciation calculation failed."))
dep=(result.get("result") or {}).get("depreciation") or {}; rid=int(dep.get("run_id"))
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote(str(exc))}",status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&calculated=1&run_id={rid}",status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote(str(exc))}",status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&calculated=1&run_id={rid}",status_code=303)
finally: db.close()
@@ -723,7 +960,7 @@ async def approve_it_depreciation(request: Request):
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed."))
dep = (result.get("result") or {}).get("depreciation") or {}
guid = quote(str(dep.get("tally_guid") or ""))
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&approved=1", status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&approved=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
finally:
@@ -761,7 +998,7 @@ async def post_it_depreciation_to_tally(request: Request):
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed."))
dep = (result.get("result") or {}).get("depreciation") or {}
guid = quote(str(dep.get("tally_guid") or ""))
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&posted=1", status_code=303)
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&posted=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
finally: