Unify accounting analysis on local SQLite mirror
This commit is contained in:
+265
-28
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user